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:
1 parent
5c545534c2
commit
f0f1eb6e8f
3 files changed
+446
-310
No files matched your search
@@ -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>
|
||||
|
||||
+362
-308
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,46 @@ Alpine.data("podcastsPage", () => ({
|
||||
// body declares (modal visibility) so CSP-bound Alpine stays happy.
|
||||
Alpine.data("podcastDetail", () => ({
|
||||
unsubOpen: false,
|
||||
filterListenerInstalled: 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
|
||||
// rendered by templ); no query changes, no server round trip.
|
||||
// finishedThreshold 99.5% matches the server's finished rule.
|
||||
applyEpisodeFilter(): void {
|
||||
const input = (this as any).$refs.episodeFilterInput as HTMLInputElement | undefined;
|
||||
const box = (this as any).$refs.hideFinished as HTMLInputElement | undefined;
|
||||
const emptyNote = (this as any).$refs.filterEmptyNote as HTMLElement | undefined;
|
||||
const q = (input?.value ?? "").trim().toLowerCase();
|
||||
const hideFinished = box?.checked ?? false;
|
||||
|
||||
let visible = 0;
|
||||
let total = 0;
|
||||
document.querySelectorAll<HTMLElement>("#episode-list [data-episode-row]").forEach((row) => {
|
||||
total++;
|
||||
const downloaded = row.dataset.downloaded === "true";
|
||||
const pct = parseFloat(row.dataset.listenPct ?? "0");
|
||||
const text = row.dataset.searchText ?? "";
|
||||
const hide =
|
||||
(hideFinished && (downloaded || pct >= 99.5)) ||
|
||||
(q !== "" && !text.toLowerCase().includes(q));
|
||||
row.classList.toggle("hidden", hide);
|
||||
if (!hide) visible++;
|
||||
});
|
||||
if (emptyNote) emptyNote.classList.toggle("hidden", visible > 0 || total === 0);
|
||||
},
|
||||
|
||||
// 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.
|
||||
initPodcastFilterPersistence(): void {
|
||||
if (this.filterListenerInstalled) return;
|
||||
this.filterListenerInstalled = true;
|
||||
document.addEventListener("htmx:afterSwap", () => {
|
||||
this.applyEpisodeFilter();
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Add-feed form: surface the server's error text inline (the JSON error
|
||||
|
||||
Reference in new issue
Block a user