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:
1 parent
0741e98ea5
commit
7d5bbd1177
6 files changed
+543
-272
No files matched your search
+69
-2
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user