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
@@ -9,6 +9,21 @@ package templates
|
|||||||
// the polling attributes render only then).
|
// the polling attributes render only then).
|
||||||
|
|
||||||
import "fmt"
|
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) {
|
templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -20,7 +35,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="podcastDetail" class={ "theme-" + user.Theme }>
|
<body x-data="podcastDetail" x-init="initPodcastFilterPersistence()" class={ "theme-" + user.Theme }>
|
||||||
@Header(user, "/podcasts")
|
@Header(user, "/podcasts")
|
||||||
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
<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)">
|
<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>
|
</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
|
<!-- Episode list (self-refreshing htmx partial). Pick the
|
||||||
polling variant at page-render time when downloads are
|
polling variant at page-render time when downloads are
|
||||||
active — otherwise a fresh page load mid-download
|
active — otherwise a fresh page load mid-download
|
||||||
@@ -268,7 +304,13 @@ templ PodcastEpisodeListPolling(data PodcastDetailData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
|
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)">
|
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
|
||||||
{ podcastInt(ep.Position) }
|
{ podcastInt(ep.Position) }
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+389
-335
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.
|
// body declares (modal visibility) so CSP-bound Alpine stays happy.
|
||||||
Alpine.data("podcastDetail", () => ({
|
Alpine.data("podcastDetail", () => ({
|
||||||
unsubOpen: false,
|
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
|
// Add-feed form: surface the server's error text inline (the JSON error
|
||||||
|
|||||||
Reference in new issue
Block a user