feat(library): bulk operations (delete / ignore)

Add a Select mode on /library: album cards become multi-select, with a bulk
bar to Delete selected (2-step confirm) or Ignore selected. POST
/api/library/bulk { action, ids } applies the op per id and reports ok/failed
counts.

Extracted the delete + ignore logic into lib/library-ops.ts
(removeLibraryItem, ignoreLibraryItem); the single DELETE route now reuses
removeLibraryItem. web 185 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 21:37:01 +02:00
parent 8c8a34e320
commit 0f64e3c83a
6 changed files with 224 additions and 33 deletions
+72 -2
View File
@@ -42,6 +42,43 @@ export function LibraryClient() {
const [duplicates, setDuplicates] = useState<DupItem[][]>([]);
const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null);
const [checking, setChecking] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkConfirmDelete, setBulkConfirmDelete] = useState(false);
function toggleSelect(id: string) {
setSelected((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id); else n.add(id);
return n;
});
}
function exitSelect() {
setSelectMode(false);
setSelected(new Set());
setBulkConfirmDelete(false);
}
async function bulk(action: "delete" | "ignore") {
const ids = [...selected];
if (ids.length === 0) return;
const res = await fetch("/api/library/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action, ids }),
}).catch(() => null);
if (res?.ok) {
const body = await res.json();
toast(
`${action === "delete" ? "Deleted" : "Ignoring"} ${body.ok} album${body.ok === 1 ? "" : "s"}` +
(body.failed?.length ? ` · ${body.failed.length} failed` : ""),
);
exitSelect();
refresh();
} else {
toast(`Bulk ${action} failed`);
}
}
const [q, setQ] = useState("");
const [sort, setSort] = useState<SortKey>("added");
const [open, setOpen] = useState<Album | null>(null);
@@ -185,13 +222,46 @@ export function LibraryClient() {
) : (
<SectionHeader
title="Pressings"
note={q ? `${shown.length} of ${albums.length} albums` : `${albums.length} albums`}
note={
<span className="sel-tools">
<span>{q ? `${shown.length} of ${albums.length}` : `${albums.length} albums`}</span>
<button className="btn sm ghost" onClick={() => (selectMode ? exitSelect() : setSelectMode(true))}>
{selectMode ? "Cancel" : "Select"}
</button>
</span>
}
/>
)}
{selectMode ? (
<div className="bulk-bar">
<span>{selected.size} selected</span>
{bulkConfirmDelete ? (
<>
<span className="rmeta">Delete {selected.size} album{selected.size === 1 ? "" : "s"} and their files?</span>
<button className="btn sm accent" onClick={() => bulk("delete")} disabled={selected.size === 0}>Delete</button>
<button className="btn sm ghost" onClick={() => setBulkConfirmDelete(false)}>Cancel</button>
</>
) : (
<>
<button className="btn sm ghost" onClick={() => setBulkConfirmDelete(true)} disabled={selected.size === 0}>Delete selected</button>
<button className="btn sm ghost" onClick={() => bulk("ignore")} disabled={selected.size === 0}>Ignore selected</button>
</>
)}
</div>
) : null}
{albums.length > 0 ? (
<div className="album-grid">
{shown.map((a) => (
<button key={a.id} className="album-card" onClick={() => show(a)} aria-label={`Open ${a.album}`}>
<button
key={a.id}
className={`album-card${selectMode && selected.has(a.id) ? " selected" : ""}`}
onClick={() => (selectMode ? toggleSelect(a.id) : show(a))}
aria-label={selectMode ? `Select ${a.album}` : `Open ${a.album}`}
aria-pressed={selectMode ? selected.has(a.id) : undefined}
>
{selectMode ? <span className="sel-check" aria-hidden>{selected.has(a.id) ? "✓" : ""}</span> : null}
<CoverArt rgMbid={a.rgMbid} alt={a.album} size={250} />
<div className="ac-title">{a.album}</div>
<div className="ac-artist">{a.artist}</div>