"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 = { Pyro: "ไฟ", Hydro: "น้ำ", Anemo: "ลม", Electro: "ไฟฟ้า", Dendro: "ไม้", Cryo: "น้ำแข็ง", Geo: "หิน", }; const weaponTypeLabels: Record = { 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) => <> elementIconUrls[option] ? : null} onChange={(values) => setParam("element", values.join(","))} /> { const src = weaponTypeIconPath(option); return src ? : null; }} onChange={(values) => setParam("weapon", values.join(","))} /> } optionText={(option) => option} onChange={(values) => setParam("rarity", values.join(","))} /> ; return (
setParam("q", event.target.value)} placeholder="ค้นหาชื่อตัวละคร…" aria-label="ค้นหาไกด์" />
}> ตัวกรอง{activeFilters.length ? ` (${activeFilters.length})` : ""} ตัวกรองไกด์เลือกธาตุ อาวุธ หรือจำนวนดาว
{filterControls(true)}
}>ดู {filtered.length} ไกด์
{filterControls(true)}
พบ {filtered.length} ไกด์ {activeFilters.map((filter) => )} {activeFilters.length ? : null}
{filtered.length ? (
{filtered.map((guide, index) => (
{`ไกด์
{guide.name} {guide.overview}
0 ? "hidden sm:block" : "hidden sm:block sm:invisible"} > {presence?.byGuide[guide.id] ?? 0} คน
))}
) : ( ไม่พบไกด์ ลองค้นหาด้วยชื่อตัวละครอื่น หรือล้างตัวกรอง )}
); } function GuideToggleFilter({ label, value, options, optionLabels = {}, optionIcon, optionText, showLabels = false, onChange, }: { label: string; value: string[]; options: string[]; optionLabels?: Record; optionIcon?: (value: string) => ReactNode; optionText?: (value: string) => string; showLabels?: boolean; onChange: (value: string[]) => void; }) { return (
{options.map((option) => ( {optionIcon?.(option)}{showLabels ? optionLabels[option] ?? option : optionText?.(option)} ))}
); }