Files
buzz-sheet/components/public/structured-directory.tsx
T
2026-10-07 17:03:15 +07:00

303 lines
14 KiB
TypeScript

"use client";
import { searchItems } from "@/lib/search";
import Image from "@/components/ui/resilient-image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SearchIcon, SlidersHorizontalIcon, StarIcon, UsersIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, type ReactNode } from "react";
import { ElementIcon } from "@/components/public/element-icon";
import {
Card,
CardAction,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { useVisitorPresence } from "@/hooks/use-presence";
import { weaponTypeIconPath } from "@/lib/overview-image";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type GuideCard = {
id: string;
slug: string;
name: string;
overview: string;
coverUrl: string;
version: number;
aliases: string[];
element: string | null;
elementIconUrl: string | null;
weaponType: string | null;
rarity: number | null;
updatedAt: Date;
publishedAt: Date | null;
};
const elementLabels: Record<string, string> = {
Pyro: "ไฟ",
Hydro: "น้ำ",
Anemo: "ลม",
Electro: "ไฟฟ้า",
Dendro: "ไม้",
Cryo: "น้ำแข็ง",
Geo: "หิน",
};
const weaponTypeLabels: Record<string, string> = {
WEAPON_SWORD_ONE_HAND: "ดาบ",
WEAPON_CLAYMORE: "ดาบใหญ่",
WEAPON_POLE: "หอก",
WEAPON_BOW: "ธนู",
WEAPON_CATALYST: "สื่อเวท",
};
const sortOptions = [
{ value: "manual", label: "แนะนำ" },
{ value: "recent", label: "อัปเดตล่าสุด" },
{ value: "name-asc", label: "A - Z" },
{ value: "name-desc", label: "Z - A" },
] as const;
const SORT_STORAGE_KEY = "buzz-sheet:directory-sort";
export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const searchParams = useSearchParams();
useEffect(() => {
if (searchParams.has("sort")) return;
try {
const savedSort = window.localStorage.getItem(SORT_STORAGE_KEY);
if (!savedSort || !sortOptions.some((option) => option.value === savedSort) || savedSort === "manual") return;
const next = new URLSearchParams(searchParams.toString());
next.set("sort", savedSort);
window.history.replaceState(null, "", `?${next.toString()}`);
} catch {
// Sorting still works when browser storage is unavailable.
}
}, [searchParams]);
const query = searchParams.get("q") ?? "";
const presence = useVisitorPresence(null);
const elements = useMemo(() => [...new Set(guides.flatMap((guide) => guide.element ? [guide.element] : []))].sort(), [guides]);
const weapons = useMemo(() => [...new Set(guides.flatMap((guide) => guide.weaponType ? [guide.weaponType] : []))].sort(), [guides]);
const rarities = useMemo(() => [...new Set(guides.flatMap((guide) => guide.rarity ? [guide.rarity] : []))].sort((a, b) => b - a), [guides]);
const elementIconUrls = useMemo(() => Object.fromEntries(guides.flatMap((guide) => guide.element && guide.elementIconUrl ? [[guide.element, guide.elementIconUrl]] : [])), [guides]);
const selectedElements = [...new Set((searchParams.get("element") ?? "").split(",").filter((value) => elements.includes(value)))];
const selectedWeapons = [...new Set((searchParams.get("weapon") ?? "").split(",").filter((value) => weapons.includes(value)))];
const selectedRarities = [...new Set((searchParams.get("rarity") ?? "").split(",").filter((value) => rarities.includes(Number(value))))];
const sortParam = searchParams.get("sort");
const sort = sortParam === "recent" || sortParam === "name-asc" || sortParam === "name-desc" ? sortParam : "manual";
const matchingGuides = guides.filter((guide) =>
(!selectedElements.length || (guide.element !== null && selectedElements.includes(guide.element))) &&
(!selectedWeapons.length || (guide.weaponType !== null && selectedWeapons.includes(guide.weaponType))) &&
(!selectedRarities.length || (guide.rarity !== null && selectedRarities.includes(String(guide.rarity)))),
);
const search = searchItems(matchingGuides, query,
(guide) => `${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`,
(guide) => [guide.name, ...guide.aliases]);
const filtered = sort === "recent"
? [...search.items].sort((left, right) =>
new Date(right.publishedAt ?? right.updatedAt).getTime() - new Date(left.publishedAt ?? left.updatedAt).getTime())
: sort === "name-asc" || sort === "name-desc"
? [...search.items].sort((left, right) => {
const order = left.name.localeCompare(right.name, "th", { numeric: true });
return sort === "name-asc" ? order : -order;
})
: search.items;
const activeFilters = [
...selectedElements.map((value) => ({ name: "element", value, label: elementLabels[value] ?? value })),
...selectedWeapons.map((value) => ({ name: "weapon", value, label: weaponTypeLabels[value] ?? value })),
...selectedRarities.map((value) => ({ name: "rarity", value, label: `${value} ดาว` })),
];
function setParam(name: string, value: string) {
if (name === "sort") {
try {
window.localStorage.setItem(SORT_STORAGE_KEY, value);
} catch {
// Keep the selection usable when browser storage is unavailable.
}
}
const next = new URLSearchParams(searchParams.toString());
if (!value || value === "all") next.delete(name);
else next.set(name, value);
const suffix = next.toString();
window.history.replaceState(null, "", suffix ? `?${suffix}` : window.location.pathname);
}
function clearParams(includeSearch = false) {
const next = new URLSearchParams(searchParams.toString());
for (const name of ["element", "weapon", "rarity", ...(includeSearch ? ["q"] : [])]) next.delete(name);
const suffix = next.toString();
window.history.replaceState(null, "", suffix ? `?${suffix}` : window.location.pathname);
}
function removeFilter(name: string, value: string) {
const values = name === "element" ? selectedElements : name === "weapon" ? selectedWeapons : selectedRarities;
setParam(name, values.filter((item) => item !== value).join(","));
}
const filterControls = (mobile: boolean) => <>
<GuideToggleFilter label="ธาตุ" value={selectedElements} options={elements} optionLabels={elementLabels} showLabels={mobile} optionIcon={(option) => elementIconUrls[option] ? <ElementIcon element={option} url={elementIconUrls[option]} /> : null} onChange={(values) => setParam("element", values.join(","))} />
<GuideToggleFilter label="อาวุธ" value={selectedWeapons} options={weapons} optionLabels={weaponTypeLabels} showLabels={mobile} optionIcon={(option) => {
const src = weaponTypeIconPath(option);
return src ? <Image src={src} alt="" width={16} height={16} className="size-4 object-contain" /> : null;
}} onChange={(values) => setParam("weapon", values.join(","))} />
<GuideToggleFilter label="ดาว" value={selectedRarities} options={rarities.map(String)} showLabels={mobile} optionIcon={() => <StarIcon className="size-4 fill-current text-yellow-500" />} optionText={(option) => option} onChange={(values) => setParam("rarity", values.join(","))} />
</>;
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<InputGroup className="min-h-11 sm:min-h-9 min-w-0 flex-1 sm:w-48 sm:flex-none">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setParam("q", event.target.value)}
placeholder="ค้นหาชื่อตัวละคร…"
aria-label="ค้นหาไกด์"
/>
</InputGroup>
<Select value={sort} onValueChange={(next) => next && setParam("sort", next)}>
<SelectTrigger aria-label="เรียงตาม" className="min-h-11 w-32 sm:min-h-0 sm:w-auto"><SelectValue>{sortOptions.find((option) => option.value === sort)?.label}</SelectValue></SelectTrigger>
<SelectContent>
<SelectGroup>
{sortOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="sm:hidden">
<Drawer>
<DrawerTrigger render={<Button variant="outline" className="min-h-11 w-full" />}>
<SlidersHorizontalIcon data-icon="inline-start" />ตัวกรอง{activeFilters.length ? ` (${activeFilters.length})` : ""}
</DrawerTrigger>
<DrawerContent>
<DrawerHeader><DrawerTitle>ตัวกรองไกด์</DrawerTitle><DrawerDescription>เลือกธาตุ อาวุธ หรือจำนวนดาว</DrawerDescription></DrawerHeader>
<div className="flex max-h-[60svh] flex-col gap-5 overflow-y-auto px-4 pb-4">{filterControls(true)}</div>
<div className="flex gap-2 border-t p-4">
<Button variant="outline" className="min-h-11" disabled={!activeFilters.length} onClick={() => clearParams()}>ล้างตัวกรอง</Button>
<DrawerClose render={<Button className="min-h-11 flex-1" />}>ดู {filtered.length} ไกด์</DrawerClose>
</div>
</DrawerContent>
</Drawer>
</div>
<div className="hidden min-w-0 flex-wrap items-start gap-5 pb-1 sm:flex" aria-label="ตัวกรองไกด์">{filterControls(true)}</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground" aria-live="polite">
<span>พบ {filtered.length} ไกด์</span>
{activeFilters.map((filter) => <Button key={`${filter.name}-${filter.value}`} variant="secondary" size="xs"
onClick={() => removeFilter(filter.name, filter.value)} aria-label={`ลบตัวกรอง ${filter.label}`}>
{filter.label}<XIcon data-icon="inline-end" />
</Button>)}
{activeFilters.length ? <Button variant="link" size="sm" onClick={() => clearParams()}>ล้างตัวกรอง</Button> : null}
</div>
</div>
{filtered.length ? (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((guide, index) => (
<Link
key={guide.id}
href={`/${guide.slug}`}
prefetch={false}
className="group rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Card className="h-full flex-row gap-0 overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5 sm:flex-col sm:gap-4">
<div className="relative size-24 shrink-0 bg-muted sm:aspect-square sm:size-auto">
<Image
src={guide.coverUrl}
alt={`ไกด์ ${guide.name}`}
fill
fetchPriority={index === 0 ? "high" : undefined}
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 96px"
className="object-cover"
/>
</div>
<CardHeader className="min-w-0 flex-1 p-3 sm:p-4 sm:pt-5">
<div className="min-w-0 space-y-1">
<CardTitle className="line-clamp-1">{guide.name}</CardTitle>
<CardDescription className="line-clamp-2 sm:line-clamp-3">
{guide.overview}
</CardDescription>
</div>
<CardAction
className={(presence?.byGuide[guide.id] ?? 0) > 0 ? "hidden sm:block" : "hidden sm:block sm:invisible"}
>
<Badge variant="secondary" className="min-w-14 tabular-nums">
<UsersIcon data-icon="inline-start" />
{presence?.byGuide[guide.id] ?? 0} คน
</Badge>
</CardAction>
</CardHeader>
</Card>
</Link>
))}
</div>
) : (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>ไม่พบไกด์</EmptyTitle>
<EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น หรือล้างตัวกรอง</EmptyDescription>
</EmptyHeader>
<Button variant="outline" onClick={() => clearParams(true)}>ล้างการค้นหาและตัวกรอง</Button>
</Empty>
)}
</div>
);
}
function GuideToggleFilter({
label,
value,
options,
optionLabels = {},
optionIcon,
optionText,
showLabels = false,
onChange,
}: {
label: string;
value: string[];
options: string[];
optionLabels?: Record<string, string>;
optionIcon?: (value: string) => ReactNode;
optionText?: (value: string) => string;
showLabels?: boolean;
onChange: (value: string[]) => void;
}) {
return (
<div className={showLabels ? "flex flex-col gap-2" : "shrink-0 border-l pl-2"}>
<ToggleGroup aria-label={label} variant="outline" size="sm" multiple value={value} onValueChange={onChange} className={showLabels ? "flex flex-wrap justify-start" : undefined}>
{options.map((option) => (
<ToggleGroupItem key={option} value={option} aria-label={`${label}: ${optionLabels[option] ?? option}`} title={`${label}: ${optionLabels[option] ?? option}`} className="max-sm:min-h-11 aria-pressed:border-primary aria-pressed:bg-transparent aria-pressed:text-primary">
{optionIcon?.(option)}{showLabels ? optionLabels[option] ?? option : optionText?.(option)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}