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
@@ -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>
|
||||
|
||||
+333
-267
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user