211 lines
6.8 KiB
TypeScript
211 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { SearchIcon, SparklesIcon, XIcon } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { MediaImage } from "@/components/public/media-image";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Empty,
|
|
EmptyContent,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
} from "@/components/ui/empty";
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupInput,
|
|
} from "@/components/ui/input-group";
|
|
import type { PublicCharacterCard } from "@/lib/content/types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface CharacterDirectoryProps {
|
|
characters: PublicCharacterCard[];
|
|
}
|
|
|
|
function unique(values: Array<string | null>): string[] {
|
|
return [...new Set(values.filter((value): value is string => Boolean(value)))];
|
|
}
|
|
|
|
export function CharacterDirectory({ characters }: CharacterDirectoryProps) {
|
|
const [query, setQuery] = useState("");
|
|
const [element, setElement] = useState<string | null>(null);
|
|
const [role, setRole] = useState<string | null>(null);
|
|
const elements = useMemo(
|
|
() => unique(characters.map((character) => character.element)),
|
|
[characters],
|
|
);
|
|
const roles = useMemo(
|
|
() => unique(characters.map((character) => character.role)),
|
|
[characters],
|
|
);
|
|
const normalizedQuery = query.trim().toLocaleLowerCase("th");
|
|
const filtered = characters.filter((character) => {
|
|
const searchable = `${character.nameTh} ${character.nameEn ?? ""}`.toLocaleLowerCase("th");
|
|
return (
|
|
(!normalizedQuery || searchable.includes(normalizedQuery)) &&
|
|
(!element || character.element === element) &&
|
|
(!role || character.role === role)
|
|
);
|
|
});
|
|
const hasFilters = Boolean(query || element || role);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div className="space-y-4">
|
|
<InputGroup className="h-11 max-w-2xl bg-card">
|
|
<InputGroupAddon>
|
|
<SearchIcon aria-hidden="true" />
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="ค้นหาชื่อตัวละคร…"
|
|
aria-label="ค้นหาตัวละคร"
|
|
/>
|
|
{query ? (
|
|
<InputGroupAddon align="inline-end">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="ล้างคำค้นหา"
|
|
onClick={() => setQuery("")}
|
|
>
|
|
<XIcon aria-hidden="true" />
|
|
</Button>
|
|
</InputGroupAddon>
|
|
) : null}
|
|
</InputGroup>
|
|
|
|
<div className="flex flex-wrap gap-x-5 gap-y-3 text-sm">
|
|
{elements.length > 0 ? (
|
|
<FilterGroup
|
|
label="ธาตุ"
|
|
values={elements}
|
|
selected={element}
|
|
onChange={setElement}
|
|
/>
|
|
) : null}
|
|
{roles.length > 0 ? (
|
|
<FilterGroup
|
|
label="บทบาท"
|
|
values={roles}
|
|
selected={role}
|
|
onChange={setRole}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{filtered.length > 0 ? (
|
|
<div
|
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
|
aria-live="polite"
|
|
>
|
|
{filtered.map((character) => (
|
|
<Link
|
|
key={character.id}
|
|
href={`/${character.slug}/${character.firstPageSlug}`}
|
|
className="group rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Card className="h-full gap-0 py-0 transition-transform group-hover:-translate-y-0.5 group-focus-visible:ring-2 group-focus-visible:ring-ring">
|
|
<MediaImage
|
|
mediaId={character.portraitMediaId}
|
|
alt={`ภาพ ${character.nameTh}`}
|
|
className="aspect-[4/3] min-h-0 rounded-t-xl"
|
|
/>
|
|
<CardHeader className="pt-4">
|
|
<CardTitle className="text-lg">{character.nameTh}</CardTitle>
|
|
<CardDescription>{character.nameEn ?? "คู่มือตัวละคร"}</CardDescription>
|
|
{character.rarity ? (
|
|
<CardAction>
|
|
<Badge variant="secondary">{character.rarity}★</Badge>
|
|
</CardAction>
|
|
) : null}
|
|
</CardHeader>
|
|
<CardContent className="flex flex-wrap gap-2 pb-4">
|
|
{character.element ? <Badge>{character.element}</Badge> : null}
|
|
{character.role ? (
|
|
<Badge variant="outline">{character.role}</Badge>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Empty className="border bg-card/40" aria-live="polite">
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<SparklesIcon aria-hidden="true" />
|
|
</EmptyMedia>
|
|
<EmptyTitle>ไม่พบตัวละครที่ตรงกัน</EmptyTitle>
|
|
<EmptyDescription>
|
|
ลองใช้ชื่ออื่นหรือเอาตัวกรองบางรายการออก
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
{hasFilters ? (
|
|
<EmptyContent>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setQuery("");
|
|
setElement(null);
|
|
setRole(null);
|
|
}}
|
|
>
|
|
ล้างตัวกรอง
|
|
</Button>
|
|
</EmptyContent>
|
|
) : null}
|
|
</Empty>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterGroup({
|
|
label,
|
|
values,
|
|
selected,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
values: string[];
|
|
selected: string | null;
|
|
onChange: (value: string | null) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2" role="group" aria-label={label}>
|
|
<span className="text-muted-foreground">{label}</span>
|
|
{values.map((value) => (
|
|
<Button
|
|
key={value}
|
|
type="button"
|
|
size="xs"
|
|
variant={selected === value ? "secondary" : "ghost"}
|
|
aria-pressed={selected === value}
|
|
className={cn(selected === value && "text-primary")}
|
|
onClick={() => onChange(selected === value ? null : value)}
|
|
>
|
|
{value}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|