Files
buzz-sheet/components/admin/glossary-manager.tsx
T
2026-09-30 19:34:28 +07:00

241 lines
11 KiB
TypeScript

"use client";
import { searchItems } from "@/lib/search";
import Image from "next/image";
import { SearchIcon, Trash2Icon } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { addGlossaryAlias, removeGlossaryAlias } from "@/app/admin/actions";
import { CatalogDropdown } from "@/components/admin/ui/catalog-select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import type { GlossaryKind } from "@/lib/glossary";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import { cn } from "@/lib/utils";
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null };
type Alias = { id: string; kind: string; catalogKey: string; alias: string };
const PAGE_SIZE = 25;
function paginationItems(currentPage: number, totalPages: number) {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1);
if (currentPage <= 4) return [1, 2, 3, 4, 5, "end-ellipsis", totalPages] as const;
if (currentPage >= totalPages - 3) return [1, "start-ellipsis", totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages] as const;
return [1, "start-ellipsis", currentPage - 1, currentPage, currentPage + 1, "end-ellipsis", totalPages] as const;
}
const kindLabels: Record<GlossaryKind, string> = {
character: "Character",
weapon: "Weapon",
artifact: "Artifact",
};
export function GlossaryManager({
aliases,
catalogs,
}: {
aliases: Alias[];
catalogs: Record<GlossaryKind, CatalogItem[]>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [kind, setKind] = useState<GlossaryKind>("character");
const [item, setItem] = useState<CatalogItem | null>(null);
const [alias, setAlias] = useState("");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const itemsByKey = useMemo(() => new Map(
Object.entries(catalogs).flatMap(([catalogKind, items]) =>
items.map((catalogItem) => [`${catalogKind}:${catalogItem.key}`, catalogItem] as const),
),
), [catalogs]);
const search = searchItems(aliases, query,
(entry) => `${entry.alias} ${itemsByKey.get(`${entry.kind}:${entry.catalogKey}`)?.name ?? entry.catalogKey}`,
(entry) => [entry.alias, itemsByKey.get(`${entry.kind}:${entry.catalogKey}`)?.name ?? ""]);
const visibleAliases = search.items;
const totalPages = Math.max(1, Math.ceil(visibleAliases.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const paginatedAliases = visibleAliases.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const pages = paginationItems(currentPage, totalPages);
const submit = () => startTransition(async () => {
const result = await addGlossaryAlias({ kind, catalogKey: item?.key, alias });
if (result.status === "error") {
toast.error(result.message);
return;
}
setAlias("");
toast.success("เพิ่ม Shortcut แล้ว");
router.refresh();
});
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>เพิ่ม Shortcut</CardTitle>
<CardDescription>ผูกคำค้นแบบย่อกับรายการจาก Catalog</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>ประเภท</FieldLabel>
<Select value={kind} onValueChange={(value) => {
setKind(value as GlossaryKind);
setItem(null);
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(kindLabels) as GlossaryKind[]).map((value) => (
<SelectItem key={value} value={value}>{kindLabels[value]}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>รายการ</FieldLabel>
<CatalogDropdown
items={catalogs[kind]}
searchNames={(value) => [value.name, ...aliases.filter((entry) => entry.kind === kind && entry.catalogKey === value.key).map((entry) => entry.alias)]}
value={item?.key ?? ""}
onValueChange={(key) => setItem(catalogs[kind].find((value) => value.key === key) ?? null)}
placeholder="ค้นหารายการ…"
rarityBackground
/>
</Field>
<Field>
<FieldLabel htmlFor="glossary-alias">Shortcut</FieldLabel>
<Input id="glossary-alias" value={alias} maxLength={120} onChange={(event) => setAlias(event.target.value)} onKeyDown={(event) => {
if (event.key === "Enter" && item && alias.trim()) submit();
}} />
</Field>
<Button disabled={pending || !item || !alias.trim()} onClick={submit}>
{pending ? <Spinner data-icon="inline-start" /> : null}
เพิ่ม Shortcut
</Button>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Glossary</CardTitle>
<CardDescription>{aliases.length} shortcuts</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor="glossary-search">ค้นหา</FieldLabel>
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input id="glossary-search" className="pl-9" value={query} onChange={(event) => {
setQuery(event.target.value);
setPage(1);
}} />
</div>
</Field>
{paginatedAliases.map((entry) => {
const catalogItem = itemsByKey.get(`${entry.kind}:${entry.catalogKey}`);
const name = catalogItem?.name;
return (
<div key={entry.id} className="flex items-center gap-3 rounded-lg border p-3">
{catalogItem ? (
<span className={cn(
"relative size-10 shrink-0 overflow-hidden rounded-md bg-muted",
rarityGradientClass(catalogItem.rarity),
)}>
<Image src={catalogItem.imageUrl} alt="" fill sizes="40px" className="object-contain" />
</span>
) : null}
<div className="min-w-0 flex-1">
<p className="font-medium">{entry.alias}</p>
<p className="truncate text-sm text-muted-foreground">{name ?? entry.catalogKey}</p>
</div>
<Badge variant={name ? "secondary" : "outline"}>{name ? kindLabels[entry.kind as GlossaryKind] : "Unavailable"}</Badge>
<Button variant="ghost" size="icon-sm" disabled={pending} aria-label={`ลบ ${entry.alias}`} onClick={() => startTransition(async () => {
const result = await removeGlossaryAlias(entry.id);
if (result.status === "error") toast.error(result.message);
else {
toast.success("ลบ Shortcut แล้ว");
router.refresh();
}
})}><Trash2Icon /></Button>
</div>
);
})}
{totalPages > 1 ? (
<Pagination className="pt-2">
<PaginationContent className="grid w-full grid-cols-[1fr_auto_1fr]">
<PaginationItem className="justify-self-start">
<PaginationPrevious
href="#"
text="ก่อนหน้า"
aria-disabled={currentPage === 1}
tabIndex={currentPage === 1 ? -1 : undefined}
className={cn(currentPage === 1 && "pointer-events-none opacity-50")}
onClick={(event) => {
event.preventDefault();
setPage(currentPage - 1);
}}
/>
</PaginationItem>
<PaginationItem>
<ul className="flex items-center gap-0.5">
{pages.map((pageItem) => (
typeof pageItem === "number" ? (
<PaginationItem key={pageItem}>
<PaginationLink
href="#"
isActive={pageItem === currentPage}
aria-label={`ไปหน้า ${pageItem}`}
onClick={(event) => {
event.preventDefault();
setPage(pageItem);
}}
>
{pageItem}
</PaginationLink>
</PaginationItem>
) : (
<PaginationItem key={pageItem}>
<PaginationEllipsis />
</PaginationItem>
)
))}
</ul>
</PaginationItem>
<PaginationItem className="justify-self-end">
<PaginationNext
href="#"
text="ถัดไป"
aria-disabled={currentPage === totalPages}
tabIndex={currentPage === totalPages ? -1 : undefined}
className={cn(currentPage === totalPages && "pointer-events-none opacity-50")}
onClick={(event) => {
event.preventDefault();
setPage(currentPage + 1);
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
) : null}
</CardContent>
</Card>
</div>
);
}