feat(admin): multi-select episode file deletion — checkboxes on downloaded rows

The plan's per-EPISODE bulk select for the admin view (§0 item 8
follow-up — trimmed in the first admin-view commit; user called it
back). Admins now tick checkboxes on DOWNLOADED episode rows and delete
their files in one request:

- Checkbox per row, rendered ONLY for admins + downloaded episodes
  (nothing to delete otherwise), via the existing podcastDetail Alpine
  component: selection ids/count survive the list's htmx swaps
  (reapplied on afterSwap — same pattern as the text filter) and clear
  after a successful bulk delete.
- Toolbar segment outside #episode-list (survives swaps): 'N selected'
  + 'Delete selected files' (hx-include gathers the CHECKED boxes);
  disabled-looking at zero selection; hx-confirm states the
  re-materialization contract. CSP-safe method bindings only
  (bulkSelectionLabel/Class) — no ternaries in Alpine expressions.
- POST /feeds/:id/episodes/delete-files (admin): repeated
  episode-file-select keys parsed via ParseForm directly (never bound —
  repeated-key wire is another echo-bind gotcha); each id validated to
  belong to THIS feed, so a forged foreign id is refused while the real
  selections apply; rows reset to pending, files removed.
- Tests: bulk of two real ids + one forged id → deleted:2, files gone,
  rows pending; non-admin 403; all prior single/feed-level pins hold.
This commit is contained in:
John O'Keefe committed 2026-10-30 14:06:54 -04:00
1 parent 0741e98ea5
commit 7d5bbd1177
6 files changed
+543 -272

No files matched your search

+69 -2
View File
@@ -24,6 +24,63 @@ Alpine.data("podcastDetail", () => ({
unsubOpen: false,
filterListenerInstalled: false,
// Admin bulk file selection (§0 item 8 follow-up): ids of CHECKED
// episode rows. Survives the list's htmx swaps (rows re-render fresh
// and unchecked → reapplied on afterSwap); cleared after a successful
// bulk delete. Plain array — deliberately NOT reactive state beyond
// bulkCount, which the toolbar binds.
bulkIds: [] as string[],
bulkCount: 0,
bulkSelectionLabel(): string {
return this.bulkCount === 0 ? "" : this.bulkCount + " selected";
},
bulkSelectionClass(): string {
return this.bulkCount === 0 ? "opacity-40 pointer-events-none" : "";
},
toggleBulkSelection(target: HTMLInputElement): void {
const id = target.value;
if (!id) return;
if (target.checked) {
if (!this.bulkIds.includes(id)) this.bulkIds.push(id);
} else {
this.bulkIds = this.bulkIds.filter((v) => v !== id);
}
this.bulkCount = this.bulkIds.length;
},
// After each swap the list re-renders unchecked; restore ticks for
// ids still selected. Ids whose rows vanished (deleted / state
// flipped) silently drop out.
reapplyBulkSelection(): void {
if (this.bulkIds.length === 0) return;
document
.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']")
.forEach((box) => {
box.checked = this.bulkIds.includes(box.value);
});
// Drop ids whose checkboxes no longer exist.
const present = new Set(
Array.from(
document.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']"),
).map((b) => b.value),
);
this.bulkIds = this.bulkIds.filter((id) => present.has(id));
this.bulkCount = this.bulkIds.length;
},
clearBulkSelection(): void {
this.bulkIds = [];
this.bulkCount = 0;
document
.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']:checked")
.forEach((box) => {
box.checked = false;
});
},
// Episode-list filters (§0 item 6, 2026-10-30): hide played/
// downloaded rows and/or substring-filter titles+descriptions. Pure
// presentation — the DOM IS the data (rows carry data-* attributes
@@ -53,14 +110,24 @@ Alpine.data("podcastDetail", () => ({
},
// Re-apply after every htmx swap of the list (polling + action
// responses swap #episode-list wholesale; fresh rows must honor the
// active filter). Installed once per page.
// responses swap #episode-list wholesale): fresh rows must honor the
// active filter AND restored bulk-selection ticks; a successful bulk
// delete clears the selection. Installed once per page.
initPodcastFilterPersistence(): void {
if (this.filterListenerInstalled) return;
this.filterListenerInstalled = true;
document.addEventListener("htmx:afterSwap", () => {
this.reapplyBulkSelection();
this.applyEpisodeFilter();
});
// Successful bulk delete → the list swaps fresh; drop the state.
document.addEventListener("htmx:afterRequest", (evt) => {
const custom = evt as CustomEvent<{ successful: boolean; elt: HTMLElement }>;
const elt = custom.detail.elt as HTMLElement | undefined;
if (custom.detail.successful && elt && elt.id === "episode-bulk-delete") {
this.clearBulkSelection();
}
});
},
}));