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

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>
);
}