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