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:
1 parent
54d7d6e7a1
commit
2235b00c59
19 files changed
+3540
-327
No files matched your search
@@ -24,6 +24,7 @@ import "./linking";
|
||||
import "./login";
|
||||
import "./password_validation";
|
||||
import "./processing_issues";
|
||||
import "./podcasts";
|
||||
import "./profile";
|
||||
import "./profile-modal";
|
||||
import "./register";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user