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