236 lines
9.8 KiB
TypeScript
236 lines
9.8 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, StarIcon, UsersIcon } from "lucide-react";
|
|
import { 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 {
|
|
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;
|
|
|
|
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 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;
|
|
|
|
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-col gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<InputGroup className="h-9 w-48 shrink-0">
|
|
<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="เรียงตาม"><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="flex min-w-0 items-center gap-2 overflow-x-auto pb-1" aria-label="ตัวกรองไกด์">
|
|
<GuideToggleFilter label="ธาตุ" value={selectedElements} options={elements} optionLabels={elementLabels} 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} 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)} optionIcon={() => <StarIcon className="size-4 fill-current text-yellow-500" />} optionText={(option) => option} onChange={(values) => setParam("rarity", values.join(","))} />
|
|
</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 GuideToggleFilter({
|
|
label,
|
|
value,
|
|
options,
|
|
optionLabels = {},
|
|
optionIcon,
|
|
optionText,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: string[];
|
|
options: string[];
|
|
optionLabels?: Record<string, string>;
|
|
optionIcon?: (value: string) => ReactNode;
|
|
optionText?: (value: string) => string;
|
|
onChange: (value: string[]) => void;
|
|
}) {
|
|
return (
|
|
<div className="shrink-0 border-l pl-2">
|
|
<ToggleGroup aria-label={label} variant="outline" size="sm" value={value} onValueChange={onChange}>
|
|
{options.map((option) => (
|
|
<ToggleGroupItem key={option} value={option} aria-label={`${label}: ${optionLabels[option] ?? option}`} title={`${label}: ${optionLabels[option] ?? option}`} className="aria-pressed:border-primary aria-pressed:bg-transparent aria-pressed:text-primary">
|
|
{optionIcon?.(option)}{optionText?.(option)}
|
|
</ToggleGroupItem>
|
|
))}
|
|
</ToggleGroup>
|
|
</div>
|
|
);
|
|
}
|