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

+40
View File
@@ -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