241 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|