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

+48 -3
View File
@@ -240,6 +240,31 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
Hide played/downloaded
</label>
<span class="text-xs hidden" x-ref="filterEmptyNote" style="color: var(--text-secondary)">No episodes match the filter.</span>
<!-- Admin bulk file deletion (§0 item 8 follow-up): the
transport for the per-row checkboxes inside the episode
list. Lives OUTSIDE #episode-list so the button survives
the list's htmx swaps; hx-include gathers the CHECKED
boxes into the post body. State (count/ids) lives in the
podcastDetail component — CSP-safe method bindings only. -->
if data.IsAdmin && data.Feed.DownloadedN > 0 {
<div class="ml-auto flex items-center gap-2">
<span class="text-xs tabular-nums" style="color: var(--text-secondary)" x-text="bulkSelectionLabel()"></span>
<button
id="episode-bulk-delete"
type="button"
class="btn btn-secondary text-xs"
:class="bulkSelectionClass()"
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/delete-files" }
hx-include="input[name='episode-file-select']:checked"
hx-swap="none"
hx-confirm="Delete the selected episode files? Rows reset to pending (a feed refresh will not re-materialize them); episodes re-download on demand."
style="color: var(--status-warning, #d97706);"
>
Delete selected files
</button>
</div>
}
</div>
<!-- Episode list (self-refreshing htmx partial). Pick the
@@ -277,7 +302,7 @@ templ PodcastEpisodeList(data PodcastDetailData) {
} else {
<div class="flex flex-col gap-2">
for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep)
@PodcastEpisodeRowView(ep, data.IsAdmin)
}
</div>
}
@@ -297,13 +322,13 @@ templ PodcastEpisodeListPolling(data PodcastDetailData) {
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
<div class="flex flex-col gap-2">
for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep)
@PodcastEpisodeRowView(ep, data.IsAdmin)
}
</div>
</div>
}
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
templ PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) {
<div
class="card p-3 flex items-center gap-3"
data-episode-row="true"
@@ -311,6 +336,26 @@ templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
data-listen-pct={ podcastPct(ep.ListenPct) }
data-search-text={ strings.ToLower(ep.Title + " " + ep.Description) }
>
<!-- Admin bulk-select (§0 item 8 follow-up): multi-select
downloaded episodes for file deletion. Rendered ONLY for
admins + DOWNLOADED rows — a checkbox on a file-less episode
would be an invitation to confusion. State survives the
list's htmx swaps via the podcastDetail component
(reapplied on afterSwap). -->
if isAdmin {
if ep.Downloaded {
<input
type="checkbox"
name="episode-file-select"
value={ ep.ID }
class="cursor-pointer shrink-0"
aria-label={ "Select " + ep.Title + " for file deletion" }
@change="toggleBulkSelection($event.target)"
/>
} else {
<span class="w-4 shrink-0"></span>
}
}
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
{ podcastInt(ep.Position) }
</span>