feat: reorganize tag page and improve tag-related components

This commit is contained in:
monoid 2025-11-06 00:44:45 +09:00
parent eb06208f80
commit 358cb66780
17 changed files with 1016 additions and 146 deletions

View file

@ -1,6 +1,6 @@
import type { Document } from "dbtype";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import TagBadge, { toPrettyTagname } from "@/components/gallery/TagBadge.tsx";
import TagBadge from "@/components/gallery/TagBadge.tsx";
import { Fragment, useLayoutEffect, useRef, useState } from "react";
import { LazyImage } from "./LazyImage.tsx";
import StyledLink from "./StyledLink.tsx";
@ -8,6 +8,7 @@ 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";
import { toPrettyTagname } from "@/lib/tags.ts";
function clipTagsWhenOverflow(tags: string[], limit: number) {
let l = 0;
@ -166,11 +167,13 @@ function GalleryCardImpl({
<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 => (
<li>
<TagBadge
key={tag}
tagname={tag}
className="transition-all duration-200 hover:shadow-sm hover:scale-105"
/>
</li>
))}
{clippedTags.length < originalTags.length && (
<TagBadge

View file

@ -2,51 +2,7 @@ import { badgeVariants } from "@/components/ui/badge.tsx";
import { Link } from "wouter";
import { cn } from "@/lib/utils.ts";
import { cva } from "class-variance-authority"
enum TagKind {
Default = "default",
Type = "type",
Character = "character",
Series = "series",
Group = "group",
Artist = "artist",
Male = "male",
Female = "female",
}
type TagKindType = `${TagKind}`;
export function getTagKind(tagname: string): TagKindType {
if (tagname.match(":") === null) {
return "default";
}
const prefix = tagname.split(":")[0];
return prefix as TagKindType;
}
export function toPrettyTagname(tagname: string): string {
const kind = getTagKind(tagname);
const name = tagname.slice(kind.length + 1);
switch (kind) {
case "male":
return `${name}`;
case "female":
return `${name}`;
case "artist":
return `🎨 ${name}`;
case "group":
return `🖿 ${name}`;
case "series":
return `📚 ${name}`
case "character":
return `👤 ${name}`;
case "default":
return tagname;
default:
return name;
}
}
import { getTagKind, toPrettyTagname } from "@/lib/tags";
interface TagBadgeProps {
tagname: string;
@ -54,9 +10,9 @@ interface TagBadgeProps {
disabled?: boolean;
}
// eslint-disable-next-line react-refresh/only-export-components
export const tagBadgeVariants = cva(
cn(badgeVariants({ variant: "default"}), "px-1"),
cn(badgeVariants({ variant: "default"}), "px-1 inline-block"),
{
variants: {
variant: {
@ -79,10 +35,12 @@ export const tagBadgeVariants = cva(
export default function TagBadge(props: TagBadgeProps) {
const { tagname } = props;
const kind = getTagKind(tagname);
return <li className={
return <Link
to={props.disabled ? '': `/search?allow_tag=${tagname}`}
className={
cn( tagBadgeVariants({ variant: kind }),
props.disabled && "opacity-50",
props.className,
)
}><Link to={props.disabled ? '': `/search?allow_tag=${tagname}`}>{toPrettyTagname(tagname)}</Link></li>;
}>{toPrettyTagname(tagname)}</Link>;
}

View file

@ -1,10 +1,11 @@
import { cn } from "@/lib/utils";
import { getTagKind, tagBadgeVariants } from "./TagBadge";
import { tagBadgeVariants } from "./TagBadge";
import { useEffect, useRef, useState } from "react";
import { Button } from "../ui/button";
import { Button } from "@/components/ui/button";
import { useOnClickOutside } from "usehooks-ts";
import { useTags } from "@/hook/useTags";
import { Skeleton } from "../ui/skeleton";
import { Skeleton } from "@/components/ui/skeleton";
import { getTagKind } from "@/lib/tags";
interface TagsSelectListProps {
className?: string;

View file

@ -0,0 +1,118 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import type { ChartConfig } from "@/components/ui/chart";
import { Progress } from "@/components/ui/progress";
import { BarChart3 as BarChart3Icon, LayoutList as LayoutListIcon } from "lucide-react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import type { TagKindStat, TagRecord, TopTagChartDatum } from "./types";
import { getTagKind, getTagKindLabel, TagKindType, toPrettyTagname } from "@/lib/tags";
import { useMemo } from "react";
const TOP_TAG_LIMIT = 8;
function useTagChartData(tags: TagRecord[]) {
const totalTagCount = tags.length;
const topTagChartData = useMemo<TopTagChartDatum[]>(() => {
return tags
.slice()
.sort((a, b) => b.occurs - a.occurs)
.slice(0, TOP_TAG_LIMIT)
.map((tag) => {
const pretty = toPrettyTagname(tag.name);
const label = pretty.length > 12 ? `${pretty.slice(0, 12)}` : pretty;
return {
label,
pretty,
occurs: tag.occurs,
};
});
}, [tags]);
const kindStats = useMemo<TagKindStat[]>(() => {
if (!totalTagCount) {
return [];
}
const kindCounts = new Map<TagKindType, number>();
tags.forEach((tag) => {
const kind = getTagKind(tag.name);
kindCounts.set(kind, (kindCounts.get(kind) || 0) + 1);
});
return Array.from(kindCounts.entries())
.map(([kind, count]) => ({
kind,
count,
ratio: Number(((count / totalTagCount) * 100).toFixed(1)),
}))
.sort((a, b) => b.count - a.count);
}, [tags, totalTagCount]);
return { topTagChartData, kindStats };
}
interface TagAnalyticsSidebarProps {
tags: TagRecord[];
chartConfig: ChartConfig;
}
export function TagAnalyticsSidebar({ tags, chartConfig }: TagAnalyticsSidebarProps) {
const { topTagChartData, kindStats } = useTagChartData(tags);
return (
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3Icon className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
{topTagChartData.length > 0 ? (
<ChartContainer config={chartConfig} className="h-64">
<BarChart data={topTagChartData}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="label" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
<YAxis allowDecimals={false} width={36} tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent labelKey="pretty" nameKey="pretty" />} />
<Bar dataKey="occurs" fill="var(--color-occurs)" radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
) : (
<div className="flex h-40 flex-col items-center justify-center text-sm text-muted-foreground">
.
</div>
)}
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<LayoutListIcon className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{kindStats.length > 0 ? (
kindStats.map((stat) => (
<div key={stat.kind} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-foreground">{getTagKindLabel(stat.kind)}</span>
<span className="font-mono text-xs text-muted-foreground">
{stat.count.toLocaleString()} · {stat.ratio}%
</span>
</div>
<Progress value={stat.ratio} />
</div>
))
) : (
<p className="text-sm text-muted-foreground"> .</p>
)}
</CardContent>
</Card>
</div>
);
}
export default TagAnalyticsSidebar;

View file

@ -0,0 +1,58 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CopyCheckIcon, CopyIcon } from "lucide-react";
import { useRef, useState, type ComponentProps } from "react";
interface TagCopyButtonProps {
tagName: string;
variant?: ComponentProps<typeof Button>["variant"];
size?: ComponentProps<typeof Button>["size"];
className?: string;
}
async function copy(text: string) {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
}
}
export function TagCopyButton({ tagName, variant = "ghost", size = "sm", className }: TagCopyButtonProps) {
const [copied, setCopied] = useState(false);
const setTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
return (
<Button variant={variant} size={size} className={cn(className, "gap-2")} onClick={async () => {
await copy(tagName);
setCopied(true);
if (setTimeoutRef.current) {
clearTimeout(setTimeoutRef.current);
}
setTimeoutRef.current = setTimeout(() => {
setCopied(false);
setTimeoutRef.current = null;
}, 2000);
}}
>
{copied ?
<CopyCheckIcon className="size-4" />
:
<CopyIcon className="size-4" />
}
{size !== "icon" &&
(copied ? "복사됨" : "태그 복사")
}
</Button>
);
}
export default TagCopyButton;

View file

@ -0,0 +1,18 @@
import { Sparkles as SparklesIcon } from "lucide-react";
export function TagHero() {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<SparklesIcon className="h-4 w-4" />
</div>
<h1 className="text-3xl font-semibold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
);
}
export default TagHero;

View file

@ -0,0 +1,63 @@
import { Button } from "@/components/ui/button";
import { CardFooter } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ChevronDown as ChevronDownIcon, ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon } from "lucide-react";
import { clamp } from "@/components/tags/util";
interface TagPaginationProps {
page: number;
totalPages: number;
paginatedCount: number;
onPageStep: (delta: number) => void;
onPageChange: (page: number) => void;
}
export function TagPagination({ page, totalPages, paginatedCount, onPageStep, onPageChange }: TagPaginationProps) {
return (
<CardFooter className="flex flex-col gap-4 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{page} / {totalPages}
<span className="ml-2">({paginatedCount.toLocaleString()} )</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => onPageStep(-1)} disabled={page <= 1}>
<ChevronLeftIcon className="mr-2 h-4 w-4" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-32 justify-between">
<ChevronDownIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52" align="end">
<div className="space-y-3">
<div className="text-sm font-medium"> </div>
<Input
type="number"
min={1}
max={totalPages}
value={page}
onChange={(event) => {
const inputValue = Number(event.target.value);
if (Number.isNaN(inputValue)) {
return;
}
onPageChange(clamp(inputValue, 1, totalPages));
}}
/>
<div className="text-xs text-muted-foreground">
1 {totalPages.toLocaleString()} .
</div>
</div>
</PopoverContent>
</Popover>
<Button variant="outline" size="sm" onClick={() => onPageStep(1)} disabled={page >= totalPages}>
<ChevronRightIcon className="ml-2 h-4 w-4" />
</Button>
</div>
</CardFooter>
);
}
export default TagPagination;

View file

@ -0,0 +1,133 @@
import TagBadge from "@/components/gallery/TagBadge";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tag as TagIcon, RefreshCcw as RefreshCcwIcon, Sparkles as SparklesIcon } from "lucide-react";
import TagCopyButton from "./TagCopyButton";
import type { TagRecord, ViewMode } from "./types";
import { getTagKind, getTagKindLabel, TagKindType } from "@/lib/tags";
interface TagResultsProps {
paginatedTags: TagRecord[];
viewMode: ViewMode;
totalResults: number;
selectedKind?: TagKindType;
onResetFilters: () => void;
}
interface TagItemProps {
tag: TagRecord;
}
function TagGridItem({ tag }: TagItemProps) {
return (
<dl className="h-full border rounded-lg bg-card p-4">
<dt className="">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 basis-0 min-w-0 grow">
<TagBadge tagname={tag.name}
className="truncate"
/>
<TagCopyButton
tagName={tag.name}
variant="ghost"
size="icon"
className="size-6 flex-grow-0"
/>
</div>
<div className="flex items-baseline gap-2">
<span className="text-lg font-semibold text-foreground">{tag.occurs.toLocaleString()}</span>
<span className="text-xs text-muted-foreground"> </span>
</div>
</div>
</dt>
<dd className="pt-1">
<p className="text-xs text-muted-foreground">
{tag.description ? tag.description : "설명이 등록되지 않은 태그입니다."}
</p>
</dd>
</dl>
);
}
function TagListRow({ tag }: TagItemProps) {
return (
<div className="grid gap-4 border-b p-4 last:border-b-0 lg:grid-cols-[1fr_auto] lg:items-center">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<TagBadge tagname={tag.name} />
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{getTagKindLabel(getTagKind(tag.name))}
</span>
</div>
<p className="text-sm text-muted-foreground">
{tag.description ? tag.description : "설명이 등록되지 않은 태그입니다."}
</p>
</div>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-end">
<span className="text-lg font-semibold text-foreground">{tag.occurs.toLocaleString()}</span>
<TagCopyButton tagName={tag.name} size="default" />
</div>
</div>
);
}
export function TagResults({
viewMode,
paginatedTags,
totalResults,
selectedKind,
onResetFilters,
}: TagResultsProps) {
return (
<div className="space-y-5">
<div className="flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div>
<span className="font-semibold text-foreground">{totalResults.toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
<TagIcon className="size-4" />
<span>{
selectedKind && getTagKindLabel(selectedKind)
}</span>
</div>
</div>
{paginatedTags.length > 0 ? (
viewMode === "grid" ? (
<section className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
{paginatedTags.map((tag) => (
<TagGridItem
key={tag.name}
tag={tag}
/>
))}
</section>
) : (
<section className="overflow-hidden rounded-lg border">
{paginatedTags.map((tag) => (
<TagListRow
key={tag.name}
tag={tag}
/>
))}
</section>
)
) : (
<Card className="border-dashed">
<CardContent className="flex h-40 flex-col items-center justify-center text-center">
<SparklesIcon className="mb-3 h-6 w-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
. .
</p>
<Button variant="ghost" size="sm" className="mt-3" onClick={onResetFilters}>
<RefreshCcwIcon className="mr-2 size-4" />
</Button>
</CardContent>
</Card>
)}
</div>
);
}
export default TagResults;

View file

@ -0,0 +1,87 @@
import TagBadge from "@/components/gallery/TagBadge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tag as TagIcon, BarChart3 as BarChart3Icon, ListFilter as ListFilterIcon } from "lucide-react";
import type { TagRecord } from "./types";
import { useMemo } from "react";
function useTagStat(tags: TagRecord[]) {
const totalTagCount = tags.length;
const totalOccurrences = useMemo(() => tags.reduce((sum, tag) => sum + tag.occurs, 0), [tags]);
const averageOccurrences = totalTagCount > 0 ? totalOccurrences / totalTagCount : 0;
const topTag = useMemo(() => {
if (!tags.length) {
return null;
}
return tags.reduce<TagRecord | null>((current, candidate) => {
if (!current || candidate.occurs > current.occurs) {
return candidate;
}
return current;
}, null);
}, [tags]);
return { totalTagCount, totalOccurrences, averageOccurrences, topTag };
}
interface TagStatCardsProps {
tags: TagRecord[];
}
export function TagStatCards({ tags }: TagStatCardsProps) {
const { totalTagCount, totalOccurrences, averageOccurrences, topTag } = useTagStat(tags);
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> </CardTitle>
</CardHeader>
<CardContent className="flex items-end justify-between">
<span className="text-3xl font-semibold">{totalTagCount.toLocaleString()}</span>
<TagIcon className="h-6 w-6 text-primary" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> </CardTitle>
</CardHeader>
<CardContent className="flex items-end justify-between">
<span className="text-3xl font-semibold">{totalOccurrences.toLocaleString()}</span>
<BarChart3Icon className="h-6 w-6 text-muted-foreground" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> </CardTitle>
</CardHeader>
<CardContent className="flex items-end justify-between">
<span className="text-3xl font-semibold">{averageOccurrences.toFixed(1)}</span>
<ListFilterIcon className="h-6 w-6 text-muted-foreground" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"> </CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2 items-start">
{topTag ? (
<>
<TagBadge tagname={topTag.name} className="truncate" />
<span className="text-xs text-muted-foreground">
{topTag.occurs.toLocaleString()}
</span>
</>
) : (
<span className="text-sm text-muted-foreground"> .</span>
)}
</CardContent>
</Card>
</div>
);
}
export default TagStatCards;

View file

@ -0,0 +1,23 @@
import { TagKindType } from "@/lib/tags";
export type ViewMode = "grid" | "list";
export type TagOrderKey = "occurs-desc" | "occurs-asc" | "name-asc" | "name-desc";
export interface TagRecord {
name: string;
description: string;
occurs: number;
}
export interface TagKindStat {
kind: TagKindType;
count: number;
ratio: number;
}
export interface TopTagChartDatum {
label: string;
pretty: string;
occurs: number;
}

View file

@ -0,0 +1,3 @@
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

View file

@ -0,0 +1,75 @@
import { useMemo } from "react";
import type { TagOrderKey, TagRecord } from "@/components/tags/types";
import { getTagKind, TagKindType } from "@/lib/tags";
export type TagFilters = {
searchTerm?: string;
orderBy: TagOrderKey;
selectedKind?: TagKindType;
}
function applyTagFilters(tags: TagRecord[], filters: TagFilters) {
const { searchTerm = "", orderBy, selectedKind } = filters;
let filtered = tags;
if (selectedKind) {
filtered = filtered.filter((tag) => {
const kind = getTagKind(tag.name);
return kind === selectedKind;
});
}
const normalizedSearch = searchTerm.trim().toLowerCase();
if (normalizedSearch) {
filtered = filtered.filter((tag) => {
const nameMatch = tag.name.toLowerCase().includes(normalizedSearch);
const descriptionMatch = tag.description?.toLowerCase().includes(normalizedSearch);
return nameMatch || Boolean(descriptionMatch);
});
}
if (orderBy) {
switch (orderBy) {
case "occurs-asc":
filtered = filtered.slice().sort((a, b) => a.occurs - b.occurs);
break;
case "name-asc":
filtered = filtered.slice().sort((a, b) => a.name.localeCompare(b.name));
break;
case "name-desc":
filtered = filtered.slice().sort((a, b) => b.name.localeCompare(a.name));
break;
case "occurs-desc":
filtered = filtered.slice().sort((a, b) => b.occurs - a.occurs);
break;
default:
{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustiveCheck: never = orderBy;
return filtered;
}
break;
}
}
return filtered;
}
export function useFilterAndSortTags(
tags: TagRecord[],
filters: TagFilters,
): TagRecord[] {
return useMemo(() => {
return applyTagFilters(tags, filters);
}, [tags, filters]);
}
export function usePaginateTags(
tags: TagRecord[],
page: number,
pageSize: number,
): { paginatedTags: TagRecord[]; totalPages: number; totalResults: number } {
const totalResults = tags.length;
const totalPages = Math.max(1, Math.ceil(totalResults / pageSize));
const paginatedTags = useMemo(() => {
const start = (page - 1) * pageSize;
return tags.slice(start, start + pageSize);
}, [page, pageSize, tags]);
return { paginatedTags, totalPages, totalResults };
}

View file

@ -0,0 +1,33 @@
export async function copyTextToClipboard(text: string): Promise<boolean> {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fallback below
}
if (typeof document === "undefined") {
return false;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
let success = false;
try {
// polyfill for older browsers
success = document.execCommand("copy");
} catch {
success = false;
}
document.body.removeChild(textarea);
return success;
}

View file

@ -0,0 +1,50 @@
export type TagKindType = "default" | "type" | "character" | "series" | "group" | "artist" | "male" | "female";
export function getTagKind(tagname: string): TagKindType {
if (tagname.match(":") === null) {
return "default";
}
const prefix = tagname.split(":")[0];
return prefix as TagKindType;
}
export function toPrettyTagname(tagname: string): string {
const kind = getTagKind(tagname);
const name = tagname.slice(kind.length + 1);
switch (kind) {
case "male":
return `${name}`;
case "female":
return `${name}`;
case "artist":
return `🎨 ${name}`;
case "group":
return `🖿 ${name}`;
case "series":
return `📚 ${name}`
case "character":
return `👤 ${name}`;
case "default":
return tagname;
default:
return name;
}
}
const KIND_LABELS: Record<TagKindType, string> = {
default: "일반 태그",
type: "콘텐츠 타입",
series: "시리즈",
character: "캐릭터",
group: "그룹/서클",
artist: "아티스트",
male: "남성 태그",
female: "여성 태그",
};
export const ALL_TAG_KIND = Object.keys(KIND_LABELS) as TagKindType[];
export function getTagKindLabel(kind: TagKindType): string {
return KIND_LABELS[kind] ?? kind;
}

View file

@ -198,7 +198,7 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
<div className="grid mt-4">
<span className="text-muted-foreground text-sm">Tags</span>
<ul className="mt-2 flex flex-wrap gap-1">
{classifiedTags.rest.map((tag) => <TagBadge key={tag} tagname={tag} />)}
{classifiedTags.rest.map((tag) => <li key={tag}><TagBadge tagname={tag} /></li>)}
</ul>
</div>
</CardContent>

View file

@ -137,7 +137,7 @@ export default function Gallery() {
<div className="flex items-center flex-wrap gap-1">
<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} />)}
{tags.map(x => <li key={x}><TagBadge tagname={x} /></li>)}
</ul>
</div>
)}

