201 lines
No EOL
9.7 KiB
TypeScript
201 lines
No EOL
9.7 KiB
TypeScript
import type { Document } from "dbtype";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
|
import TagBadge, { toPrettyTagname } from "@/components/gallery/TagBadge.tsx";
|
|
import { Fragment, useLayoutEffect, useRef, useState } from "react";
|
|
import { LazyImage } from "./LazyImage.tsx";
|
|
import StyledLink from "./StyledLink.tsx";
|
|
import React from "react";
|
|
import { Skeleton } from "../ui/skeleton.tsx";
|
|
import { Palette, Users, Trash2, Clock, LayersIcon } from "lucide-react";
|
|
|
|
function clipTagsWhenOverflow(tags: string[], limit: number) {
|
|
let l = 0;
|
|
for (let i = 0; i < tags.length; i++) {
|
|
l += tags[i].length;
|
|
if (l > limit) {
|
|
return tags.slice(0, i);
|
|
}
|
|
l += 1; // for space
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
|
|
function GalleryCardImpl({
|
|
doc: x
|
|
}: { doc: Document; }) {
|
|
const ref = useRef<HTMLUListElement>(null);
|
|
const [clipCharCount, setClipCharCount] = useState(200);
|
|
const isDeleted = x.deleted_at !== null;
|
|
|
|
const artists = x.tags.filter(x => x.startsWith("artist:")).map(x => x.replace("artist:", ""));
|
|
const groups = x.tags.filter(x => x.startsWith("group:")).map(x => x.replace("group:", ""));
|
|
|
|
const originalTags = x.tags.filter(x => !x.startsWith("artist:") && !x.startsWith("group:"));
|
|
const clippedTags = clipTagsWhenOverflow(originalTags, clipCharCount);
|
|
|
|
useLayoutEffect(() => {
|
|
const listener = () => {
|
|
if (ref.current) {
|
|
const { width } = ref.current.getBoundingClientRect();
|
|
const canvas = document.createElement("canvas");
|
|
const context = canvas.getContext("2d");
|
|
if (context) {
|
|
// 스타일에 맞는 폰트 설정 (Tailwind의 기본 폰트 스타일에 맞게 설정)
|
|
context.font = getComputedStyle(ref.current).font || "16px sans-serif";
|
|
|
|
let totalWidth = 0;
|
|
let charCount = 0;
|
|
|
|
for (const tag of originalTags) {
|
|
// prefix와 패딩을 고려한 너비 계산
|
|
const prettyTag = toPrettyTagname(tag); // prefix가 포함된 태그 이름
|
|
const tagWidth =
|
|
context.measureText(prettyTag).width + 8; // 양쪽 패딩 4px씩 추가
|
|
const spaceWidth = context.measureText(" ").width; // 공백 너비
|
|
|
|
if (totalWidth + tagWidth + spaceWidth > width * 3) { // 3줄 제한
|
|
break;
|
|
}
|
|
totalWidth += tagWidth + spaceWidth;
|
|
charCount += tag.length + 1; // 태그 길이 + 공백
|
|
}
|
|
|
|
setClipCharCount(charCount);
|
|
}
|
|
else {
|
|
const charWidth = 7; // rough estimate
|
|
const newClipCharCount = Math.floor(width / charWidth) * 3;
|
|
setClipCharCount(newClipCharCount);
|
|
}
|
|
}
|
|
};
|
|
listener();
|
|
window.addEventListener("resize", listener);
|
|
return () => {
|
|
window.removeEventListener("resize", listener);
|
|
};
|
|
}, [originalTags]);
|
|
|
|
return <Card className="flex h-[200px] overflow-hidden transition-all duration-200 hover:shadow-lg hover:shadow-primary/20 group">
|
|
{isDeleted ? (
|
|
<div className="bg-gradient-to-br from-red-500/20 to-red-800/30 flex items-center justify-center h-[200px] w-[142px] rounded-l-xl border-r border-border/50">
|
|
<div className="flex flex-col items-center gap-2 text-primary-foreground">
|
|
<Trash2 className="h-8 w-8 opacity-80" />
|
|
<span className="text-sm font-medium">Deleted</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="relative rounded-l-xl overflow-hidden h-[200px] w-[142px] flex-none bg-gradient-to-br from-primary/5 to-primary/10 flex items-center justify-center group-hover:from-primary/10 group-hover:to-primary/20 transition-all duration-300">
|
|
<LazyImage
|
|
src={`/api/doc/${x.id}/comic/thumbnail`}
|
|
alt={x.title}
|
|
className="max-h-full max-w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
|
|
/>
|
|
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1 opacity-80">
|
|
<LayersIcon className="h-3 w-3" />
|
|
<span>{x.pagenum}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 flex flex-col">
|
|
<CardHeader className="flex-none">
|
|
<CardTitle className="group-hover:text-primary transition-colors duration-200">
|
|
<StyledLink className="line-clamp-2 font-bold" to={`/doc/${x.id}`}>
|
|
{x.title}
|
|
</StyledLink>
|
|
</CardTitle>
|
|
<CardDescription className="flex flex-wrap items-center gap-x-3">
|
|
{artists.length > 0 && (
|
|
<div className="flex items-center gap-1.5">
|
|
<Palette className="h-3.5 w-3.5 text-primary/70" />
|
|
<span className="flex flex-wrap items-center">
|
|
{artists.map((x, i) => (
|
|
<Fragment key={`artist:${x}`}>
|
|
<StyledLink
|
|
to={`/search?allow_tag=artist:${x}`}
|
|
className="hover:text-primary hover:underline transition-colors"
|
|
>
|
|
{x}
|
|
</StyledLink>
|
|
{i + 1 < artists.length && <span className="opacity-50 mx-1">,</span>}
|
|
</Fragment>
|
|
))}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{groups.length > 0 && (
|
|
<div className="flex items-center gap-1.5">
|
|
<Users className="h-3.5 w-3.5 text-primary/70" />
|
|
<span className="flex flex-wrap items-center">
|
|
{groups.map((x, i) => (
|
|
<Fragment key={`group:${x}`}>
|
|
<StyledLink
|
|
to={`/search?allow_tag=group:${x}`}
|
|
className="hover:text-primary hover:underline transition-colors"
|
|
>
|
|
{x}
|
|
</StyledLink>
|
|
{i + 1 < groups.length && <span className="opacity-50 mx-1">,</span>}
|
|
</Fragment>
|
|
))}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
<span className="text-xs">{new Date(x.created_at).toLocaleDateString()}</span>
|
|
</div>
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent className="flex-1 overflow-hidden">
|
|
<ul ref={ref} className="flex flex-wrap gap-1.5 items-baseline content-start">
|
|
{clippedTags.map(tag => (
|
|
<TagBadge
|
|
key={tag}
|
|
tagname={tag}
|
|
className="transition-all duration-200 hover:shadow-sm hover:scale-105"
|
|
/>
|
|
))}
|
|
{clippedTags.length < originalTags.length && (
|
|
<TagBadge
|
|
key={"..."}
|
|
tagname="..."
|
|
className="inline-block opacity-70"
|
|
disabled
|
|
/>
|
|
)}
|
|
</ul>
|
|
</CardContent>
|
|
</div>
|
|
</Card>
|
|
}
|
|
|
|
export function GalleryCardSkeleton({
|
|
tagCount = 20
|
|
}: {
|
|
tagCount?: number;
|
|
}) {
|
|
return <Card className="flex h-[200px]">
|
|
<Skeleton className="rounded-xl overflow-hidden h-[200px] w-[142px] flex-none" />
|
|
<div className="flex-1 flex flex-col">
|
|
<CardHeader className="flex-none">
|
|
<Skeleton className="line-clamp-2 w-1/2 h-4" />
|
|
<Skeleton className="w-1/4 h-3" />
|
|
</CardHeader>
|
|
<CardContent className="flex-1 overflow-hidden">
|
|
<ul className="flex flex-wrap gap-2 items-baseline content-start">
|
|
{Array.from({ length: tagCount }).map((_, i) => <Skeleton key={i}
|
|
style={{ width: `${Math.random() * 100 + 50}px` }}
|
|
className="h-4" />)}
|
|
</ul>
|
|
</CardContent>
|
|
</div>
|
|
</Card>
|
|
}
|
|
|
|
export const GalleryCard = React.memo(GalleryCardImpl); |