Files
pinkudex/components/grid/MultiFilterPopover.tsx
T
2026-05-26 22:46:00 +02:00

247 lines
9.7 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Search, ChevronDown, Users, Building2, Film, Hash, FolderHeart, Tag, X, Check, Layers } from "lucide-react";
import { cn } from "@/lib/utils";
import { tabSupportsAnd, type FilterCriteria, type FilterTabKey } from "@/lib/filters";
export interface FilterOption {
id: number;
name: string;
count?: number;
}
const TAB_META: Array<{ key: FilterTabKey; label: string; Icon: React.ComponentType<{ className?: string }> }> = [
{ key: "actresses", label: "Actresses", Icon: Users },
{ key: "studios", label: "Studios", Icon: Building2 },
{ key: "series", label: "Series", Icon: Film },
{ key: "categories", label: "Categories", Icon: Layers },
{ key: "tags", label: "Tags", Icon: Tag },
{ key: "genres", label: "Genres", Icon: Hash },
{ key: "collections", label: "Collections", Icon: FolderHeart },
];
export function MultiFilterPopover({
criteria,
options,
onChange,
}: {
criteria: FilterCriteria;
options: Record<FilterTabKey, FilterOption[]>;
onChange: (next: FilterCriteria) => void;
}) {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<FilterTabKey>("actresses");
const [search, setSearch] = useState("");
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const total = useMemo(() => {
let n = 0;
for (const t of TAB_META) n += criteria.ids[t.key].length;
return n;
}, [criteria]);
function toggleId(tab: FilterTabKey, id: number) {
const cur = criteria.ids[tab];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
onChange({ ...criteria, ids: { ...criteria.ids, [tab]: next } });
}
function setMode(tab: FilterTabKey, mode: "and" | "or") {
onChange({ ...criteria, mode: { ...criteria.mode, [tab]: mode } });
}
function clearTab(tab: FilterTabKey) {
onChange({ ...criteria, ids: { ...criteria.ids, [tab]: [] } });
}
function clearAllTabs() {
onChange({
...criteria,
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
});
}
const tabOptions = options[activeTab] ?? [];
const q = search.trim().toLowerCase();
const filteredOptions = q ? tabOptions.filter((o) => o.name.toLowerCase().includes(q)) : tabOptions;
const supportsAnd = tabSupportsAnd(activeTab);
return (
<div className="relative" ref={wrapRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors min-w-[140px] justify-between",
total > 0
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: "glass glass-hover text-[var(--color-fg-dim)]",
)}
>
Browse
<span
className={cn(
"inline-flex items-center justify-center w-[22px] h-4 rounded-full bg-[var(--color-cyan)] text-black text-[10px] font-mono font-bold tabular-nums",
total === 0 && "invisible",
)}
>
{total || 0}
</span>
<ChevronDown className="w-3 h-3 opacity-70" />
</button>
{open && (
<div
className="absolute left-0 top-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-2xl shadow-2xl p-3 w-[720px] max-w-[calc(100vw-32px)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-wrap gap-1 border-b border-[var(--color-glass-border)] pb-2 mb-3">
{TAB_META.map(({ key, label, Icon }) => {
const count = criteria.ids[key].length;
const isActive = key === activeTab;
return (
<button
key={key}
type="button"
onClick={() => { setActiveTab(key); setSearch(""); }}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs transition-colors",
isActive
? "bg-[var(--color-glass-strong)] text-[var(--color-cyan)]"
: "text-[var(--color-fg-muted)] hover:bg-[var(--color-glass)] hover:text-[var(--color-fg-dim)]",
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
{count > 0 && (
<span className="inline-flex items-center justify-center min-w-[14px] h-3.5 px-1 rounded-full bg-[var(--color-cyan)] text-black text-[9px] font-mono font-bold">
{count}
</span>
)}
</button>
);
})}
</div>
<div className="flex items-center gap-2 mb-2">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={`Filter ${activeTab}`}
className="w-full glass rounded-lg pl-8 pr-2 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
</div>
{supportsAnd && (
<div className="inline-flex border border-[var(--color-glass-border)] rounded-lg overflow-hidden text-[11px] font-mono">
<button
type="button"
onClick={() => setMode(activeTab, "and")}
className={cn(
"px-2.5 py-1 transition-colors",
criteria.mode[activeTab] === "and"
? "bg-[var(--color-cyan)] text-black font-bold"
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]",
)}
>
AND
</button>
<button
type="button"
onClick={() => setMode(activeTab, "or")}
className={cn(
"px-2.5 py-1 transition-colors",
criteria.mode[activeTab] === "or"
? "bg-[var(--color-cyan)] text-black font-bold"
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]",
)}
>
OR
</button>
</div>
)}
</div>
<div className="max-h-[260px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-2 py-3">
{q ? "No matches" : `No ${activeTab} yet`}
</div>
) : (
filteredOptions.map((o) => {
const checked = criteria.ids[activeTab].includes(o.id);
return (
<button
key={o.id}
type="button"
onClick={() => toggleId(activeTab, o.id)}
className={cn(
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors",
checked ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<span className={cn(
"w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0",
checked ? "bg-[var(--color-cyan)]/20 border-[var(--color-cyan)]" : "border-[var(--color-glass-border-strong)]",
)}>
{checked && <Check className="w-2.5 h-2.5" strokeWidth={3} />}
</span>
<span className="flex-1 truncate">{o.name}</span>
{typeof o.count === "number" && (
<span className="font-mono text-[11px] text-[var(--color-fg-muted)]">{o.count}</span>
)}
</button>
);
})
)}
</div>
<div className="mt-2 pt-2 border-t border-[var(--color-glass-border)] flex items-center justify-between text-[11px] font-mono text-[var(--color-fg-muted)]">
<span>
{supportsAnd
? "tap to toggle · AND = match all · OR = match any"
: "tap to toggle"}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => clearTab(activeTab)}
className="text-[var(--color-coral)] hover:underline"
>
Clear Tab
</button>
<span className="text-[var(--color-fg-muted)]">|</span>
<button
type="button"
onClick={clearAllTabs}
className="text-[var(--color-coral)] hover:underline"
>
Clear All Tabs
</button>
</div>
</div>
</div>
)}
</div>
);
}
export const FILTER_TABS = TAB_META;