From 7d5bbd117785dae6f8162a859c93421df181e464 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 14:06:37 -0400 Subject: [PATCH] =?UTF-8?q?feat(admin):=20multi-select=20episode=20file=20?= =?UTF-8?q?deletion=20=E2=80=94=20checkboxes=20on=20downloaded=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/tests/podcast_test.go | 40 ++ internal/handlers/podcast.go | 52 +++ internal/router/podcast.go | 1 + templates/podcast_detail.templ | 51 ++- templates/podcast_detail_templ.go | 600 +++++++++++++++++------------- web/src/podcasts.ts | 71 +++- 6 files changed, 543 insertions(+), 272 deletions(-) diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 42a472fd..6e4dc700 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -1399,6 +1399,46 @@ func TestAdminDeleteEpisodeFiles(t *testing.T) { assert.Equal(t, "pending", after2.DownloadState) assert.NoFileExists(t, pathFor(after2), "feed-level deletion clears the remaining file") + // --- bulk multi-select delete-files (§0 item 8 follow-up) --- + // Re-download both, then delete them in ONE request with a forged + // foreign id mixed in: only the matching-feed ids are applied. + for _, ep := range []database.MediaItems{ep1, ep2} { + require.NoError(t, setup.DB.SetPodcastEpisodePending(adminCtx, ep.ID)) + _, err = setup.DB.SetPodcastEpisodeDownloadQueued(adminCtx, ep.ID) + require.NoError(t, err) + } + worker.DrainNow(context.Background()) + require.Eventually(t, func() bool { + eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID) + if err != nil || len(eps) != 2 { + return false + } + for _, ep := range eps { + if ep.DownloadState != "downloaded" { + return false + } + } + return true + }, 30*time.Second, 250*time.Millisecond, "both re-downloaded for the bulk test") + + foreignID := uuid.New().String() + bulkForm := url.Values{} + bulkForm["episode-file-select"] = []string{uuid.UUID(ep1.ID.Bytes).String(), foreignID, uuid.UUID(ep2.ID.Bytes).String()} + code, rbody = podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", bulkForm) + require.Equal(t, http.StatusOK, code, string(rbody)) + assert.Contains(t, string(rbody), `"deleted":2`, "the forged foreign id is refused, the real two applied") + assert.NoFileExists(t, pathFor(ep1)) + assert.NoFileExists(t, pathFor(ep2)) + afterBulk, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID) + require.NoError(t, err) + for _, ep := range afterBulk { + assert.Equal(t, "pending", ep.DownloadState, "bulk deletion resets every selected row (%s)", ep.Title) + } + + // Non-admins cannot bulk-delete either. + code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", "") + assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot bulk-delete files") + // --- refresh must NOT re-materialize deleted files (invariant 2): // the poll upsert rewrites metadata but never touches download_state. code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "") diff --git a/internal/handlers/podcast.go b/internal/handlers/podcast.go index e8d31340..9d250d81 100644 --- a/internal/handlers/podcast.go +++ b/internal/handlers/podcast.go @@ -120,6 +120,58 @@ func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"}) } +// AdminDeleteEpisodeFilesBulk POST /api/podcasts/feeds/:id/episodes/delete-files — +// admin-only (§0 item 8): multi-select file deletion. Body carries the +// CHECKED boxes' values (form-encoded, repeated episode-file-select +// keys — hx-include gathers them; parsed directly, never bound: the +// repeated-key wire is another echo-bind gotcha). Each id is validated +// to belong to THIS feed; rows reset to pending, files removed. +func (h *PodcastHandler) AdminDeleteEpisodeFilesBulk(c *echo.Context) error { + user := MustGetAuthenticatedUser(c) + if user.Role != "admin" { + return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"}) + } + feedID, err := parseUUID(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) + } + if err := c.Request().ParseForm(); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + rawIDs := c.Request().PostForm["episode-file-select"] + if len(rawIDs) == 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "no episodes selected"}) + } + actx, err := h.worker.AdminCtx(c.Request().Context()) + if err != nil { + return podcastErrorResponse(c, err) + } + + deleted := 0 + for _, raw := range rawIDs { + episodeID, perr := parseUUID(raw) + if perr != nil { + continue + } + // The checkbox list only renders this feed's rows, but the wire + // is user-controlled: a forged id for a foreign feed's episode + // is refused (feed-match guard), not silently applied. + ep, gerr := h.db.GetPodcastEpisode(actx, episodeID) + if gerr != nil || ep.SourceFeedID != feedID { + continue + } + if derr := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); derr == nil { + deleted++ + } + } + log.Printf("[PODCAST] admin: bulk-deleted files for %d/%d episode(s) of feed %s (user %s)", + deleted, len(rawIDs), feedID, user.Username) + if isHXRequest(c) { + return h.hxEpisodeList(c, feedID.String(), false, false) + } + return c.JSON(http.StatusOK, map[string]interface{}{"deleted": deleted}) +} + // flipSubscriptionToAll implements issue #3: Download All (button or // add-feed checkbox) sets the CALLER's subscription policy to 'all' — // per-user, never global (files are shared D6, policies are not). The diff --git a/internal/router/podcast.go b/internal/router/podcast.go index b48a41c8..286319ed 100644 --- a/internal/router/podcast.go +++ b/internal/router/podcast.go @@ -78,5 +78,6 @@ func registerPodcastRoutes(cfg *Config) { // Admin maintenance (§0 item 8): file deletion frees the disk and // resets rows to pending. Handler checks admin role. feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles)) + feeds.POST("/episodes/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFilesBulk)) feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile)) } diff --git a/templates/podcast_detail.templ b/templates/podcast_detail.templ index 139bdb73..f97a0c6c 100644 --- a/templates/podcast_detail.templ +++ b/templates/podcast_detail.templ @@ -240,6 +240,31 @@ templ PodcastDetailPage(user User, data PodcastDetailData) { Hide played/downloaded + + + if data.IsAdmin && data.Feed.DownloadedN > 0 { +
+ + +
+ } + if isAdmin { + if ep.Downloaded { + + } else { + + } + } { podcastInt(ep.Position) } diff --git a/templates/podcast_detail_templ.go b/templates/podcast_detail_templ.go index 69562ca1..74b7fb4e 100644 --- a/templates/podcast_detail_templ.go +++ b/templates/podcast_detail_templ.go @@ -566,7 +566,30 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
No episodes match the filter.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
No episodes match the filter.") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.IsAdmin && data.Feed.DownloadedN > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -581,7 +604,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -589,7 +612,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -616,50 +639,50 @@ func PodcastEpisodeList(data PodcastDetailData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var25 := templ.GetChildren(ctx) - if templ_7745c5c3_Var25 == nil { - templ_7745c5c3_Var25 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "

