refactor: 개선된 GalleryCard 및 Gallery 컴포넌트 스타일링과 레이아웃 조정

This commit is contained in:
monoid 2025-10-08 03:13:22 +09:00
parent a20297f34e
commit d19bb520ed
3 changed files with 163 additions and 136 deletions

View file

@ -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<HTMLUListElement>(null);
const [clipCharCount, setClipCharCount] = useState(200);
const isDeleted = x.deleted_at !== null;
@ -77,17 +82,22 @@ function GalleryCardImpl({
};
}, [originalTags]);
return <Card className="flex h-[200px] overflow-hidden
transition-all duration-200 hover:shadow-lg hover:shadow-primary/20 group">
return <Card className={cn("flex flex-col sm:flex-row overflow-hidden",
"transition-all duration-200 hover:shadow-lg hover:shadow-primary/20 group",
className
)}>
{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="h-[240px] sm:h-[200px] w-full sm:w-[142px]
bg-gradient-to-br from-red-500/20 to-red-800/30
flex items-center justify-center">
<div className="flex flex-col items-center gap-2 text-primary-foreground">
<Trash2 className="h-8 w-8 opacity-80" />
<Trash2 className="size-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">
<div className="h-[240px] sm:h-[200px] w-full sm:w-[142px]
relative overflow-hidden 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}
@ -100,17 +110,17 @@ function GalleryCardImpl({
</div>
)}
<div className="flex-1 flex flex-col">
<CardHeader className="flex-none">
<div className="flex-1 flex flex-col min-h-0">
<CardHeader className="flex-none pb-2 sm:pb-4">
<CardTitle className="group-hover:text-primary transition-colors duration-200">
<StyledLink className="line-clamp-2 font-bold" to={`/doc/${x.id}`}>
<StyledLink className="line-clamp-1 font-bold sm:line-clamp-2" to={`/doc/${x.id}`}>
{x.title}
</StyledLink>
</CardTitle>
<div className="flex flex-wrap items-center gap-x-3 leading-tight text-sm">
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 leading-tight text-sm">
{artists.length > 0 && (
<div className="flex items-center gap-1.5">
<Palette className="size-3.5 text-primary/70" />
<div className="flex items-center gap-1 sm:gap-1.5">
<Palette className="size-3 sm:size-3.5 text-primary/70" />
<span className="flex flex-wrap items-center">
{artists.map((x, i) => (
<Fragment key={`artist:${x}`}>
@ -128,8 +138,8 @@ function GalleryCardImpl({
)}
{groups.length > 0 && (
<div className="flex items-center gap-1.5">
<Users className="size-3.5 text-primary/70" />
<div className="flex items-center gap-1 sm:gap-1.5">
<Users className="size-3 sm:size-3.5 text-primary/70" />
<span className="flex flex-wrap items-center">
{groups.map((x, i) => (
<Fragment key={`group:${x}`}>
@ -146,15 +156,15 @@ function GalleryCardImpl({
</div>
)}
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="size-3.5" />
<div className="flex items-center gap-1 sm:gap-1.5 text-muted-foreground">
<Clock className="size-3 sm:size-3.5" />
<span className="text-xs">{new Date(x.created_at).toLocaleDateString()}</span>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
<ul ref={ref} className="flex flex-wrap gap-1.5 items-baseline content-start">
<CardContent className="flex-1 overflow-hidden pt-0">
<ul ref={ref} className="flex flex-wrap gap-1 sm:gap-1.5 items-baseline content-start">
{clippedTags.map(tag => (
<TagBadge
key={tag}
@ -181,18 +191,19 @@ export function GalleryCardSkeleton({
}: {
tagCount?: number;
}) {
return <Card className="flex h-[200px] flex-col md:flex-row">
<Skeleton className="rounded-xl overflow-hidden h-[200px] w-[142px] flex-none" />
return <Card className="flex flex-col sm:flex-row">
<Skeleton className="h-[240px] sm:h-[200px] w-full sm:w-[142px]
rounded-xl overflow-hidden 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 className="flex-none pb-2 sm:pb-4">
<Skeleton className="line-clamp-2 w-3/4 sm:w-1/2 h-4 sm:h-5" />
<Skeleton className="w-1/2 sm:w-1/4 h-3 mt-2" />
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
<ul className="flex flex-wrap gap-2 items-baseline content-start">
<CardContent className="flex-1 overflow-hidden pt-0">
<ul className="flex flex-wrap gap-1 sm: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" />)}
style={{ width: `${Math.random() * 80 + 40}px` }}
className="h-3 sm:h-4" />)}
</ul>
</CardContent>
</div>

View file

@ -30,7 +30,7 @@ export default function Layout({ children }: LayoutProps) {
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col min-h-0 p-0">
<main className="flex-1 flex flex-col min-h-0 p-0 md:pb-0 pb-14">
{children}
</main>

View file

@ -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<HTMLDivElement>(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 <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>
return <div className="flex flex-col items-center justify-center p-8 sm:p-12 text-center">
<ImageIcon className="w-12 h-12 sm:w-16 sm:h-16 text-muted-foreground mb-3 sm:mb-4 opacity-50" />
<h3 className="text-xl sm:text-3xl font-semibold text-muted-foreground mb-2"> </h3>
<p className="text-sm sm:text-base text-muted-foreground"> </p>
</div>
}
else {
return <div className="w-full relative"
style={{ height: virtualizer.getTotalSize() }}>
style={{
height: `${virtualizer.getTotalSize()}px`
}}
>
{// TODO: date based grouping
virtualItems.map((item) => {
const isLoaderRow = item.index === size - 1 && isLoadingMore;
if (isLoaderRow) {
return <div key={item.index}
className="w-full flex justify-center top-0 left-0 absolute p-8"
className="w-full flex justify-center top-0 left-0 absolute p-4 sm:p-8"
style={{
height: `${item.size}px`,
transform: `translateY(${item.start}px)`
}}>
<span className="text-muted-foreground"><Spinner /> ...</span>
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`
}}
data-index={item.index}
>
<span className="text-sm sm:text-base text-muted-foreground flex items-center gap-2">
<Spinner /> ...
</span>
</div>;
}
const docs = data[item.index];
if (!docs) return null;
return <div className="w-full grid gap-2 content-start top-0 left-0 absolute mb-4" key={item.index}
return <div className="w-full grid gap-2 content-start top-0 left-0 absolute" key={item.index}
style={{
height: `${item.size}px`,
// height: `${item.size}px`,
transform: `translateY(${item.start}px)`
}}>
}}
data-index={item.index}
ref={virtualizer.measureElement}
>
{docs.startCursor && (
<div className="bg-muted/50 rounded-lg p-2">
<h3 className="text-2xl font-medium flex items-center gap-2">
<div className="bg-muted/50 rounded-lg p-2 sm:p-3">
<h3 className="text-lg sm:text-2xl font-medium flex items-center gap-2">
ID <span className="text-primary">{docs.startCursor}</span>
</h3>
</div>
)}
{docs?.data?.map((x) => (
<GalleryCard doc={x} key={x.id} />
<GalleryCard doc={x} key={x.id}
className="h-[409.5px] sm:h-[200px]"
/>
))}
</div>
})
@ -106,20 +122,20 @@ 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-2 sm:p-4 grid gap-2 items-start content-start" ref={parentRef}>
<Search />
{((word ?? "").length > 0 || tags.length > 0) &&
<div className="bg-primary rounded-full p-2 mt-3 shadow-lg flex flex-wrap items-center gap-2">
<div className="bg-primary rounded-full p-2 mt-2 sm:mt-3 shadow-lg flex flex-wrap items-center gap-1.5 sm:gap-2">
{word && (
<div className="flex items-center bg-primary-foreground/20 rounded-full px-3 py-1 text-primary-foreground">
<SearchIcon className="w-4 h-4 mr-2" />
<div className="flex items-center bg-primary-foreground/20 rounded-full px-2.5 sm:px-3 py-1 text-primary-foreground">
<SearchIcon className="size-3.5 sm:size-4 mr-1.5 sm: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" />
<TagIcon className="size-3.5 sm:size-4 text-primary-foreground ml-1 sm:ml-2" />
<ul className="inline-flex flex-wrap gap-1 ml-1">
{tags.map(x => <TagBadge tagname={x} key={x} />)}
</ul>
@ -129,8 +145,8 @@ export default function Gallery() {
}
{error && (
<div className="p-6 bg-destructive/10 rounded-lg flex items-center">
<AlertCircle className="w-6 h-6 text-destructive mr-2" />
<div className="p-4 sm:p-6 bg-destructive/10 rounded-lg flex items-center">
<AlertCircle className="size-5 sm:size-6 text-destructive mr-2 flex-shrink-0" />
<div className="text-destructive font-medium">{String(error)}</div>
</div>
)}
@ -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 <div className="flex flex-col sm:flex-row gap-3">
return <div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<TagInput
className="flex-1 shadow-sm"
input={word}
@ -177,7 +193,7 @@ function Search() {
navigate(`/search?${params.toString()}`);
}}
>
<SearchIcon className="w-4 h-4" />
<SearchIcon className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</Button>
</div>