feat: reorganize tag page and improve tag-related components
This commit is contained in:
parent
eb06208f80
commit
358cb66780
17 changed files with 1016 additions and 146 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
118
packages/client/src/components/tags/TagAnalyticsSidebar.tsx
Normal file
118
packages/client/src/components/tags/TagAnalyticsSidebar.tsx
Normal 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;
|
||||
58
packages/client/src/components/tags/TagCopyButton.tsx
Normal file
58
packages/client/src/components/tags/TagCopyButton.tsx
Normal 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;
|
||||
18
packages/client/src/components/tags/TagHero.tsx
Normal file
18
packages/client/src/components/tags/TagHero.tsx
Normal 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;
|
||||
63
packages/client/src/components/tags/TagPagination.tsx
Normal file
63
packages/client/src/components/tags/TagPagination.tsx
Normal 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;
|
||||
133
packages/client/src/components/tags/TagResults.tsx
Normal file
133
packages/client/src/components/tags/TagResults.tsx
Normal 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;
|
||||
87
packages/client/src/components/tags/TagStatCards.tsx
Normal file
87
packages/client/src/components/tags/TagStatCards.tsx
Normal 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;
|
||||
23
packages/client/src/components/tags/types.ts
Normal file
23
packages/client/src/components/tags/types.ts
Normal 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;
|
||||
}
|
||||
3
packages/client/src/components/tags/util.ts
Normal file
3
packages/client/src/components/tags/util.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
75
packages/client/src/hook/useTagFilters.ts
Normal file
75
packages/client/src/hook/useTagFilters.ts
Normal 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 };
|
||||
}
|
||||
33
packages/client/src/lib/copy.ts
Normal file
33
packages/client/src/lib/copy.ts
Normal 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;
|
||||
}
|
||||
50
packages/client/src/lib/tags.ts
Normal file
50
packages/client/src/lib/tags.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 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="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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
{tag.description}
|
||||
<Button type="button" variant="outline" className="w-full" onClick={onRetry}>
|
||||
다시 시도
|
||||
</Button>
|
||||
</CardContent>
|
||||
</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;
|
||||
|
||||
|
||||
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>
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue