Files
buzz-sheet/components/admin/glossary-manager.tsx
T
gunshiz 2429b9d319
CI / Verify (push) Successful in 1m6s
CI / Build immutable images and deploy (push) Successful in 2m49s
feat : update
2026-09-05 18:39:03 +07:00

171 lines
7.2 KiB
TypeScript

"use client";
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
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 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 itemNames = useMemo(() => new Map(
Object.entries(catalogs).flatMap(([catalogKind, items]) =>
items.map((catalogItem) => [`${catalogKind}:${catalogItem.key}`, catalogItem.name] as const),
),
), [catalogs]);
const visibleAliases = aliases.filter((entry) =>
`${entry.alias} ${itemNames.get(`${entry.kind}:${entry.catalogKey}`) ?? entry.catalogKey}`
.toLocaleLowerCase("th")
.includes(query.trim().toLocaleLowerCase("th")),
);
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>
<Combobox
items={catalogs[kind]}
value={item}
onValueChange={setItem}
itemToStringLabel={(value) => value.name}
itemToStringValue={(value) => value.name}
>
<ComboboxInput placeholder="ค้นหารายการ…" showClear />
<ComboboxContent>
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
<ComboboxList>
{(catalogItem) => (
<ComboboxItem key={catalogItem.key} value={catalogItem}>
<span className={cn(
"relative size-8 shrink-0 overflow-hidden rounded-md bg-muted",
rarityGradientClass(catalogItem.rarity),
)}>
<Image src={catalogItem.imageUrl} alt="" fill className="object-contain" />
</span>
{catalogItem.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</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)} />
</div>
</Field>
{visibleAliases.map((entry) => {
const name = itemNames.get(`${entry.kind}:${entry.catalogKey}`);
return (
<div key={entry.id} className="flex items-center gap-3 rounded-lg border p-3">
<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>
);
})}
</CardContent>
</Card>
</div>
);
}