Files
buzz-sheet/components/public/structured-directory.tsx
T

210 lines
7.7 KiB
TypeScript

"use client";
import { searchItems } from "@/lib/search";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SearchIcon, UsersIcon } from "lucide-react";
import { useMemo } from "react";
import {
Card,
CardAction,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
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 {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type GuideCard = {
id: string;
slug: string;
name: string;
overview: string;
coverUrl: string;
version: number;
aliases: string[];
element: 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: "สื่อเวท",
};
export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const searchParams = useSearchParams();
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 elementParam = searchParams.get("element");
const weaponParam = searchParams.get("weapon");
const rarityParam = searchParams.get("rarity");
const element = elementParam && elements.includes(elementParam) ? elementParam : "all";
const weapon = weaponParam && weapons.includes(weaponParam) ? weaponParam : "all";
const rarity = rarityParam && rarities.includes(Number(rarityParam)) ? rarityParam : "all";
const sort = searchParams.get("sort") === "recent" ? "recent" : "manual";
const matchingGuides = guides.filter((guide) =>
(element === "all" || guide.element === element) &&
(weapon === "all" || guide.weaponType === weapon) &&
(rarity === "all" || guide.rarity === Number(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())
: search.items;
function setParam(name: string, value: string) {
const next = new URLSearchParams(searchParams.toString());
if (!value || value === "all" || (name === "sort" && value === "manual")) next.delete(name);
else next.set(name, value);
const suffix = next.toString();
window.history.replaceState(null, "", suffix ? `?${suffix}` : window.location.pathname);
}
return (
<div className="flex flex-col gap-8">
<div className="flex flex-wrap items-center gap-2">
<InputGroup className="h-11 min-w-64 flex-1">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setParam("q", event.target.value)}
placeholder="ค้นหาไกด์…"
aria-label="ค้นหาไกด์"
/>
</InputGroup>
<div className="flex flex-wrap gap-2" aria-label="ตัวกรองไกด์">
<GuideFilter label="ธาตุ" value={element} options={elements} optionLabels={elementLabels} onChange={(value) => setParam("element", value)} />
<GuideFilter label="อาวุธ" value={weapon} options={weapons} optionLabels={weaponTypeLabels} onChange={(value) => setParam("weapon", value)} />
<GuideFilter label="ดาว" value={rarity} options={rarities.map(String)} onChange={(value) => setParam("rarity", value)} />
<GuideFilter label="เรียงตาม" value={sort} options={["manual", "recent"]} optionLabels={{ manual: "แนะนำ", recent: "อัปเดตล่าสุด" }} includeAll={false} onChange={(value) => setParam("sort", value)} />
</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 overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5">
<div className="relative aspect-square bg-muted">
<Image
src={guide.coverUrl}
alt={`ไกด์ ${guide.name}`}
fill
fetchPriority={index === 0 ? "high" : undefined}
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover"
/>
</div>
<CardHeader className="pt-5">
<CardTitle>{guide.name}</CardTitle>
<CardAction
className={(presence?.byGuide[guide.id] ?? 0) > 0 ? undefined : "invisible"}
>
<Badge variant="secondary" className="min-w-14 tabular-nums">
<UsersIcon data-icon="inline-start" />
{presence?.byGuide[guide.id] ?? 0} คน
</Badge>
</CardAction>
<CardDescription className="line-clamp-3">
{guide.overview}
</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
) : (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>ไม่พบไกด์</EmptyTitle>
<EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
);
}
function GuideFilter({
label,
value,
options,
optionLabels = {},
includeAll = true,
onChange,
}: {
label: string;
value: string;
options: string[];
optionLabels?: Record<string, string>;
includeAll?: boolean;
onChange: (value: string) => void;
}) {
return (
<Select value={value} onValueChange={(next) => next && onChange(next)}>
<SelectTrigger aria-label={label}>
<SelectValue>{optionLabels[value] ?? (value === "all" ? label : value)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{includeAll ? <SelectItem value="all">{label}: ทั้งหมด</SelectItem> : null}
{options.map((option) => (
<SelectItem key={option} value={option}>{optionLabels[option] ?? option}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}