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