229 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|