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 type { Document } from "dbtype";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
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 { Fragment, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { LazyImage } from "./LazyImage.tsx";
|
import { LazyImage } from "./LazyImage.tsx";
|
||||||
import StyledLink from "./StyledLink.tsx";
|
import StyledLink from "./StyledLink.tsx";
|
||||||
|
|
@ -8,6 +8,7 @@ import React from "react";
|
||||||
import { Skeleton } from "../ui/skeleton.tsx";
|
import { Skeleton } from "../ui/skeleton.tsx";
|
||||||
import { Palette, Users, Trash2, Clock, LayersIcon } from "lucide-react";
|
import { Palette, Users, Trash2, Clock, LayersIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils.ts";
|
import { cn } from "@/lib/utils.ts";
|
||||||
|
import { toPrettyTagname } from "@/lib/tags.ts";
|
||||||
|
|
||||||
function clipTagsWhenOverflow(tags: string[], limit: number) {
|
function clipTagsWhenOverflow(tags: string[], limit: number) {
|
||||||
let l = 0;
|
let l = 0;
|
||||||
|
|
@ -166,11 +167,13 @@ function GalleryCardImpl({
|
||||||
<CardContent className="flex-1 overflow-hidden pt-0">
|
<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">
|
<ul ref={ref} className="flex flex-wrap gap-1 sm:gap-1.5 items-baseline content-start">
|
||||||
{clippedTags.map(tag => (
|
{clippedTags.map(tag => (
|
||||||
<TagBadge
|
<li>
|
||||||
key={tag}
|
<TagBadge
|
||||||
tagname={tag}
|
key={tag}
|
||||||
className="transition-all duration-200 hover:shadow-sm hover:scale-105"
|
tagname={tag}
|
||||||
/>
|
className="transition-all duration-200 hover:shadow-sm hover:scale-105"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
{clippedTags.length < originalTags.length && (
|
{clippedTags.length < originalTags.length && (
|
||||||
<TagBadge
|
<TagBadge
|
||||||
|
|
|
||||||
|
|
@ -2,51 +2,7 @@ import { badgeVariants } from "@/components/ui/badge.tsx";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { cn } from "@/lib/utils.ts";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import { cva } from "class-variance-authority"
|
import { cva } from "class-variance-authority"
|
||||||
|
import { getTagKind, toPrettyTagname } from "@/lib/tags";
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TagBadgeProps {
|
interface TagBadgeProps {
|
||||||
tagname: string;
|
tagname: string;
|
||||||
|
|
@ -54,9 +10,9 @@ interface TagBadgeProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const tagBadgeVariants = cva(
|
export const tagBadgeVariants = cva(
|
||||||
cn(badgeVariants({ variant: "default"}), "px-1"),
|
cn(badgeVariants({ variant: "default"}), "px-1 inline-block"),
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|
@ -79,10 +35,12 @@ export const tagBadgeVariants = cva(
|
||||||
export default function TagBadge(props: TagBadgeProps) {
|
export default function TagBadge(props: TagBadgeProps) {
|
||||||
const { tagname } = props;
|
const { tagname } = props;
|
||||||
const kind = getTagKind(tagname);
|
const kind = getTagKind(tagname);
|
||||||
return <li className={
|
return <Link
|
||||||
|
to={props.disabled ? '': `/search?allow_tag=${tagname}`}
|
||||||
|
className={
|
||||||
cn( tagBadgeVariants({ variant: kind }),
|
cn( tagBadgeVariants({ variant: kind }),
|
||||||
props.disabled && "opacity-50",
|
props.disabled && "opacity-50",
|
||||||
props.className,
|
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 { cn } from "@/lib/utils";
|
||||||
import { getTagKind, tagBadgeVariants } from "./TagBadge";
|
import { tagBadgeVariants } from "./TagBadge";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useOnClickOutside } from "usehooks-ts";
|
import { useOnClickOutside } from "usehooks-ts";
|
||||||
import { useTags } from "@/hook/useTags";
|
import { useTags } from "@/hook/useTags";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { getTagKind } from "@/lib/tags";
|
||||||
|
|
||||||
interface TagsSelectListProps {
|
interface TagsSelectListProps {
|
||||||
className?: string;
|
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">
|
<div className="grid mt-4">
|
||||||
<span className="text-muted-foreground text-sm">Tags</span>
|
<span className="text-muted-foreground text-sm">Tags</span>
|
||||||
<ul className="mt-2 flex flex-wrap gap-1">
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export default function Gallery() {
|
||||||
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`
|
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`
|
||||||
}}
|
}}
|
||||||
data-index={item.index}
|
data-index={item.index}
|
||||||
>
|
>
|
||||||
<span className="text-sm sm:text-base text-muted-foreground flex items-center gap-2">
|
<span className="text-sm sm:text-base text-muted-foreground flex items-center gap-2">
|
||||||
<Spinner />컨텐츠를 불러오는 중...
|
<Spinner />컨텐츠를 불러오는 중...
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -102,7 +102,7 @@ export default function Gallery() {
|
||||||
}}
|
}}
|
||||||
data-index={item.index}
|
data-index={item.index}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
>
|
>
|
||||||
{docs.startCursor && (
|
{docs.startCursor && (
|
||||||
<div className="bg-muted/50 rounded-lg p-2 sm:p-3">
|
<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">
|
<h3 className="text-lg sm:text-2xl font-medium flex items-center gap-2">
|
||||||
|
|
@ -111,7 +111,7 @@ export default function Gallery() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{docs?.data?.map((x) => (
|
{docs?.data?.map((x) => (
|
||||||
<GalleryCard doc={x} key={x.id}
|
<GalleryCard doc={x} key={x.id}
|
||||||
className="h-[409.5px] sm:h-[200px]"
|
className="h-[409.5px] sm:h-[200px]"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -137,7 +137,7 @@ export default function Gallery() {
|
||||||
<div className="flex items-center flex-wrap gap-1">
|
<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" />
|
<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">
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,340 @@
|
||||||
import { toPrettyTagname } from "@/components/gallery/TagBadge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useTags } from "@/hook/useTags";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
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) {
|
const DEFAULT_ORDER: TagOrderKey = "occurs-desc";
|
||||||
return <div>Loading...</div>
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
return <div>Error: {error.message}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredTags = data?.filter(tag =>
|
const CHART_CONFIG: ChartConfig = {
|
||||||
tag.name.toLowerCase().includes(searchInput.toLowerCase())
|
occurs: {
|
||||||
).sort((a, b) => {
|
label: "사용 횟수",
|
||||||
if (orderby === "name") {
|
theme: {
|
||||||
return a.name.localeCompare(b.name);
|
light: "hsl(var(--primary))",
|
||||||
} else if (orderby === "occurs") {
|
dark: "hsl(var(--primary))",
|
||||||
return b.occurs - a.occurs;
|
},
|
||||||
}
|
},
|
||||||
return 0;
|
};
|
||||||
});
|
|
||||||
const paginatedTags = filteredTags?.slice((page - 1) * pageSize, page * pageSize);
|
|
||||||
const totalPages = Math.ceil((filteredTags?.length || 0) / pageSize);
|
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="flex h-full min-h-[60vh] items-center justify-center p-6">
|
||||||
<div className="flex space-x-2">
|
<Card className="flex items-center gap-3 px-6 py-4 shadow-sm">
|
||||||
<Select value={orderby} onValueChange={setOrderby}>
|
<Spinner className="text-lg" />
|
||||||
<SelectTrigger className="w-[180px]">
|
<span className="text-sm text-muted-foreground">태그 정보를 불러오는 중입니다…</span>
|
||||||
<SelectValue placeholder="order by" />
|
</Card>
|
||||||
</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>
|
|
||||||
</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>
|
</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>
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue