132 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|