Episodes

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-trigger=\"podcast:changed from:body\" hx-swap=\"outerHTML\">

Episodes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(data.Episodes) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
No episodes yet — hit Refresh to check the feed.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
No episodes yet — hit Refresh to check the feed.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, ep := range data.Episodes { - templ_7745c5c3_Err = PodcastEpisodeRowView(ep).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = PodcastEpisodeRowView(ep, data.IsAdmin).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -686,35 +709,35 @@ func PodcastEpisodeListPolling(data PodcastDetailData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var27 := templ.GetChildren(ctx) - if templ_7745c5c3_Var27 == nil { - templ_7745c5c3_Var27 = templ.NopComponent + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "

Episodes

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" hx-trigger=\"podcast:changed from:body, every 2s\" hx-swap=\"outerHTML\">

Episodes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, ep := range data.Episodes { - templ_7745c5c3_Err = PodcastEpisodeRowView(ep).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = PodcastEpisodeRowView(ep, data.IsAdmin).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -722,7 +745,7 @@ func PodcastEpisodeListPolling(data PodcastDetailData) templ.Component { }) } -func PodcastEpisodeRowView(ep PodcastEpisodeRow) templ.Component { +func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -738,293 +761,336 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var29 := templ.GetChildren(ctx) - if templ_7745c5c3_Var29 == nil { - templ_7745c5c3_Var29 = templ.NopComponent + templ_7745c5c3_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" data-search-text=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(ep.Position)) + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(ep.Title + " " + ep.Description)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 315, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 337, Col: 69} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var34 templ.SafeURL - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 319, Col: 24} + if isAdmin { + if ep.Downloaded { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" class=\"font-medium text-sm hover:underline line-clamp-1\" style=\"color: var(--text-primary)\">") + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(ep.Position)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 360, Col: 28} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 319, Col: 129} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") + var templ_7745c5c3_Var37 templ.SafeURL + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 364, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" class=\"font-medium text-sm hover:underline line-clamp-1\" style=\"color: var(--text-primary)\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 364, Col: 129} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if ep.DownloadState == "failed" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 321, Col: 165} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 323, Col: 134} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if ep.DownloadState == "pending" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 325, Col: 133} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if ep.DownloadError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(ep.DownloadError) + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 329, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 366, Col: 165} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 368, Col: 134} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if ep.DownloadState == "pending" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 370, Col: 133} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ep.DownloadError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(ep.DownloadError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 374, Col: 97} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if ep.DownloadState == "downloading" && ep.ProgressKnown { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if ep.ProgressTotal > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal)) + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 338, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 383, Col: 93} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived)) + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 344, Col: 122} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 389, Col: 122} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Date) + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Date) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 349, Col: 13} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 394, Col: 13} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if ep.Duration != "" { - var templ_7745c5c3_Var44 string - templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Duration) + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Duration) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 351, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 396, Col: 27} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if ep.Size != "" { - var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Size) + var templ_7745c5c3_Var48 string + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Size) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 354, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 399, Col: 23} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if ep.Downloaded { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\" class=\"btn btn-secondary shrink-0\" title=\"Play — chains into the next downloaded episode (append ?single=1 to the URL to play just this one)\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1032,12 +1098,12 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "Play") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "Play") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1045,60 +1111,60 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var47 = []any{"btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) + var templ_7745c5c3_Var50 = []any{"btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var50...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1180,55 +1246,55 @@ func PodcastAutoDownloadSelect(feed PodcastFeedCard) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var52 := templ.GetChildren(ctx) - if templ_7745c5c3_Var52 == nil { - templ_7745c5c3_Var52 = templ.NopComponent + templ_7745c5c3_Var55 := templ.GetChildren(ctx) + if templ_7745c5c3_Var55 == nil { + templ_7745c5c3_Var55 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, ">Newest") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1256,65 +1322,65 @@ func PodcastOrderSelectOOB(feedID string, order string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var54 := templ.GetChildren(ctx) - if templ_7745c5c3_Var54 == nil { - templ_7745c5c3_Var54 = templ.NopComponent + templ_7745c5c3_Var57 := templ.GetChildren(ctx) + if templ_7745c5c3_Var57 == nil { + templ_7745c5c3_Var57 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, ">Newest first (pub date)") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1340,55 +1406,55 @@ func PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var56 := templ.GetChildren(ctx) - if templ_7745c5c3_Var56 == nil { - templ_7745c5c3_Var56 = templ.NopComponent + templ_7745c5c3_Var59 := templ.GetChildren(ctx) + if templ_7745c5c3_Var59 == nil { + templ_7745c5c3_Var59 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, ">Newest") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1416,51 +1482,51 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var58 := templ.GetChildren(ctx) - if templ_7745c5c3_Var58 == nil { - templ_7745c5c3_Var58 = templ.NopComponent + templ_7745c5c3_Var61 := templ.GetChildren(ctx) + if templ_7745c5c3_Var61 == nil { + templ_7745c5c3_Var61 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "

Unsubscribe from ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "

Unsubscribe from ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var59 string - templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle()) + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 484, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 529, Col: 112} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "?

You are the only subscriber.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "\" hx-swap=\"none\" @click=\"unsubOpen = false\">Remove feed — delete episodes and files

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/podcasts.ts b/web/src/podcasts.ts index 66305f58..307b2f73 100644 --- a/web/src/podcasts.ts +++ b/web/src/podcasts.ts @@ -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("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("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("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(); + } + }); }, }));