Initial commit
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user