View file

@ -1,93 +1,340 @@
import { toPrettyTagname } from "@/components/gallery/TagBadge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useCallback, useMemo, useState } from "react";
import { AlertCircle as AlertCircleIcon, LayoutGridIcon, LayoutListIcon, ListFilterIcon, RefreshCcwIcon, SearchIcon } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import type { ChartConfig } from "@/components/ui/chart.tsx";
import TagHero from "@/components/tags/TagHero.tsx";
import TagStatCards from "@/components/tags/TagStatCards.tsx";
import TagResults from "@/components/tags/TagResults.tsx";
import TagPagination from "@/components/tags/TagPagination.tsx";
import TagAnalyticsSidebar from "@/components/tags/TagAnalyticsSidebar.tsx";
import type { TagOrderKey, TagRecord, ViewMode } from "@/components/tags/types.ts";
import { Spinner } from "@/components/Spinner.tsx";
import { TagFilters, useFilterAndSortTags, usePaginateTags } from "@/hook/useTagFilters.ts";
import { useTags } from "@/hook/useTags.ts";
import { ALL_TAG_KIND, getTagKind, getTagKindLabel, TagKindType } from "@/lib/tags";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useTags } from "@/hook/useTags";
import { useState } from "react";
import { cn } from "@/lib/utils";
export function TagsPage() {
const { data, error, isLoading } = useTags();
const [searchInput, setSearchInput] = useState<string>("");
const [orderby, setOrderby] = useState<string>("name");
const [page, setPage] = useState<number>(1);
const pageSize = 10;
if (isLoading) {
return <div>Loading...</div>
}
if (error) {
return <div>Error: {error.message}</div>
}
const DEFAULT_ORDER: TagOrderKey = "occurs-desc";
const filteredTags = data?.filter(tag =>
tag.name.toLowerCase().includes(searchInput.toLowerCase())
).sort((a, b) => {
if (orderby === "name") {
return a.name.localeCompare(b.name);
} else if (orderby === "occurs") {
return b.occurs - a.occurs;
}
return 0;
});
const paginatedTags = filteredTags?.slice((page - 1) * pageSize, page * pageSize);
const totalPages = Math.ceil((filteredTags?.length || 0) / pageSize);
const CHART_CONFIG: ChartConfig = {
occurs: {
label: "사용 횟수",
theme: {
light: "hsl(var(--primary))",
dark: "hsl(var(--primary))",
},
},
};
function LoadingState() {
return (
<div className="p-4">
<div className="flex space-x-2">
<Select value={orderby} onValueChange={setOrderby}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="order by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="occurs">Occurs</SelectItem>
</SelectContent>
</Select>
<Input
className="mb-4"
placeholder="Search tags..."
value={searchInput}
onChange={(e) => {
setSearchInput(e.target.value);
setPage(1);
}}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-4 xl:grid-cols-3 gap-2 ">
{paginatedTags?.map(tag => (
<Card key={tag.name} className="mb-4">
<CardHeader>
<CardTitle className="text-2xl">{toPrettyTagname(tag.name)}({tag.occurs})</CardTitle>
</CardHeader>
<CardContent>
{tag.description}
</CardContent>
<div className="flex h-full min-h-[60vh] items-center justify-center p-6">
<Card className="flex items-center gap-3 px-6 py-4 shadow-sm">
<Spinner className="text-lg" />
<span className="text-sm text-muted-foreground"> </span>
</Card>
))}
</div>
<div className="flex justify-between mt-4">
<Button onClick={() => setPage(page - 1)} disabled={page <= 1}>
</Button>
<Popover>
<PopoverTrigger asChild>
<span>{page} / {totalPages}</span>
</PopoverTrigger>
<PopoverContent>
<Input type="number" value={page}
max={totalPages} min={1}
onChange={(e) => setPage(Number(e.target.value))} />
</PopoverContent>
</Popover>
<Button onClick={() => setPage(page + 1)} disabled={page >= totalPages}>
</Button>
</div>
</div>
)
);
}
export default TagsPage;
interface ErrorStateProps {
message: string;
onRetry: () => void;
}
function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div className="flex h-full min-h-[60vh] items-center justify-center p-6">
<Card className="max-w-md shadow-sm">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-lg">
<AlertCircleIcon className="h-5 w-5 text-destructive" />
</CardTitle>
<CardDescription>{message}</CardDescription>
</CardHeader>
<CardContent>
<Button type="button" variant="outline" className="w-full" onClick={onRetry}>
</Button>
</CardContent>
</Card>
</div>
);
}
export default function TagsPage() {
const { data, error, isLoading, mutate } = useTags();
const tags = useMemo<TagRecord[]>(() => data ?? [], [data]);
const [filters, setFilters] = useState<TagFilters>({
orderBy: DEFAULT_ORDER,
searchTerm: "",
});
const hasActiveFilters = useMemo(() => {
return (
Boolean(filters.searchTerm?.trim()) ||
filters.selectedKind ||
filters.orderBy !== DEFAULT_ORDER
);
}, [filters]);
const resetFilters = useCallback(() => {
setFilters({
orderBy: DEFAULT_ORDER,
searchTerm: "",
selectedKind: undefined,
});
}, []);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(24);
const setFilterOf = useCallback(
<K extends keyof TagFilters>(key: K, value: TagFilters[K]) => {
setFilters((prev) => ({
...prev,
[key]: value,
}));
setPage(1);
},
[],
);
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const kindCounts = useMemo(() => {
const kindCounts = new Map<string, number>();
for (const tag of tags) {
const kind = getTagKind(tag.name);
kindCounts.set(kind, (kindCounts.get(kind) ?? 0) + 1);
}
return kindCounts;
}, [tags])
const handleRetry = useCallback(() => {
void mutate();
}, [mutate]);
const filteredTags = useFilterAndSortTags(tags, filters);
const { paginatedTags, totalPages, totalResults } = usePaginateTags(filteredTags, page, pageSize);
if (error) {
const message = error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.";
return <ErrorState message={message} onRetry={handleRetry} />;
}
if (isLoading && !data) {
return <LoadingState />;
}
return (
<div className="p-4 space-y-8">
<TagHero />
<TagStatCards
tags={tags}
/>
<div className="grid gap-6 xl:grid-cols-[2fr_1fr]">
<Card className="shadow-sm">
<CardHeader className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<ListFilterIcon className="size-4" />
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={resetFilters} disabled={!hasActiveFilters}>
<RefreshCcwIcon className="mr-2 size-4" />
</Button>
<ToggleViewModeButton
viewMode={viewMode}
setViewMode={setViewMode}
/>
</div>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={filters.searchTerm}
onChange={(event) => setFilterOf("searchTerm", event.target.value)}
placeholder="태그 이름이나 설명을 입력하세요"
className="pl-9"
/>
</div>
<TagOrderSelector
orderBy={filters.orderBy}
onOrderChange={(value) => setFilterOf("orderBy", value)}
/>
<TagPageSizeSelector
pageSize={pageSize}
onPageSizeChange={(value) => setPageSize(value)}
/>
</div>
<TagKindSelectButtonList
kindCounts={kindCounts}
selectedKind={filters.selectedKind}
handleKindChange={(kind) => setFilterOf("selectedKind", kind)}
/>
</CardHeader>
<CardContent className="space-y-6 pt-0">
<TagResults
viewMode={viewMode}
paginatedTags={paginatedTags}
totalResults={totalResults}
selectedKind={filters.selectedKind}
onResetFilters={resetFilters}
/>
</CardContent>
{totalResults > 0 ? (
<TagPagination
page={page}
totalPages={totalPages}
paginatedCount={paginatedTags.length}
onPageStep={(delta) => {
setPage((prev) => Math.min(prev + delta, totalPages));
}}
onPageChange={(page) => {
setPage(Math.min(page, totalPages));
}}
/>
) : null}
</Card>
<TagAnalyticsSidebar
tags={tags}
chartConfig={CHART_CONFIG}
/>
</div>
</div>
);
}
function ToggleViewModeButton({
viewMode,
setViewMode,
}: {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
}) {
return (
<div className="flex items-center gap-2">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
className="rounded-none"
onClick={() => setViewMode("grid")}
>
<LayoutGridIcon className="mr-2 size-4" />
</Button>
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
className="rounded-none border-l"
onClick={() => setViewMode("list")}
>
<LayoutListIcon className="mr-2 size-4" />
</Button>
</div>
);
}
const ORDER_OPTIONS: { value: TagOrderKey; label: string }[] = [
{ value: "occurs-desc", label: "사용 많은 순" },
{ value: "occurs-asc", label: "사용 적은 순" },
{ value: "name-asc", label: "이름 A-Z" },
{ value: "name-desc", label: "이름 Z-A" },
];
function TagOrderSelector({
orderBy,
onOrderChange,
className,
disabled,
}: {
orderBy?: TagOrderKey;
onOrderChange?: (orderBy: TagOrderKey) => void;
className?: string;
disabled?: boolean;
}) {
return (
<Select value={orderBy} onValueChange={(value) => onOrderChange?.(value as TagOrderKey)}
disabled={disabled}>
<SelectTrigger className={cn("w-full lg:w-48", className)}>
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{ORDER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
const PAGE_SIZE_OPTIONS = [12, 24, 48, 96];
function TagPageSizeSelector({
pageSize,
onPageSizeChange,
}: {
pageSize: number;
onPageSizeChange?: (pageSize: number) => void;
}) {
return (
<Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange?.(Number(value))}>
<SelectTrigger className="w-full lg:w-36">
<SelectValue placeholder="페이지 크기" />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function TagKindSelectButtonList({
kindCounts,
selectedKind,
handleKindChange
}: {
kindCounts: Map<string, number>;
selectedKind?: TagKindType;
handleKindChange: (kind?: TagKindType) => void;
}) {
return <div className="flex flex-wrap gap-2">
{[undefined, ...ALL_TAG_KIND].map((kind) => {
const count = kind ? kindCounts.get(kind) : undefined;
return (
<Button
key={kind}
size="sm"
variant={selectedKind === kind ? "default" : "outline"}
onClick={() => handleKindChange(kind)}
>
{kind ? getTagKindLabel(kind) : "전체 태그"}
{typeof count === "number" ? (
<span className="ml-2 text-xs text-muted-foreground">
{count.toLocaleString()}
</span>
) : null}
</Button>
);
})}
</div>
}