166 lines
6.7 KiB
TypeScript
166 lines
6.7 KiB
TypeScript
"use client";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Bookmark, ChevronDown, Gem, Star, MinusCircle, Package, Check } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import type { FilterCriteria, MarkOption } from "@/lib/filters";
|
|
|
|
const OPTIONS: Array<{
|
|
value: MarkOption;
|
|
label: string;
|
|
Icon: React.ComponentType<{ className?: string }>;
|
|
tint: "cyan" | "amber" | "violet" | "muted";
|
|
}> = [
|
|
{ value: "vip", label: "VIP", Icon: Gem, tint: "cyan" },
|
|
{ value: "favorite", label: "Favorite", Icon: Star, tint: "amber" },
|
|
{ value: "owned", label: "Owned", Icon: Package, tint: "violet" },
|
|
{ value: "unmarked", label: "Unmarked", Icon: MinusCircle, tint: "muted" },
|
|
];
|
|
|
|
export function MarkPopover({
|
|
criteria,
|
|
onChange,
|
|
}: {
|
|
criteria: FilterCriteria;
|
|
onChange: (next: FilterCriteria) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const active = criteria.marks.length > 0;
|
|
|
|
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]);
|
|
|
|
function toggle(value: MarkOption) {
|
|
const has = criteria.marks.includes(value);
|
|
const next = has ? criteria.marks.filter((m) => m !== value) : [...criteria.marks, value];
|
|
onChange({ ...criteria, marks: next });
|
|
}
|
|
|
|
function selectAll() { onChange({ ...criteria, marks: [] }); }
|
|
|
|
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",
|
|
active
|
|
? "bg-[var(--color-violet)]/12 border-[var(--color-violet)]/40 text-[var(--color-violet)]"
|
|
: "glass glass-hover text-[var(--color-fg-dim)]",
|
|
)}
|
|
>
|
|
<Bookmark className="w-3.5 h-3.5" />
|
|
Filter
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--color-violet)] text-black text-[10px] font-mono font-bold tabular-nums",
|
|
!active && "invisible",
|
|
)}
|
|
>
|
|
{criteria.marks.length || 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-xl shadow-2xl p-2 w-60"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={selectAll}
|
|
className={cn(
|
|
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors",
|
|
!active
|
|
? "bg-[var(--color-glass-strong)] text-[var(--color-fg)]"
|
|
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
|
)}
|
|
>
|
|
<span className={cn(
|
|
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
|
|
!active ? "bg-[var(--color-fg)]/20 border-[var(--color-fg-dim)]" : "border-[var(--color-glass-border-strong)]",
|
|
)}>
|
|
{!active && <span className="w-1.5 h-1.5 rounded-full bg-[var(--color-fg)]" />}
|
|
</span>
|
|
<span className="flex-1">All (clear filter)</span>
|
|
</button>
|
|
|
|
<div className="h-px bg-[var(--color-glass-border)] my-1" />
|
|
|
|
{OPTIONS.map(({ value, label, Icon, tint }) => {
|
|
const on = criteria.marks.includes(value);
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => toggle(value)}
|
|
className={cn(
|
|
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors",
|
|
on
|
|
? tint === "cyan" ? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
|
|
: tint === "amber" ? "text-amber-200"
|
|
: tint === "violet" ? "bg-[var(--color-violet)]/15 text-[var(--color-violet)]"
|
|
: "bg-[var(--color-glass-strong)] text-[var(--color-fg)]"
|
|
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
|
)}
|
|
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.15)" } : undefined}
|
|
>
|
|
<span className={cn(
|
|
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
|
|
on
|
|
? tint === "cyan" ? "bg-[var(--color-cyan)]/30 border-[var(--color-cyan)]"
|
|
: tint === "amber" ? "border-amber-400"
|
|
: tint === "violet" ? "bg-[var(--color-violet)]/30 border-[var(--color-violet)]"
|
|
: "bg-[var(--color-fg-dim)]/30 border-[var(--color-fg-dim)]"
|
|
: "border-[var(--color-glass-border-strong)]",
|
|
)}
|
|
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.25)" } : undefined}>
|
|
{on && (
|
|
<Check
|
|
className="w-3 h-3"
|
|
strokeWidth={3}
|
|
style={{
|
|
color: tint === "cyan" ? "var(--color-cyan)"
|
|
: tint === "amber" ? "#fbbf24"
|
|
: tint === "violet" ? "var(--color-violet)"
|
|
: "var(--color-fg)",
|
|
}}
|
|
/>
|
|
)}
|
|
</span>
|
|
<span
|
|
className="inline-flex items-center"
|
|
style={{
|
|
color: tint === "cyan" ? "var(--color-cyan)"
|
|
: tint === "amber" ? "#fbbf24"
|
|
: tint === "violet" ? "var(--color-violet)"
|
|
: "var(--color-fg-muted)",
|
|
}}
|
|
>
|
|
<Icon className={cn("w-3.5 h-3.5", on && tint === "amber" && "fill-amber-300")} />
|
|
</span>
|
|
<span className="flex-1">{label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|