style: improve gallery card
This commit is contained in:
		
							parent
							
								
									f8e2b43e79
								
							
						
					
					
						commit
						94cf46e7f8
					
				
					 3 changed files with 236 additions and 97 deletions
				
			
		| 
						 | 
					@ -1,11 +1,12 @@
 | 
				
			||||||
import type { Document } from "dbtype";
 | 
					import type { Document } from "dbtype";
 | 
				
			||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
 | 
					import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
 | 
				
			||||||
import TagBadge from "@/components/gallery/TagBadge.tsx";
 | 
					import TagBadge, { toPrettyTagname } from "@/components/gallery/TagBadge.tsx";
 | 
				
			||||||
import { Fragment, useLayoutEffect, useRef, useState } from "react";
 | 
					import { Fragment, useLayoutEffect, useRef, useState } from "react";
 | 
				
			||||||
import { LazyImage } from "./LazyImage.tsx";
 | 
					import { LazyImage } from "./LazyImage.tsx";
 | 
				
			||||||
import StyledLink from "./StyledLink.tsx";
 | 
					import StyledLink from "./StyledLink.tsx";
 | 
				
			||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { Skeleton } from "../ui/skeleton.tsx";
 | 
					import { Skeleton } from "../ui/skeleton.tsx";
 | 
				
			||||||
 | 
					import { Palette, Users, Trash2, Clock, LayersIcon } from "lucide-react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function clipTagsWhenOverflow(tags: string[], limit: number) {
 | 
					function clipTagsWhenOverflow(tags: string[], limit: number) {
 | 
				
			||||||
    let l = 0;
 | 
					    let l = 0;
 | 
				
			||||||
| 
						 | 
					@ -19,6 +20,7 @@ function clipTagsWhenOverflow(tags: string[], limit: number) {
 | 
				
			||||||
    return tags;
 | 
					    return tags;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function GalleryCardImpl({
 | 
					function GalleryCardImpl({
 | 
				
			||||||
    doc: x
 | 
					    doc: x
 | 
				
			||||||
}: { doc: Document; }) {
 | 
					}: { doc: Document; }) {
 | 
				
			||||||
| 
						 | 
					@ -36,9 +38,36 @@ function GalleryCardImpl({
 | 
				
			||||||
        const listener = () => {
 | 
					        const listener = () => {
 | 
				
			||||||
            if (ref.current) {
 | 
					            if (ref.current) {
 | 
				
			||||||
                const { width } = ref.current.getBoundingClientRect();
 | 
					                const { width } = ref.current.getBoundingClientRect();
 | 
				
			||||||
                const charWidth = 7; // rough estimate
 | 
					                const canvas = document.createElement("canvas");
 | 
				
			||||||
                const newClipCharCount = Math.floor(width / charWidth) * 3;
 | 
					                const context = canvas.getContext("2d");
 | 
				
			||||||
                setClipCharCount(newClipCharCount);
 | 
					                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();
 | 
					        listener();
 | 
				
			||||||
| 
						 | 
					@ -46,46 +75,104 @@ function GalleryCardImpl({
 | 
				
			||||||
        return () => {
 | 
					        return () => {
 | 
				
			||||||
            window.removeEventListener("resize", listener);
 | 
					            window.removeEventListener("resize", listener);
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
    }, []);
 | 
					    }, [originalTags]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return <Card className="flex h-[200px]">
 | 
					    return <Card className="flex h-[200px] overflow-hidden transition-all duration-200 hover:shadow-lg hover:shadow-primary/20 group">
 | 
				
			||||||
        {isDeleted ? <div className="bg-primary border flex items-center justify-center h-[200px] w-[142px] rounded-xl">
 | 
					            {isDeleted ? (
 | 
				
			||||||
            <span className="text-primary-foreground text-lg font-bold">Deleted</span>
 | 
					                <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> : <div className="rounded-xl overflow-hidden h-[200px] w-[142px] flex-none bg-[#272733] flex items-center justify-center">
 | 
					                    <div className="flex flex-col items-center gap-2 text-primary-foreground">
 | 
				
			||||||
            <LazyImage src={`/api/doc/${x.id}/comic/thumbnail`}
 | 
					                        <Trash2 className="h-8 w-8 opacity-80" />
 | 
				
			||||||
                alt={x.title}
 | 
					                        <span className="text-sm font-medium">Deleted</span>
 | 
				
			||||||
                className="max-h-full max-w-full object-cover object-center"
 | 
					                    </div>
 | 
				
			||||||
            />
 | 
					                </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">
 | 
				
			||||||
        <div className="flex-1 flex flex-col">
 | 
					                    <LazyImage 
 | 
				
			||||||
            <CardHeader className="flex-none">
 | 
					                        src={`/api/doc/${x.id}/comic/thumbnail`}
 | 
				
			||||||
                <CardTitle>
 | 
					                        alt={x.title}
 | 
				
			||||||
                    <StyledLink className="line-clamp-2" to={`/doc/${x.id}`}>
 | 
					                        className="max-h-full max-w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
 | 
				
			||||||
                        {x.title}
 | 
					                    />
 | 
				
			||||||
                    </StyledLink>
 | 
					                    <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">
 | 
				
			||||||
                </CardTitle>
 | 
					                        <LayersIcon className="h-3 w-3" />
 | 
				
			||||||
                <CardDescription>
 | 
					                        <span>{x.pagenum}</span>
 | 
				
			||||||
                    {artists.map((x, i) => <Fragment key={`artist:${x}`}>
 | 
					                    </div>
 | 
				
			||||||
                        <StyledLink to={`/search?allow_tag=artist:${x}`}>{x}</StyledLink>
 | 
					                </div>
 | 
				
			||||||
                        {i + 1 < artists.length && <span className="opacity-50">, </span>}
 | 
					            )}
 | 
				
			||||||
                    </Fragment>)}
 | 
					            
 | 
				
			||||||
                    {groups.length > 0 && <span key={"sep"}>{" | "}</span>}
 | 
					            <div className="flex-1 flex flex-col">
 | 
				
			||||||
                    {groups.map((x, i) => <Fragment key={`group:${x}`}>
 | 
					                <CardHeader className="flex-none">
 | 
				
			||||||
                        <StyledLink to={`/search?allow_tag=group:${x}`}>{x}</StyledLink>
 | 
					                    <CardTitle className="group-hover:text-primary transition-colors duration-200">
 | 
				
			||||||
                        {i + 1 < groups.length && <span className="opacity-50">, </span>}
 | 
					                        <StyledLink className="line-clamp-2 font-bold" to={`/doc/${x.id}`}>
 | 
				
			||||||
                    </Fragment>
 | 
					                            {x.title}
 | 
				
			||||||
                    )}
 | 
					                        </StyledLink>
 | 
				
			||||||
                </CardDescription>
 | 
					                    </CardTitle>
 | 
				
			||||||
            </CardHeader>
 | 
					                    <CardDescription className="flex flex-wrap items-center gap-x-3">
 | 
				
			||||||
            <CardContent className="flex-1 overflow-hidden">
 | 
					                        {artists.length > 0 && (
 | 
				
			||||||
                <ul ref={ref} className="flex flex-wrap gap-2 items-baseline content-start">
 | 
					                            <div className="flex items-center gap-1.5">
 | 
				
			||||||
                    {clippedTags.map(tag => <TagBadge key={tag} tagname={tag} className="" />)}
 | 
					                                <Palette className="h-3.5 w-3.5 text-primary/70" />
 | 
				
			||||||
                    {clippedTags.length < originalTags.length && <TagBadge key={"..."} tagname="..." className="inline-block" disabled />}
 | 
					                                <span className="flex flex-wrap items-center">
 | 
				
			||||||
                </ul>
 | 
					                                    {artists.map((x, i) => (
 | 
				
			||||||
            </CardContent>
 | 
					                                        <Fragment key={`artist:${x}`}>
 | 
				
			||||||
        </div>
 | 
					                                            <StyledLink 
 | 
				
			||||||
    </Card>;
 | 
					                                                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({
 | 
					export function GalleryCardSkeleton({
 | 
				
			||||||
| 
						 | 
					@ -104,7 +191,7 @@ export function GalleryCardSkeleton({
 | 
				
			||||||
                <ul className="flex flex-wrap gap-2 items-baseline content-start">
 | 
					                <ul className="flex flex-wrap gap-2 items-baseline content-start">
 | 
				
			||||||
                    {Array.from({ length: tagCount }).map((_, i) => <Skeleton key={i}
 | 
					                    {Array.from({ length: tagCount }).map((_, i) => <Skeleton key={i}
 | 
				
			||||||
                        style={{ width: `${Math.random() * 100 + 50}px` }}
 | 
					                        style={{ width: `${Math.random() * 100 + 50}px` }}
 | 
				
			||||||
                    className="h-4" />)}
 | 
					                        className="h-4" />)}
 | 
				
			||||||
                </ul>
 | 
					                </ul>
 | 
				
			||||||
            </CardContent>
 | 
					            </CardContent>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,13 +9,26 @@ export function LazyImage({ src, alt, className }: { src: string; alt: string; c
 | 
				
			||||||
            const observer = new IntersectionObserver((entries) => {
 | 
					            const observer = new IntersectionObserver((entries) => {
 | 
				
			||||||
                if (entries.some(x => x.isIntersecting)) {
 | 
					                if (entries.some(x => x.isIntersecting)) {
 | 
				
			||||||
                    setLoaded(true);
 | 
					                    setLoaded(true);
 | 
				
			||||||
                    ref.current?.animate([
 | 
					                    if (ref.current?.complete) {
 | 
				
			||||||
                        { opacity: 0 },
 | 
					                        ref.current?.animate([
 | 
				
			||||||
                        { opacity: 1 }
 | 
					                            { opacity: 0 },
 | 
				
			||||||
                    ], {
 | 
					                            { opacity: 1 }
 | 
				
			||||||
                        duration: 300,
 | 
					                        ], {
 | 
				
			||||||
                        easing: "ease-in-out"
 | 
					                            duration: 300,
 | 
				
			||||||
                    });
 | 
					                            easing: "ease-in-out"
 | 
				
			||||||
 | 
					                        });
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    else {
 | 
				
			||||||
 | 
					                        ref.current?.addEventListener("load", () => {
 | 
				
			||||||
 | 
					                            ref.current?.animate([
 | 
				
			||||||
 | 
					                                { opacity: 0 },
 | 
				
			||||||
 | 
					                                { opacity: 1 }
 | 
				
			||||||
 | 
					                            ], {
 | 
				
			||||||
 | 
					                                duration: 300,
 | 
				
			||||||
 | 
					                                easing: "ease-in-out"
 | 
				
			||||||
 | 
					                            });
 | 
				
			||||||
 | 
					                        });
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
                    observer.disconnect();
 | 
					                    observer.disconnect();
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }, {
 | 
					            }, {
 | 
				
			||||||
| 
						 | 
					@ -34,5 +47,7 @@ export function LazyImage({ src, alt, className }: { src: string; alt: string; c
 | 
				
			||||||
        src={loaded ? src : undefined}
 | 
					        src={loaded ? src : undefined}
 | 
				
			||||||
        alt={alt}
 | 
					        alt={alt}
 | 
				
			||||||
        className={className}
 | 
					        className={className}
 | 
				
			||||||
        loading="lazy" />;
 | 
					        loading="lazy" 
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        />;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,8 +6,8 @@ import { useSearchGalleryInfinite } from "../hook/useSearchGallery.ts";
 | 
				
			||||||
import { Spinner } from "../components/Spinner.tsx";
 | 
					import { Spinner } from "../components/Spinner.tsx";
 | 
				
			||||||
import TagInput from "@/components/gallery/TagInput.tsx";
 | 
					import TagInput from "@/components/gallery/TagInput.tsx";
 | 
				
			||||||
import { useEffect, useRef, useState } from "react";
 | 
					import { useEffect, useRef, useState } from "react";
 | 
				
			||||||
import { Separator } from "@/components/ui/separator.tsx";
 | 
					 | 
				
			||||||
import { useVirtualizer } from "@tanstack/react-virtual";
 | 
					import { useVirtualizer } from "@tanstack/react-virtual";
 | 
				
			||||||
 | 
					import { SearchIcon, TagIcon, AlertCircle, ImageIcon } from "lucide-react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Gallery() {
 | 
					export default function Gallery() {
 | 
				
			||||||
    const search = useSearch();
 | 
					    const search = useSearch();
 | 
				
			||||||
| 
						 | 
					@ -24,13 +24,13 @@ export default function Gallery() {
 | 
				
			||||||
    const parentRef = useRef<HTMLDivElement>(null);
 | 
					    const parentRef = useRef<HTMLDivElement>(null);
 | 
				
			||||||
    const virtualizer = useVirtualizer({
 | 
					    const virtualizer = useVirtualizer({
 | 
				
			||||||
        count: size,
 | 
					        count: size,
 | 
				
			||||||
        // biome-ignore lint/style/noNonNullAssertion: <explanation>
 | 
					        // biome-ignore lint/style/noNonNullAssertion: could not be null
 | 
				
			||||||
        getScrollElement: () => parentRef.current!,
 | 
					        getScrollElement: () => parentRef.current!,
 | 
				
			||||||
        estimateSize: (index) => {
 | 
					        estimateSize: (index) => {
 | 
				
			||||||
            if (!data) return 8;
 | 
					            if (!data) return 8;
 | 
				
			||||||
            const docs = data?.[index];
 | 
					            const docs = data?.[index];
 | 
				
			||||||
            if (!docs) return 8;
 | 
					            if (!docs) return 8;
 | 
				
			||||||
            return docs.data.length * (200 + 8) + 37 + 8;
 | 
					            return docs.data.length * (200 + 8) + 32 + 8 + 8 * 2; // 200px for image, 8px for gap, 32px for title, 8px for padding
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        overscan: 1,
 | 
					        overscan: 1,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
| 
						 | 
					@ -60,7 +60,11 @@ export default function Gallery() {
 | 
				
			||||||
        const NoResult = data.reduce((acc, x) => acc + x.data.length, 0) === 0;
 | 
					        const NoResult = data.reduce((acc, x) => acc + x.data.length, 0) === 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (NoResult) {
 | 
					        if (NoResult) {
 | 
				
			||||||
            return <div className="p-4 text-3xl">No results</div>
 | 
					            return <div className="flex flex-col items-center justify-center p-12 text-center">
 | 
				
			||||||
 | 
					                <ImageIcon className="w-16 h-16 text-muted-foreground mb-4 opacity-50" />
 | 
				
			||||||
 | 
					                <h3 className="text-3xl font-semibold text-muted-foreground mb-2">검색 결과가 없습니다</h3>
 | 
				
			||||||
 | 
					                <p className="text-muted-foreground">다른 검색어나 태그로 시도해보세요</p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        else {
 | 
					        else {
 | 
				
			||||||
            return <div className="w-full relative overflow-auto h-full"
 | 
					            return <div className="w-full relative overflow-auto h-full"
 | 
				
			||||||
| 
						 | 
					@ -69,30 +73,32 @@ export default function Gallery() {
 | 
				
			||||||
                    virtualItems.map((item) => {
 | 
					                    virtualItems.map((item) => {
 | 
				
			||||||
                        const isLoaderRow = item.index === size - 1 && isLoadingMore;
 | 
					                        const isLoaderRow = item.index === size - 1 && isLoadingMore;
 | 
				
			||||||
                        if (isLoaderRow) {
 | 
					                        if (isLoaderRow) {
 | 
				
			||||||
                            return <div key={item.index} className="w-full flex justify-center top-0 left-0 absolute"
 | 
					                            return <div key={item.index}
 | 
				
			||||||
 | 
					                                className="w-full flex justify-center top-0 left-0 absolute p-8"
 | 
				
			||||||
                                style={{
 | 
					                                style={{
 | 
				
			||||||
                                    height: `${item.size}px`,
 | 
					                                    height: `${item.size}px`,
 | 
				
			||||||
                                    transform: `translateY(${item.start}px)`
 | 
					                                    transform: `translateY(${item.start}px)`
 | 
				
			||||||
                                }}>
 | 
					                                }}>
 | 
				
			||||||
                                <Spinner />
 | 
					                                <span className="text-muted-foreground"><Spinner />컨텐츠를 불러오는 중...</span>
 | 
				
			||||||
                            </div>;
 | 
					                            </div>;
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                        const docs = data[item.index];
 | 
					                        const docs = data[item.index];
 | 
				
			||||||
                        if (!docs) return null;
 | 
					                        if (!docs) return null;
 | 
				
			||||||
                        return <div className="w-full grid gap-2 content-start top-0 left-0 absolute mb-2" key={item.index}
 | 
					                        return <div className="w-full grid gap-2 content-start top-0 left-0 absolute mb-4" key={item.index}
 | 
				
			||||||
                            style={{
 | 
					                            style={{
 | 
				
			||||||
                                height: `${item.size}px`,
 | 
					                                height: `${item.size}px`,
 | 
				
			||||||
                                transform: `translateY(${item.start}px)`
 | 
					                                transform: `translateY(${item.start}px)`
 | 
				
			||||||
                            }}>
 | 
					                            }}>
 | 
				
			||||||
                            {docs.startCursor && <div>
 | 
					                            {docs.startCursor && (
 | 
				
			||||||
                                <h3 className="text-3xl">Start with {docs.startCursor}</h3>
 | 
					                                <div className="bg-muted/50 rounded-lg p-2">
 | 
				
			||||||
                                <Separator />
 | 
					                                    <h3 className="text-2xl font-medium flex items-center gap-2">
 | 
				
			||||||
                            </div>}
 | 
					                                        ID <span className="text-primary">{docs.startCursor}</span>이하
 | 
				
			||||||
                            {docs?.data?.map((x) => {
 | 
					                                    </h3>
 | 
				
			||||||
                                return (
 | 
					                                </div>
 | 
				
			||||||
                                    <GalleryCard doc={x} key={x.id} />
 | 
					                            )}
 | 
				
			||||||
                                );
 | 
					                            {docs?.data?.map((x) => (
 | 
				
			||||||
                            })}
 | 
					                                <GalleryCard doc={x} key={x.id} />
 | 
				
			||||||
 | 
					                            ))}
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                    })
 | 
					                    })
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
| 
						 | 
					@ -102,20 +108,41 @@ export default function Gallery() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (<div className="p-4 grid gap-2 h-dvh overflow-auto items-start content-start" ref={parentRef}>
 | 
					    return (<div className="p-4 grid gap-2 h-dvh overflow-auto items-start content-start" ref={parentRef}>
 | 
				
			||||||
        <Search />
 | 
					        <Search />
 | 
				
			||||||
        {(word || tags) &&
 | 
					
 | 
				
			||||||
            <div className="bg-primary rounded-full p-1 sticky top-0 shadow-md z-10">
 | 
					        {((word ?? "").length > 0 || tags.length > 0) &&
 | 
				
			||||||
                {word && <span className="text-primary-foreground ml-4">Search: {word}</span>}
 | 
					            <div className="bg-primary rounded-full p-2 mt-3 shadow-lg flex flex-wrap items-center gap-2">
 | 
				
			||||||
                {tags && <span className="text-primary-foreground ml-4">Tags: <ul className="inline-flex gap-1">{
 | 
					                {word && (
 | 
				
			||||||
                    tags.map(x => <TagBadge tagname={x} key={x} />)}
 | 
					                    <div className="flex items-center bg-primary-foreground/20 rounded-full px-3 py-1 text-primary-foreground">
 | 
				
			||||||
                </ul></span>}
 | 
					                        <SearchIcon className="w-4 h-4 mr-2" />
 | 
				
			||||||
 | 
					                        <span className="font-medium">{word}</span>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					                {tags && tags.length > 0 && (
 | 
				
			||||||
 | 
					                    <div className="flex items-center flex-wrap gap-1">
 | 
				
			||||||
 | 
					                        <TagIcon className="w-4 h-4 text-primary-foreground ml-2" />
 | 
				
			||||||
 | 
					                        <ul className="inline-flex flex-wrap gap-1 ml-1">
 | 
				
			||||||
 | 
					                            {tags.map(x => <TagBadge tagname={x} key={x} />)}
 | 
				
			||||||
 | 
					                        </ul>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        {error && <div className="p-4">Error: {String(error)}</div>}
 | 
					
 | 
				
			||||||
        {isLoading && <>
 | 
					        {error && (
 | 
				
			||||||
            <GalleryCardSkeleton />
 | 
					            <div className="p-6 bg-destructive/10 rounded-lg flex items-center">
 | 
				
			||||||
            <GalleryCardSkeleton />
 | 
					                <AlertCircle className="w-6 h-6 text-destructive mr-2" />
 | 
				
			||||||
            <GalleryCardSkeleton />
 | 
					                <div className="text-destructive font-medium">{String(error)}</div>
 | 
				
			||||||
        </>}
 | 
					            </div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {isLoading && (
 | 
				
			||||||
 | 
					            <div className="grid gap-2">
 | 
				
			||||||
 | 
					                <GalleryCardSkeleton />
 | 
				
			||||||
 | 
					                <GalleryCardSkeleton />
 | 
				
			||||||
 | 
					                <GalleryCardSkeleton />
 | 
				
			||||||
 | 
					                <GalleryCardSkeleton />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
        {renderContent()}
 | 
					        {renderContent()}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
| 
						 | 
					@ -127,22 +154,32 @@ function Search() {
 | 
				
			||||||
    const searchParams = new URLSearchParams(search);
 | 
					    const searchParams = new URLSearchParams(search);
 | 
				
			||||||
    const [tags, setTags] = useState(searchParams.get("allow_tag")?.split(",") ?? []);
 | 
					    const [tags, setTags] = useState(searchParams.get("allow_tag")?.split(",") ?? []);
 | 
				
			||||||
    const [word, setWord] = useState(searchParams.get("word") ?? "");
 | 
					    const [word, setWord] = useState(searchParams.get("word") ?? "");
 | 
				
			||||||
    return <div className="flex space-x-2">
 | 
					    return <div className="flex flex-col sm:flex-row gap-3">
 | 
				
			||||||
        <TagInput className="flex-1" input={word} onInputChange={setWord}
 | 
					        <TagInput
 | 
				
			||||||
            tags={tags} onTagsChange={setTags}
 | 
					            className="flex-1 shadow-sm"
 | 
				
			||||||
 | 
					            input={word}
 | 
				
			||||||
 | 
					            onInputChange={setWord}
 | 
				
			||||||
 | 
					            tags={tags}
 | 
				
			||||||
 | 
					            onTagsChange={setTags}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        <Button className="flex-none" onClick={() => {
 | 
					        <Button
 | 
				
			||||||
            const params = new URLSearchParams();
 | 
					            className="flex-none gap-2 px-4 shadow-md hover:shadow-lg transition-all"
 | 
				
			||||||
            if (tags.length > 0) {
 | 
					            onClick={() => {
 | 
				
			||||||
                for (const tag of tags) {
 | 
					                const params = new URLSearchParams();
 | 
				
			||||||
                    params.append("allow_tag", tag);
 | 
					                if (tags.length > 0) {
 | 
				
			||||||
 | 
					                    for (const tag of tags) {
 | 
				
			||||||
 | 
					                        params.append("allow_tag", tag);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					                if (word) {
 | 
				
			||||||
            if (word) {
 | 
					                    params.set("word", word);
 | 
				
			||||||
                params.set("word", word);
 | 
					                }
 | 
				
			||||||
            }
 | 
					                navigate(`/search?${params.toString()}`);
 | 
				
			||||||
            navigate(`/search?${params.toString()}`);
 | 
					            }}
 | 
				
			||||||
        }}>Search</Button>
 | 
					        >
 | 
				
			||||||
    </div>;
 | 
					            <SearchIcon className="w-4 h-4" />
 | 
				
			||||||
 | 
					            검색
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		
		Reference in a new issue