Files
gunshiz e0794822b1
CI / Verify (push) Successful in 1m18s
CI / Build immutable images and deploy (push) Successful in 1m57s
feat(i18n) : add personal languages and compact weapon exports
2026-09-14 16:44:44 +07:00

184 lines
8.1 KiB
TypeScript

"use client";
import { useTranslation } from "@/components/i18n/locale-provider";
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 { t } = useTranslation();
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(t(result.message));
return;
}
setAlias("");
toast.success(t("เพิ่ม Shortcut แล้ว"));
router.refresh();
});
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>{t("เพิ่ม Shortcut")}</CardTitle>
<CardDescription>{t("ผูกคำค้นแบบย่อกับรายการจาก Catalog")}</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>{t("ประเภท")}</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>{t("รายการ")}</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={t("ค้นหารายการ…")} showClear />
<ComboboxContent>
<ComboboxEmpty>{t("ไม่พบรายการ")}</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">{t("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}
{t("เพิ่ม Shortcut")}</Button>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("Glossary")}</CardTitle>
<CardDescription>{aliases.length} {t("shortcuts")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor="glossary-search">{t("ค้นหา")}</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={t("ลบ {name}", { name: entry.alias })} onClick={() => startTransition(async () => {
const result = await removeGlossaryAlias(entry.id);
if (result.status === "error") toast.error(t(result.message));
else {
toast.success(t("ลบ Shortcut แล้ว"));
router.refresh();
}
})}><Trash2Icon /></Button>
</div>
);
})}
</CardContent>
</Card>
</div>
);
}