Files
2026-05-26 22:46:00 +02:00

229 lines
8.2 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Plus, Minus, X, Loader2, Sparkles } from "lucide-react";
import { CategoryIcon } from "./CategoryIcon";
import { useActressSelection } from "./ActressSelectionProvider";
import {
bulkAddCategory,
bulkRemoveCategory,
createActressCategory,
} from "@/app/actions/actressCategories";
import type { ActressCategory } from "@/lib/db/queries";
const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"];
export function ActressBulkBar({ categories }: { categories: ActressCategory[] }) {
const sel = useActressSelection();
const router = useRouter();
const [, start] = useTransition();
const [busy, setBusy] = useState(false);
const [openMenu, setOpenMenu] = useState<"add" | "remove" | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState(PALETTE[0]);
const ref = useRef<HTMLDivElement>(null);
// Click outside to close menus.
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpenMenu(null);
setCreating(false);
}
};
if (openMenu || creating) document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [openMenu, creating]);
const empty = sel.ids.size === 0;
const selectedIds = Array.from(sel.ids);
function runAdd(categoryId: number) {
setBusy(true);
setOpenMenu(null);
start(async () => {
try {
await bulkAddCategory(selectedIds, categoryId);
router.refresh();
} finally {
setBusy(false);
}
});
}
function runRemove(categoryId: number) {
setBusy(true);
setOpenMenu(null);
start(async () => {
try {
await bulkRemoveCategory(selectedIds, categoryId);
router.refresh();
} finally {
setBusy(false);
}
});
}
async function createAndAdd() {
if (busy) return;
const name = newName.trim();
if (!name) return;
setBusy(true);
try {
const created = await createActressCategory({ name, color: newColor, icon: "tag", priority: 50 });
if (created) {
await bulkAddCategory(selectedIds, created.id);
router.refresh();
}
setCreating(false);
setNewName("");
setOpenMenu(null);
} catch (err) {
console.error("[createAndAdd] failed:", err);
} finally {
setBusy(false);
}
}
return (
<div
ref={ref}
aria-hidden={empty}
className={`flex items-center gap-2 px-2.5 py-1 rounded-full border border-[var(--color-cyan)]/40 bg-[var(--color-cyan)]/5 transition-opacity ${empty ? "invisible" : ""}`}
>
<div className="flex items-center gap-1.5 px-1">
<Sparkles className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
<span className="text-xs font-medium tabular-nums">{sel.ids.size}</span>
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">selected</span>
</div>
<div className="relative">
<button
type="button"
onClick={() => { setOpenMenu(openMenu === "add" ? null : "add"); setCreating(false); }}
disabled={busy}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-full glass glass-hover disabled:opacity-50"
>
{busy && openMenu !== "remove" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
Add
</button>
{openMenu === "add" && (
<Menu>
{categories.map((c) => (
<MenuItem key={c.id} onClick={() => runAdd(c.id)} icon={<CategoryIcon name={c.icon} className="w-3.5 h-3.5" />} color={c.color}>
{c.name}
</MenuItem>
))}
<div className="border-t border-[var(--color-glass-border)] my-1" />
{creating ? (
<div className="px-2 py-2 space-y-2">
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") createAndAdd(); if (e.key === "Escape") setCreating(false); }}
placeholder="Category name"
maxLength={32}
className="w-full glass rounded-md px-2 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
<div className="flex flex-wrap gap-1">
{PALETTE.map((p) => (
<button
key={p}
type="button"
onClick={() => setNewColor(p)}
className={`w-5 h-5 rounded-full border-2 ${newColor === p ? "border-white" : "border-transparent"}`}
style={{ background: p }}
aria-label={`Color ${p}`}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setCreating(false)}
className="flex-1 text-xs px-2 py-1.5 rounded-md glass glass-hover"
>
Cancel
</button>
<button
type="button"
onClick={createAndAdd}
disabled={!newName.trim() || busy}
className="flex-1 text-xs px-2 py-1.5 rounded-md bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
>
Create & Assign
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setCreating(true)}
className="w-full flex items-center gap-2 text-xs px-3 py-2 hover:bg-[var(--color-glass)] text-[var(--color-cyan)]"
>
<Plus className="w-3.5 h-3.5" /> New Category
</button>
)}
</Menu>
)}
</div>
<div className="relative">
<button
type="button"
onClick={() => { setOpenMenu(openMenu === "remove" ? null : "remove"); setCreating(false); }}
disabled={busy}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-full glass glass-hover disabled:opacity-50"
>
{busy && openMenu === "remove" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Minus className="w-3.5 h-3.5" />}
Remove
</button>
{openMenu === "remove" && (
<Menu>
{categories.map((c) => (
<MenuItem key={c.id} onClick={() => runRemove(c.id)} icon={<CategoryIcon name={c.icon} className="w-3.5 h-3.5" />} color={c.color}>
{c.name}
</MenuItem>
))}
</Menu>
)}
</div>
<button
type="button"
onClick={() => sel.clear()}
title="Clear selection"
className="flex items-center gap-1 text-xs px-2 py-1 rounded-full text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
function Menu({ children }: { children: React.ReactNode }) {
return (
<div className="absolute top-full mt-2 left-0 min-w-[220px] rounded-xl border border-[var(--color-glass-border)] bg-[var(--color-bg-0)] shadow-2xl py-1 overflow-hidden max-h-[60vh] overflow-y-auto">
{children}
</div>
);
}
function MenuItem({
icon, color, children, onClick,
}: { icon?: React.ReactNode; color?: string | null; children: React.ReactNode; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-2 text-sm px-3 py-2 hover:bg-[var(--color-glass)] text-left"
style={color ? { color } : undefined}
>
<span className="shrink-0">{icon}</span>
<span className="truncate">{children}</span>
</button>
);
}