diff --git a/packages/client/src/components/gallery/GalleryCard.tsx b/packages/client/src/components/gallery/GalleryCard.tsx index da3da6f..18462c8 100644 --- a/packages/client/src/components/gallery/GalleryCard.tsx +++ b/packages/client/src/components/gallery/GalleryCard.tsx @@ -1,11 +1,12 @@ import type { Document } from "dbtype"; 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 { 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; @@ -19,6 +20,7 @@ function clipTagsWhenOverflow(tags: string[], limit: number) { return tags; } + function GalleryCardImpl({ doc: x }: { doc: Document; }) { @@ -36,9 +38,36 @@ function GalleryCardImpl({ const listener = () => { if (ref.current) { const { width } = ref.current.getBoundingClientRect(); - const charWidth = 7; // rough estimate - const newClipCharCount = Math.floor(width / charWidth) * 3; - setClipCharCount(newClipCharCount); + 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(); @@ -46,46 +75,104 @@ function GalleryCardImpl({ return () => { window.removeEventListener("resize", listener); }; - }, []); + }, [originalTags]); - return - {isDeleted ?
- Deleted -
:
- -
- } -
- - - - {x.title} - - - - {artists.map((x, i) => - {x} - {i + 1 < artists.length && , } - )} - {groups.length > 0 && {" | "}} - {groups.map((x, i) => - {x} - {i + 1 < groups.length && , } - - )} - - - -
    - {clippedTags.map(tag => )} - {clippedTags.length < originalTags.length && } -
-
-
-
; + return + {isDeleted ? ( +
+
+ + Deleted +
+
+ ) : ( +
+ +
+ + {x.pagenum} +
+
+ )} + +
+ + + + {x.title} + + + + {artists.length > 0 && ( +
+ + + {artists.map((x, i) => ( + + + {x} + + {i + 1 < artists.length && ,} + + ))} + +
+ )} + + {groups.length > 0 && ( +
+ + + {groups.map((x, i) => ( + + + {x} + + {i + 1 < groups.length && ,} + + ))} + +
+ )} + +
+ + {new Date(x.created_at).toLocaleDateString()} +
+
+
+ + +
    + {clippedTags.map(tag => ( + + ))} + {clippedTags.length < originalTags.length && ( + + )} +
+
+
+
} export function GalleryCardSkeleton({ @@ -104,7 +191,7 @@ export function GalleryCardSkeleton({ diff --git a/packages/client/src/components/gallery/LazyImage.tsx b/packages/client/src/components/gallery/LazyImage.tsx index fd6ea7c..ee19a93 100644 --- a/packages/client/src/components/gallery/LazyImage.tsx +++ b/packages/client/src/components/gallery/LazyImage.tsx @@ -9,13 +9,26 @@ export function LazyImage({ src, alt, className }: { src: string; alt: string; c const observer = new IntersectionObserver((entries) => { if (entries.some(x => x.isIntersecting)) { setLoaded(true); - ref.current?.animate([ - { opacity: 0 }, - { opacity: 1 } - ], { - duration: 300, - easing: "ease-in-out" - }); + if (ref.current?.complete) { + ref.current?.animate([ + { opacity: 0 }, + { opacity: 1 } + ], { + 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(); } }, { @@ -34,5 +47,7 @@ export function LazyImage({ src, alt, className }: { src: string; alt: string; c src={loaded ? src : undefined} alt={alt} className={className} - loading="lazy" />; + loading="lazy" + + />; } diff --git a/packages/client/src/page/galleryPage.tsx b/packages/client/src/page/galleryPage.tsx index 007b28d..3528232 100644 --- a/packages/client/src/page/galleryPage.tsx +++ b/packages/client/src/page/galleryPage.tsx @@ -6,8 +6,8 @@ import { useSearchGalleryInfinite } from "../hook/useSearchGallery.ts"; import { Spinner } from "../components/Spinner.tsx"; import TagInput from "@/components/gallery/TagInput.tsx"; import { useEffect, useRef, useState } from "react"; -import { Separator } from "@/components/ui/separator.tsx"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { SearchIcon, TagIcon, AlertCircle, ImageIcon } from "lucide-react"; export default function Gallery() { const search = useSearch(); @@ -24,13 +24,13 @@ export default function Gallery() { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: size, - // biome-ignore lint/style/noNonNullAssertion: + // biome-ignore lint/style/noNonNullAssertion: could not be null getScrollElement: () => parentRef.current!, estimateSize: (index) => { if (!data) return 8; const docs = data?.[index]; 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, }); @@ -51,16 +51,20 @@ export default function Gallery() { virtualizer.measure(); }, [virtualizer, data]); - + const renderContent = () => { if (!data) { return null; } const isLoadingMore = data && size > 0 && (data[size - 1] === undefined); const NoResult = data.reduce((acc, x) => acc + x.data.length, 0) === 0; - + if (NoResult) { - return
No results
+ return
+ +

검색 결과가 없습니다

+

다른 검색어나 태그로 시도해보세요

+
} else { return
{ const isLoaderRow = item.index === size - 1 && isLoadingMore; if (isLoaderRow) { - return
- + 컨텐츠를 불러오는 중...
; } const docs = data[item.index]; if (!docs) return null; - return
- {docs.startCursor &&
-

Start with {docs.startCursor}

- -
} - {docs?.data?.map((x) => { - return ( - - ); - })} + {docs.startCursor && ( +
+

+ ID {docs.startCursor}이하 +

+
+ )} + {docs?.data?.map((x) => ( + + ))}
}) } @@ -102,20 +108,41 @@ export default function Gallery() { return (
- {(word || tags) && -
- {word && Search: {word}} - {tags && Tags:
    { - tags.map(x => )} -
} + + {((word ?? "").length > 0 || tags.length > 0) && +
+ {word && ( +
+ + {word} +
+ )} + {tags && tags.length > 0 && ( +
+ +
    + {tags.map(x => )} +
+
+ )}
} - {error &&
Error: {String(error)}
} - {isLoading && <> - - - - } + + {error && ( +
+ +
{String(error)}
+
+ )} + + {isLoading && ( +
+ + + + +
+ )} {renderContent()}
); @@ -127,22 +154,32 @@ function Search() { const searchParams = new URLSearchParams(search); const [tags, setTags] = useState(searchParams.get("allow_tag")?.split(",") ?? []); const [word, setWord] = useState(searchParams.get("word") ?? ""); - return
- + - -
; + if (word) { + params.set("word", word); + } + navigate(`/search?${params.toString()}`); + }} + > + + 검색 + +
}