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); |