Files
2026-05-26 22:46:00 +02:00

132 lines
4.3 KiB
TypeScript

"use client";
import { useMemo, useRef, useState, useCallback } from "react";
import Link from "next/link";
import { ChevronDown, Search, Tag, FolderHeart, Users, Building2, Film, Hash, X } from "lucide-react";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { cn } from "@/lib/utils";
export interface FilterOption {
id: string | number;
label: string;
href: string;
count?: number;
}
const ICONS = {
tag: Tag,
folder: FolderHeart,
actress: Users,
studio: Building2,
series: Film,
genre: Hash,
} as const;
export type FilterIconKey = keyof typeof ICONS;
export function FilterDropdown({
label,
iconKey,
options,
emptyMsg = "Nothing here yet",
align = "left",
activeLabel,
clearHref,
}: {
label: string;
iconKey?: FilterIconKey;
options: FilterOption[];
emptyMsg?: string;
align?: "left" | "right";
activeLabel?: string;
clearHref?: string;
}) {
const Icon = iconKey ? ICONS[iconKey] : null;
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
}, [options, filter]);
const isActive = activeLabel != null;
return (
<div ref={ref} className="relative">
<div
className={cn(
"flex items-center rounded-full border text-sm transition-colors",
isActive
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "glass glass-hover text-[var(--color-fg-dim)]",
)}
>
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 pl-3 pr-2 py-1.5"
>
{Icon && <Icon className="w-3.5 h-3.5" />}
<span>{label}{isActive ? `: ${activeLabel}` : ""}</span>
<ChevronDown className={cn("w-3 h-3 opacity-60 transition-transform", open && "rotate-180")} />
</button>
{isActive && clearHref && (
<Link
href={clearHref}
aria-label="Clear filter"
className="pr-2 pl-1 py-1.5 hover:opacity-70"
>
<X className="w-3.5 h-3.5" />
</Link>
)}
</div>
{open && (
<div
className={cn(
"absolute top-full mt-2 z-30 w-64 rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden",
align === "right" ? "right-0" : "left-0",
)}
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<div className="relative p-2 border-b border-[var(--color-glass-border)]">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-fg-muted)] pointer-events-none" />
<input
autoFocus
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={`Filter ${label.toLowerCase()}`}
className="w-full bg-[var(--color-bg-1)]/60 text-xs pl-7 pr-2 py-1.5 rounded-md border border-[var(--color-glass-border)] outline-none focus:border-[var(--color-cyan)]"
/>
</div>
{filtered.length === 0 ? (
<div className="px-3 py-4 text-xs text-[var(--color-fg-muted)] italic text-center">
{filter ? "No matches" : emptyMsg}
</div>
) : (
<div className="max-h-72 overflow-y-auto py-1">
{filtered.map((o) => (
<Link
key={o.id}
href={o.href}
onClick={() => setOpen(false)}
className="flex items-center justify-between gap-2 px-3 py-1.5 text-sm hover:bg-[var(--color-glass)]"
>
<span className="truncate">{o.label}</span>
{o.count != null && (
<span className="text-xs font-mono text-[var(--color-fg-muted)] tabular-nums flex-shrink-0">
{o.count}
</span>
)}
</Link>
))}
</div>
)}
</div>
)}
</div>
);
}