feat(podcasts): hide played/downloaded toggle + episode text filter

Pocket Casts steal pulled forward from Phase 4 (§0 item 6): the episode
list gains a client-side 'Filter episodes…' box and a 'Hide
played/downloaded' toggle. Pure presentation — rows carry templ-rendered
data-* attributes (downloaded, listen-pct, search text); the filter
toggles DOM visibility with the 99.5% finishedThreshold. The toolbar
lives OUTSIDE #episode-list so its state survives the list's polling
swaps, and an htmx:afterSwap listener re-applies the active filter to
freshly swapped rows. No query change, no server round trip, no new JS
rendering surface (Alpine component method, CSP-safe).
This commit is contained in:
John O'Keefe committed 2026-10-30 10:28:31 -04:00
1 parent 5c545534c2
commit f0f1eb6e8f
3 files changed
+473 -337

No files matched your search

+44 -2
View File
@@ -9,6 +9,21 @@ package templates
// the polling attributes render only then).
import "fmt"
import "strings"
// boolAttr renders a boolean data attribute value.
func boolAttr(b bool) string {
if b {
return "true"
}
return "false"
}
// podcastPct renders a listen percentage for the row's data attribute
// (one decimal; the client-side hide-played filter reads it).
func podcastPct(p float64) string {
return fmt.Sprintf("%.1f", p)
}
templ PodcastDetailPage(user User, data PodcastDetailData) {
<!DOCTYPE html>
@@ -20,7 +35,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="podcastDetail" class={ "theme-" + user.Theme }>
<body x-data="podcastDetail" x-init="initPodcastFilterPersistence()" class={ "theme-" + user.Theme }>
@Header(user, "/podcasts")
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<a href="/podcasts" class="inline-flex items-center gap-1 text-sm mb-4 hover:underline" style="color: var(--text-secondary)">
@@ -206,6 +221,27 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
</div>
}
<!-- Episode-list filters (§0 item 6): pure client-local
presentation state (Alpine; survives the list's htmx
swaps because this toolbar lives OUTSIDE #episode-list).
Hidden rows re-apply after every swap via the component's
htmx:afterSwap listener. -->
<div class="flex flex-wrap items-center gap-2 mb-2">
<input
type="search"
class="input w-auto py-1.5 text-sm flex-1 min-w-[150px]"
placeholder="Filter episodes…"
aria-label="Filter episodes by title or description"
x-ref="episodeFilterInput"
@input="applyEpisodeFilter($event)"
/>
<label class="flex items-center gap-1.5 text-sm cursor-pointer select-none" style="color: var(--text-secondary)">
<input type="checkbox" class="cursor-pointer" @change="applyEpisodeFilter($event)" x-ref="hideFinished"/>
Hide played/downloaded
</label>
<span class="text-xs hidden" x-ref="filterEmptyNote" style="color: var(--text-secondary)">No episodes match the filter.</span>
</div>
<!-- Episode list (self-refreshing htmx partial). Pick the
polling variant at page-render time when downloads are
active — otherwise a fresh page load mid-download
@@ -268,7 +304,13 @@ templ PodcastEpisodeListPolling(data PodcastDetailData) {
}
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
<div class="card p-3 flex items-center gap-3">
<div
class="card p-3 flex items-center gap-3"
data-episode-row="true"
data-downloaded={ boolAttr(ep.Downloaded) }
data-listen-pct={ podcastPct(ep.ListenPct) }
data-search-text={ strings.ToLower(ep.Title + " " + ep.Description) }
>
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
{ podcastInt(ep.Position) }
</span>
File diff suppressed because it is too large. Load diff