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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user