183 lines
8.0 KiB
TypeScript
183 lines
8.0 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 { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { FuzzyCombobox, 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 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 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>
|
|
<FuzzyCombobox
|
|
items={catalogs[kind]}
|
|
searchNames={(value) => [value.name, ...aliases.filter((entry) => entry.kind === kind && entry.catalogKey === value.key).map((entry) => entry.alias)]}
|
|
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>
|
|
</FuzzyCombobox>
|
|
</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 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>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|