feat(podcasts): web UI — /podcasts page, feed detail, download lifecycle (task 1.9)

Rendering ownership holds throughout: every piece of server data is
templ-rendered; htmx swaps partials; the only TS is the Add-Feed modal
toggle + error surfacing (pure client-local state, bundled via
web/src/podcasts.ts with automatic bundle-hash stamping).

Sidebar: /podcasts nav entry (D13, between Series and Collections),
gated by a ShowPodcastsNav flag resolved centrally in
getTemplateUserWithTheme (one indexed COUNT — same class of
per-request lookup as the live-user guard). New 'podcast' icon.

/podcasts: header + Add Feed modal (htmx post → HX-Redirect to the
feed page; inline error surfacing on failure), subscription-scoped
search + facet bar (host/tag/language — server-side GET filters, no
JS), grid cards with download counts / new-episodes / complete /
explicit badges. Empty states for no-library (admin creates it first)
and no-subscriptions.

Feed detail: hero (artwork, host/publisher, tags, fetch state), action
row — Download All, Refresh, ORDER SELECTOR (D5 override), serial
one-click oldest-first suggestion (never silent), auto-download policy
select (D6), Unsubscribe (files retained), admin Remove Feed — gap
banner (D8 count), and the EPISODE LIST with the full lifecycle inline
(D8/D19): pending greyed + Download button (every non-downloaded
episode has one), queued/downloading pulse, failed RED + reason text +
Retry (same endpoint). The list is a self-refreshing partial: re-fetch
on the podcast:changed HX-Trigger and polls every 4s ONLY while the
server says downloads are active (it renders the polling variant).

API endpoints grew HX awareness: 204 + HX-Trigger podcast:changed (or
HX-Redirect for add/unsubscribe/remove) — the sanctioned Go-handler
response channels; JSON callers keep JSON. AddFeed result exposes the
fetch-error text for the redirect.

Dates render in the requesting user's timezone (D17) via
utils.FormatTimeInTimezone; episodes play through the existing
/readers/{id} audio reader (episodes ARE audiobook items — no new
player code day one). CSP tripwire (templates/csp_source_test.go)
passes: no inline scripts, no on*= handlers, no hx-on/js: vals.
This commit is contained in:
John O'Keefe committed 2026-10-29 16:17:55 -04:00
1 parent 54d7d6e7a1
commit 2235b00c59
19 files changed
+3540 -327

No files matched your search

+1
View File
@@ -24,6 +24,7 @@ import "./linking";
import "./login";
import "./password_validation";
import "./processing_issues";
import "./podcasts";
import "./profile";
import "./profile-modal";
import "./register";
+61
View File
@@ -0,0 +1,61 @@
// Podcasts UI islands (plan task 1.9): the Add-Feed modal toggle and
// the htmx glue the modal needs (error surfacing). All episode-list /
// download behavior is htmx-native (hx-post + podcast:changed trigger +
// server-driven polling) — this module only owns pure client-local
// presentation state, per the rendering-ownership rule.
import { Alpine } from "./alpine";
Alpine.data("podcastsPage", () => ({
showAddFeed(): void {
const modal = document.getElementById("podcast-add-feed-modal");
if (modal) modal.classList.remove("hidden");
},
hideAddFeed(): void {
const modal = document.getElementById("podcast-add-feed-modal");
if (modal) modal.classList.add("hidden");
const err = document.getElementById("podcast-add-feed-error");
if (err) err.classList.add("hidden");
},
}));
// The detail page's actions are pure htmx (hx-post/hx-put/hx-delete +
// HX-Trigger-driven list refresh); Alpine only hosts the x-data the
// body declares so CSP-bound Alpine stays happy.
Alpine.data("podcastDetail", () => ({}));
// Add-feed form: surface the server's error text inline (the JSON error
// body arrives on non-2xx; htmx swaps nothing). Success redirects via
// HX-Redirect — nothing to do here.
export function initPodcastAddFeedForm(): void {
const form = document.querySelector<HTMLFormElement>(
'#podcast-add-feed-modal form[hx-post="/api/podcasts/feeds"], #podcast-add-feed-modal form',
);
if (!form) return;
form.addEventListener("htmx:afterRequest", (evt) => {
const custom = evt as CustomEvent<{
successful: boolean;
xhr: XMLHttpRequest;
}>;
if (custom.detail.successful) return;
let message = "Could not add the feed — check the URL and try again.";
try {
const parsed = JSON.parse(custom.detail.xhr.responseText) as {
error?: string;
};
if (parsed.error) message = parsed.error;
} catch {
// keep the default text
}
const err = document.getElementById("podcast-add-feed-error");
if (err) {
err.textContent = message;
err.classList.remove("hidden");
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initPodcastAddFeedForm);
} else {
initPodcastAddFeedForm();
}