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

167 lines
6.3 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FilterCriteria, FilterTabKey, StatusAxisKey } from "@/lib/filters";
import { anyActive, EMPTY_STATUS } from "@/lib/filters";
import { FILTER_TABS, type FilterOption } from "./MultiFilterPopover";
import { useSelection } from "@/components/select/SelectionProvider";
const WATCHED_LABELS: Record<string, string> = { watched: "Watched", unwatched: "Unwatched" };
const RATED_LABELS: Record<string, string> = { rated: "Rated", unrated: "No Rating" };
const PRESENCE_LABELS: Record<string, string> = { has: "Has", missing: "No" };
function pillFor(axis: StatusAxisKey, value: string): string | null {
if (value === "all") return null;
if (axis === "watched") return WATCHED_LABELS[value] ?? null;
if (axis === "rated") return RATED_LABELS[value] ?? null;
if (axis === "collection") return value === "has" ? "Has Collection" : "No Collection";
if (axis === "tags") return value === "has" ? "Has Tags" : "No Tags";
if (axis === "video") return value === "has" ? "Has Video" : "No Video";
return null;
}
const STATUS_AXES: StatusAxisKey[] = ["watched", "rated", "collection", "tags", "video"];
export function ActiveCriteriaStrip({
criteria,
options,
onChange,
}: {
criteria: FilterCriteria;
options: Record<FilterTabKey, FilterOption[]>;
onChange: (next: FilterCriteria) => void;
}) {
// SSR has no document, so defer the portal until after mount.
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const sel = useSelection();
const selectionActive = sel.ids.size > 0;
if (!anyActive(criteria)) return null;
if (!mounted) return null;
const tabSections: Array<{ key: FilterTabKey; label: string; Icon: React.ComponentType<{ className?: string }>; pills: FilterOption[] }> = [];
for (const t of FILTER_TABS) {
const ids = criteria.ids[t.key];
if (ids.length === 0) continue;
const optionMap = new Map(options[t.key].map((o) => [o.id, o]));
const pills = ids.map((id) => optionMap.get(id)).filter((o): o is FilterOption => !!o);
if (pills.length > 0) tabSections.push({ key: t.key, label: t.label, Icon: t.Icon, pills });
}
function removeId(tab: FilterTabKey, id: number) {
onChange({ ...criteria, ids: { ...criteria.ids, [tab]: criteria.ids[tab].filter((x) => x !== id) } });
}
function resetAxis(key: StatusAxisKey) {
onChange({ ...criteria, status: { ...criteria.status, [key]: "all" } as typeof criteria.status });
}
function removeMark(m: typeof criteria.marks[number]) {
onChange({ ...criteria, marks: criteria.marks.filter((x) => x !== m) });
}
function clearAll() {
onChange({
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
mode: criteria.mode,
status: { ...EMPTY_STATUS },
marks: [],
});
}
return createPortal(
<div
className="fixed left-1/2 -translate-x-1/2 z-40 flex items-center gap-1.5 flex-wrap py-2 px-3 rounded-2xl border border-[var(--color-glass-border-strong)] shadow-2xl backdrop-blur-2xl"
style={{
bottom: selectionActive ? "76px" : "20px",
background: "color-mix(in oklch, var(--color-bg-0) 88%, transparent)",
width: "max-content",
maxWidth: "min(96vw, 1600px)",
}}
>
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mr-1">Active Filters</span>
{tabSections.map((section, sIdx) => (
<span key={section.key} className="inline-flex items-center gap-1 flex-wrap">
{section.pills.map((p, i) => (
<span key={p.id} className="inline-flex items-center gap-1">
<Pill kind="cyan" Icon={section.Icon} label={p.name} onRemove={() => removeId(section.key, p.id)} />
{i < section.pills.length - 1 && (
<span className="text-[10px] font-mono text-[var(--color-fg-muted)] px-0.5">
{criteria.mode[section.key].toUpperCase()}
</span>
)}
</span>
))}
{sIdx < tabSections.length - 1 && (
<span className="text-[10px] font-mono text-[var(--color-violet)] px-1">AND</span>
)}
</span>
))}
{STATUS_AXES.map((axis) => {
const label = pillFor(axis, criteria.status[axis]);
if (!label) return null;
return <Pill key={axis} kind="coral" label={label} onRemove={() => resetAxis(axis)} />;
})}
{criteria.marks.map((m) => (
<Pill
key={m}
kind={m === "vip" ? "cyan" : m === "favorite" ? "amber" : m === "owned" ? "violet" : "coral"}
label={m === "vip" ? "VIP" : m === "favorite" ? "Favorite" : m === "owned" ? "Owned" : "Unmarked"}
onRemove={() => removeMark(m)}
/>
))}
<button
type="button"
onClick={clearAll}
className="ml-auto text-[11px] text-[var(--color-fg-muted)] hover:text-[var(--color-coral)] underline"
>
Clear All
</button>
</div>,
document.body,
);
}
function Pill({
kind,
label,
Icon,
onRemove,
}: {
kind: "cyan" | "coral" | "amber" | "violet";
label: string;
Icon?: React.ComponentType<{ className?: string }>;
onRemove: () => void;
}) {
const cls =
kind === "cyan"
? "bg-[var(--color-cyan)]/12 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
: kind === "amber"
? "border-amber-400/40 text-amber-200"
: kind === "violet"
? "bg-[var(--color-violet)]/12 border-[var(--color-violet)]/40 text-[var(--color-violet)]"
: "bg-[var(--color-coral)]/12 border-[var(--color-coral)]/40 text-[var(--color-coral)]";
return (
<span
className={cn("inline-flex items-center gap-1.5 pl-2.5 pr-1 py-0.5 rounded-full border text-xs font-mono", cls)}
style={kind === "amber" ? { background: "rgba(251,191,36,0.12)" } : undefined}
>
{Icon && <Icon className="w-3 h-3 opacity-70" />}
{label}
<button
type="button"
onClick={onRemove}
className="w-4 h-4 grid place-items-center rounded-full bg-black/30 hover:bg-[var(--color-coral)]/40 hover:text-white"
aria-label={`Remove ${label}`}
>
<X className="w-2.5 h-2.5" />
</button>
</span>
);
}