diff --git a/packages/client/src/components/gallery/GalleryCard.tsx b/packages/client/src/components/gallery/GalleryCard.tsx
index ed8b61d..72119bf 100644
--- a/packages/client/src/components/gallery/GalleryCard.tsx
+++ b/packages/client/src/components/gallery/GalleryCard.tsx
@@ -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({
{clippedTags.map(tag => (
-
+ -
+
+
))}
{clippedTags.length < originalTags.length && (
{toPrettyTagname(tagname)};
+ }>{toPrettyTagname(tagname)};
}
\ No newline at end of file
diff --git a/packages/client/src/components/gallery/TagInput.tsx b/packages/client/src/components/gallery/TagInput.tsx
index 44216ae..70c4851 100644
--- a/packages/client/src/components/gallery/TagInput.tsx
+++ b/packages/client/src/components/gallery/TagInput.tsx
@@ -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;
diff --git a/packages/client/src/components/tags/TagAnalyticsSidebar.tsx b/packages/client/src/components/tags/TagAnalyticsSidebar.tsx
new file mode 100644
index 0000000..c33d999
--- /dev/null
+++ b/packages/client/src/components/tags/TagAnalyticsSidebar.tsx
@@ -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(() => {
+ 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(() => {
+ if (!totalTagCount) {
+ return [];
+ }
+ const kindCounts = new Map();
+ 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 (
+
+
+
+
+ μμ νκ·Έ μ¬μ©λ
+
+ μ 체 λ°μ΄ν° κΈ°μ€ μ¬μ© λΉλκ° λμ νκ·Έλ₯Ό 보μ¬μ€λλ€.
+
+
+ {topTagChartData.length > 0 ? (
+
+
+
+
+
+ } />
+
+
+
+ ) : (
+
+ μμ§ μκ°νν λ°μ΄ν°κ° λΆμ‘±ν©λλ€.
+
+ )}
+
+
+
+
+
+
+ νκ·Έ μ’
λ₯ μμ½
+
+ νκ·Έ μ’
λ₯λ³ κ°μμ λΉμ¨μ νμΈνμΈμ.
+
+
+ {kindStats.length > 0 ? (
+ kindStats.map((stat) => (
+
+
+ {getTagKindLabel(stat.kind)}
+
+ {stat.count.toLocaleString()}κ° Β· {stat.ratio}%
+
+
+
+
+ ))
+ ) : (
+ νκ·Έ λ°μ΄ν°κ° μμ΅λλ€.
+ )}
+
+
+
+ );
+}
+
+export default TagAnalyticsSidebar;
diff --git a/packages/client/src/components/tags/TagCopyButton.tsx b/packages/client/src/components/tags/TagCopyButton.tsx
new file mode 100644
index 0000000..ca0a3e4
--- /dev/null
+++ b/packages/client/src/components/tags/TagCopyButton.tsx
@@ -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["variant"];
+ size?: ComponentProps["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 | null>(null);
+
+ return (
+
+ );
+}
+
+export default TagCopyButton;
diff --git a/packages/client/src/components/tags/TagHero.tsx b/packages/client/src/components/tags/TagHero.tsx
new file mode 100644
index 0000000..10f2d1d
--- /dev/null
+++ b/packages/client/src/components/tags/TagHero.tsx
@@ -0,0 +1,18 @@
+import { Sparkles as SparklesIcon } from "lucide-react";
+
+export function TagHero() {
+ return (
+
+
+
+ νκ·Έ νμκΈ°
+
+
νκ·Έ λΌμ΄λΈλ¬λ¦¬
+
+ νκ·Έλ₯Ό κ²μνκ³ μ λ ¬νλ©° 컬λ μ
μ λ λΉ λ₯΄κ² ννν΄ λ³΄μΈμ.
+
+
+ );
+}
+
+export default TagHero;
diff --git a/packages/client/src/components/tags/TagPagination.tsx b/packages/client/src/components/tags/TagPagination.tsx
new file mode 100644
index 0000000..9b6f5d6
--- /dev/null
+++ b/packages/client/src/components/tags/TagPagination.tsx
@@ -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 (
+
+
+ νμ΄μ§ {page} / {totalPages}
+ ({paginatedCount.toLocaleString()}κ° νμ μ€)
+
+
+
+
+
+
+
+
+
+
μ΄λν νμ΄μ§
+
{
+ const inputValue = Number(event.target.value);
+ if (Number.isNaN(inputValue)) {
+ return;
+ }
+ onPageChange(clamp(inputValue, 1, totalPages));
+ }}
+ />
+
+ 1μμ {totalPages.toLocaleString()} μ¬μ΄μ κ°μ μ
λ ₯νμΈμ.
+
+
+
+
+
+
+
+ );
+}
+
+export default TagPagination;
diff --git a/packages/client/src/components/tags/TagResults.tsx b/packages/client/src/components/tags/TagResults.tsx
new file mode 100644
index 0000000..d323183
--- /dev/null
+++ b/packages/client/src/components/tags/TagResults.tsx
@@ -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 (
+
+ -
+
+
+
+
+
+
+ {tag.occurs.toLocaleString()}
+ ν μ¬μ©
+
+
+
+ -
+
+ {tag.description ? tag.description : "μ€λͺ
μ΄ λ±λ‘λμ§ μμ νκ·Έμ
λλ€."}
+
+
+
+ );
+}
+
+function TagListRow({ tag }: TagItemProps) {
+ return (
+
+
+
+
+
+ {getTagKindLabel(getTagKind(tag.name))}
+
+
+
+ {tag.description ? tag.description : "μ€λͺ
μ΄ λ±λ‘λμ§ μμ νκ·Έμ
λλ€."}
+
+
+
+ {tag.occurs.toLocaleString()}ν
+
+
+
+ );
+}
+
+export function TagResults({
+ viewMode,
+ paginatedTags,
+ totalResults,
+ selectedKind,
+ onResetFilters,
+}: TagResultsProps) {
+
+ return (
+
+
+
+ 쑰건μ λ§λ νκ·Έ {totalResults.toLocaleString()}κ°
+
+
+
+ {
+ selectedKind && getTagKindLabel(selectedKind)
+ }
+
+
+
+ {paginatedTags.length > 0 ? (
+ viewMode === "grid" ? (
+
+ {paginatedTags.map((tag) => (
+
+ ))}
+
+ ) : (
+
+ {paginatedTags.map((tag) => (
+
+ ))}
+
+ )
+ ) : (
+
+
+
+
+ 쑰건μ λ§λ νκ·Έκ° μμ΅λλ€. λ€λ₯Έ κ²μμ΄λ νν°λ₯Ό μλν΄ λ³΄μΈμ.
+
+
+
+
+ )}
+
+ );
+}
+
+export default TagResults;
diff --git a/packages/client/src/components/tags/TagStatCards.tsx b/packages/client/src/components/tags/TagStatCards.tsx
new file mode 100644
index 0000000..747570d
--- /dev/null
+++ b/packages/client/src/components/tags/TagStatCards.tsx
@@ -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((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 (
+
+
+
+ μ 체 νκ·Έ
+
+
+ {totalTagCount.toLocaleString()}
+
+
+
+
+
+
+ μ΄ μ¬μ© νμ
+
+
+ {totalOccurrences.toLocaleString()}
+
+
+
+
+
+
+ νκ· μ¬μ© λΉλ
+
+
+ {averageOccurrences.toFixed(1)}
+
+
+
+
+
+
+ μ΅λ€ μ¬μ© νκ·Έ
+
+
+ {topTag ? (
+ <>
+
+
+ μ΄ {topTag.occurs.toLocaleString()}ν μ¬μ©
+
+ >
+ ) : (
+ μμ§ μ§κ³λ νκ·Έκ° μμ΅λλ€.
+ )}
+
+
+
+ );
+}
+
+export default TagStatCards;
diff --git a/packages/client/src/components/tags/types.ts b/packages/client/src/components/tags/types.ts
new file mode 100644
index 0000000..0190e7a
--- /dev/null
+++ b/packages/client/src/components/tags/types.ts
@@ -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;
+}
diff --git a/packages/client/src/components/tags/util.ts b/packages/client/src/components/tags/util.ts
new file mode 100644
index 0000000..9bcef58
--- /dev/null
+++ b/packages/client/src/components/tags/util.ts
@@ -0,0 +1,3 @@
+export function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
diff --git a/packages/client/src/hook/useTagFilters.ts b/packages/client/src/hook/useTagFilters.ts
new file mode 100644
index 0000000..5d0fb15
--- /dev/null
+++ b/packages/client/src/hook/useTagFilters.ts
@@ -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 };
+}
diff --git a/packages/client/src/lib/copy.ts b/packages/client/src/lib/copy.ts
new file mode 100644
index 0000000..b0ae5b4
--- /dev/null
+++ b/packages/client/src/lib/copy.ts
@@ -0,0 +1,33 @@
+export async function copyTextToClipboard(text: string): Promise {
+ 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;
+}
\ No newline at end of file
diff --git a/packages/client/src/lib/tags.ts b/packages/client/src/lib/tags.ts
new file mode 100644
index 0000000..d694246
--- /dev/null
+++ b/packages/client/src/lib/tags.ts
@@ -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 = {
+ 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;
+}
\ No newline at end of file
diff --git a/packages/client/src/page/contentInfoPage.tsx b/packages/client/src/page/contentInfoPage.tsx
index d887372..0257f0d 100644
--- a/packages/client/src/page/contentInfoPage.tsx
+++ b/packages/client/src/page/contentInfoPage.tsx
@@ -198,7 +198,7 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
Tags
- {classifiedTags.rest.map((tag) => )}
+ {classifiedTags.rest.map((tag) => )}
diff --git a/packages/client/src/page/galleryPage.tsx b/packages/client/src/page/galleryPage.tsx
index dfe7621..38b7705 100644
--- a/packages/client/src/page/galleryPage.tsx
+++ b/packages/client/src/page/galleryPage.tsx
@@ -87,7 +87,7 @@ export default function Gallery() {
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`
}}
data-index={item.index}
- >
+ >
컨ν
μΈ λ₯Ό λΆλ¬μ€λ μ€...
@@ -102,7 +102,7 @@ export default function Gallery() {
}}
data-index={item.index}
ref={virtualizer.measureElement}
- >
+ >
{docs.startCursor && (
@@ -111,7 +111,7 @@ export default function Gallery() {
)}
{docs?.data?.map((x) => (
-
))}
@@ -137,7 +137,7 @@ export default function Gallery() {
- {tags.map(x => )}
+ {tags.map(x => )}
)}
diff --git a/packages/client/src/page/tagsPage.tsx b/packages/client/src/page/tagsPage.tsx
index 29fa3a8..ef76f88 100644
--- a/packages/client/src/page/tagsPage.tsx
+++ b/packages/client/src/page/tagsPage.tsx
@@ -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("");
- const [orderby, setOrderby] = useState("name");
- const [page, setPage] = useState(1);
- const pageSize = 10;
- if (isLoading) {
- return Loading...
- }
- if (error) {
- return Error: {error.message}
- }
+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 (
-
-
-
- {
- setSearchInput(e.target.value);
- setPage(1);
- }}
- />
-
-
- {paginatedTags?.map(tag => (
-
-
- {toPrettyTagname(tag.name)}({tag.occurs})
-
-
- {tag.description}
-
-
- ))}
-
-
-
-
-
- {page} / {totalPages}
-
-
- setPage(Number(e.target.value))} />
-
-
-
-
+
+
+
+ νκ·Έ μ 보λ₯Ό λΆλ¬μ€λ μ€μ
λλ€β¦
+
- )
+ );
}
-export default TagsPage;
\ No newline at end of file
+interface ErrorStateProps {
+ message: string;
+ onRetry: () => void;
+}
+
+function ErrorState({ message, onRetry }: ErrorStateProps) {
+ return (
+
+
+
+
+ μ€λ₯κ° λ°μνμ΅λλ€
+
+ {message}
+
+
+
+
+
+
+ );
+}
+
+
+
+export default function TagsPage() {
+ const { data, error, isLoading, mutate } = useTags();
+ const tags = useMemo
(() => data ?? [], [data]);
+ const [filters, setFilters] = useState({
+ 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(
+ (key: K, value: TagFilters[K]) => {
+ setFilters((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ setPage(1);
+ },
+ [],
+ );
+ const [viewMode, setViewMode] = useState("grid");
+
+ const kindCounts = useMemo(() => {
+ const kindCounts = new Map();
+ 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 ;
+ }
+
+ if (isLoading && !data) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ νν°
+
+
+
+
+
+
+
+
+
+
+ setFilterOf("searchTerm", event.target.value)}
+ placeholder="νκ·Έ μ΄λ¦μ΄λ μ€λͺ
μ μ
λ ₯νμΈμ"
+ className="pl-9"
+ />
+
+
setFilterOf("orderBy", value)}
+ />
+ setPageSize(value)}
+ />
+
+
+ setFilterOf("selectedKind", kind)}
+ />
+
+
+
+
+
+
+ {totalResults > 0 ? (
+ {
+ setPage((prev) => Math.min(prev + delta, totalPages));
+ }}
+ onPageChange={(page) => {
+ setPage(Math.min(page, totalPages));
+ }}
+ />
+ ) : null}
+
+
+
+
+
+ );
+}
+
+function ToggleViewModeButton({
+ viewMode,
+ setViewMode,
+}: {
+ viewMode: ViewMode;
+ setViewMode: (mode: ViewMode) => void;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+const PAGE_SIZE_OPTIONS = [12, 24, 48, 96];
+
+function TagPageSizeSelector({
+ pageSize,
+ onPageSizeChange,
+}: {
+ pageSize: number;
+ onPageSizeChange?: (pageSize: number) => void;
+}) {
+ return (
+
+ );
+}
+
+function TagKindSelectButtonList({
+ kindCounts,
+ selectedKind,
+ handleKindChange
+}: {
+ kindCounts: Map;
+ selectedKind?: TagKindType;
+ handleKindChange: (kind?: TagKindType) => void;
+}) {
+ return
+ {[undefined, ...ALL_TAG_KIND].map((kind) => {
+ const count = kind ? kindCounts.get(kind) : undefined;
+ return (
+
+ );
+ })}
+
+}
\ No newline at end of file