diff --git a/packages/client/src/components/gallery/GalleryCard.tsx b/packages/client/src/components/gallery/GalleryCard.tsx index da9a4a6..ed8b61d 100644 --- a/packages/client/src/components/gallery/GalleryCard.tsx +++ b/packages/client/src/components/gallery/GalleryCard.tsx @@ -7,6 +7,7 @@ import StyledLink from "./StyledLink.tsx"; import React from "react"; import { Skeleton } from "../ui/skeleton.tsx"; import { Palette, Users, Trash2, Clock, LayersIcon } from "lucide-react"; +import { cn } from "@/lib/utils.ts"; function clipTagsWhenOverflow(tags: string[], limit: number) { let l = 0; @@ -22,8 +23,12 @@ function clipTagsWhenOverflow(tags: string[], limit: number) { function GalleryCardImpl({ - doc: x -}: { doc: Document; }) { + doc: x, + className, +}: { + doc: Document; + className?: string; +}) { const ref = useRef(null); const [clipCharCount, setClipCharCount] = useState(200); const isDeleted = x.deleted_at !== null; @@ -77,103 +82,108 @@ function GalleryCardImpl({ }; }, [originalTags]); - return - {isDeleted ? ( -
-
- - Deleted -
+ 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 && ( - - )} -
-
- + ) : ( +
+ +
+ + {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({ @@ -181,18 +191,19 @@ export function GalleryCardSkeleton({ }: { tagCount?: number; }) { - return - + return +
- - - + + + - -
    + +
      {Array.from({ length: tagCount }).map((_, i) => )} + style={{ width: `${Math.random() * 80 + 40}px` }} + className="h-3 sm:h-4" />)}
diff --git a/packages/client/src/components/layout/layout.tsx b/packages/client/src/components/layout/layout.tsx index 439ac7d..c7db00f 100644 --- a/packages/client/src/components/layout/layout.tsx +++ b/packages/client/src/components/layout/layout.tsx @@ -30,7 +30,7 @@ export default function Layout({ children }: LayoutProps) { {/* Main Content */} -
+
{children}
diff --git a/packages/client/src/page/galleryPage.tsx b/packages/client/src/page/galleryPage.tsx index 94d3028..dfe7621 100644 --- a/packages/client/src/page/galleryPage.tsx +++ b/packages/client/src/page/galleryPage.tsx @@ -6,8 +6,9 @@ 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 { useVirtualizer } from "@tanstack/react-virtual"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; import { SearchIcon, TagIcon, AlertCircle, ImageIcon } from "lucide-react"; +import { useMediaQuery } from "usehooks-ts"; export default function Gallery() { const search = useSearch(); @@ -16,21 +17,24 @@ export default function Gallery() { const tags = searchParams.getAll("allow_tag") ?? undefined; const limit = searchParams.get("limit"); const cursor = searchParams.get("cursor"); + const isSmallScreen = useMediaQuery('(max-width: 640px)'); const { data, error, isLoading, size, setSize } = useSearchGalleryInfinite({ word, tags, limit: limit ? Number.parseInt(limit) : undefined, cursor: cursor ? Number.parseInt(cursor) : undefined }); const parentRef = useRef(null); - const virtualizer = useVirtualizer({ + const virtualizer = useWindowVirtualizer({ count: size, - // biome-ignore lint/style/noNonNullAssertion: could not be null - getScrollElement: () => parentRef.current!, + scrollMargin: parentRef.current?.offsetTop ?? 0, estimateSize: (index) => { if (!data) return 8; const docs = data?.[index]; if (!docs) return 8; - return docs.data.length * (200 + 8) + 32 + 8 + 8 * 2; // 200px for image, 8px for gap, 32px for title, 8px for padding + const cardHeight = isSmallScreen ? 409.5 : 200; + const gap = 8; + const headerHeight = docs.startCursor ? (isSmallScreen ? 44 : 56) : 0; + return docs.data.length * (cardHeight + gap) + headerHeight + gap * 2; }, overscan: 1, }); @@ -60,44 +64,56 @@ export default function Gallery() { const NoResult = data.reduce((acc, x) => acc + x.data.length, 0) === 0; if (NoResult) { - return
- -

검색 결과가 없습니다

-

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

+ return
+ +

검색 결과가 없습니다

+

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

} else { return
+ style={{ + height: `${virtualizer.getTotalSize()}px` + }} + > {// TODO: date based grouping virtualItems.map((item) => { const isLoaderRow = item.index === size - 1 && isLoadingMore; if (isLoaderRow) { return
- 컨텐츠를 불러오는 중... + transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)` + }} + data-index={item.index} + > + + 컨텐츠를 불러오는 중... +
; } const docs = data[item.index]; if (!docs) return null; - return
+ }} + data-index={item.index} + ref={virtualizer.measureElement} + > {docs.startCursor && ( -
-

+
+

ID {docs.startCursor}이하

)} {docs?.data?.map((x) => ( - + ))}

}) @@ -106,20 +122,20 @@ export default function Gallery() { } } - return (
+ return (
{((word ?? "").length > 0 || tags.length > 0) && -
+
{word && ( -
- +
+ {word}
)} {tags && tags.length > 0 && (
- +
    {tags.map(x => )}
@@ -129,8 +145,8 @@ export default function Gallery() { } {error && ( -
- +
+
{String(error)}
)} @@ -154,7 +170,7 @@ function Search() { const searchParams = new URLSearchParams(search); const [tags, setTags] = useState(searchParams.get("allow_tag")?.split(",") ?? []); const [word, setWord] = useState(searchParams.get("word") ?? ""); - return
+ return
- + 검색