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
+473
-337
No files matched your search
@@ -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