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

+310
View File
@@ -0,0 +1,310 @@
package templates
// Feed detail page (plan task 1.9, D5/D7/D8/D19): hero (artwork,
// metadata, admin cluster), action row (Download All, Refresh, order
// override, subscription policy, admin delete), and the episode list
// with the download lifecycle inline. The episode list is a self-
// refreshing htmx partial: it re-fetches on the podcast:changed event
// AND polls every 4s ONLY while downloads are active (server decides —
// the polling attributes render only then).
templ PodcastDetailPage(user User, data PodcastDetailData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ data.Feed.DisplayTitle() } - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="podcastDetail" class={ "theme-" + user.Theme }>
@Header(user, "/podcasts")
<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)">
@Icon("arrow-left", "h-4 w-4")
<span>Podcasts</span>
</a>
<!-- Hero -->
<div class="flex flex-col sm:flex-row gap-6 mb-8">
<div class="h-44 w-44 rounded-xl shrink-0 overflow-hidden grid place-items-center" style="background-color: var(--surface-secondary); color: var(--text-secondary);">
if data.Feed.ArtworkPath != "" {
<img
src={ "/uploads/library-" + data.Feed.LibraryID + "/" + data.Feed.ArtworkPath }
alt={ "Cover for " + data.Feed.DisplayTitle() }
class="h-full w-full object-cover"
data-image-fallback
/>
} else {
@Icon("podcast", "h-12 w-12")
}
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">{ data.Feed.TypeLabel() }</span>
if data.Feed.IsComplete {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">complete</span>
}
if data.Feed.Language != "" {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ data.Feed.Language }</span>
}
</div>
<h1 class="text-2xl font-bold tracking-tight mb-1" style="color: var(--text-primary)">{ data.Feed.DisplayTitle() }</h1>
if data.Feed.Host != "" || data.Feed.Publisher != "" {
<p class="text-sm mb-2" style="color: var(--text-secondary)">
if data.Feed.Host != "" {
{ "Host: " + data.Feed.Host }
}
if data.Feed.Publisher != "" && data.Feed.Publisher != data.Feed.Host {
{ " · Publisher: " + data.Feed.Publisher }
}
</p>
}
if data.Feed.Description != "" {
<p class="text-sm line-clamp-3 mb-2" style="color: var(--text-secondary)">{ data.Feed.Description }</p>
}
if len(data.Feed.Tags) > 0 {
<div class="flex flex-wrap gap-1.5 mb-2">
for _, t := range data.Feed.Tags {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ t }</span>
}
</div>
}
<p class="text-xs" style="color: var(--text-secondary)">
{ podcastInt(data.Feed.DownloadedN) + " of " + podcastInt(data.Feed.EpisodeTotal) + " episodes downloaded" }
if data.Feed.LastFetched != "" {
{ " · last checked " + data.Feed.LastFetched }
}
</p>
</div>
</div>
<!-- Actions -->
<div class="flex flex-wrap items-center gap-2 mb-6">
<button
class="btn btn-primary"
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/all/download" }
hx-swap="none"
title="Queue every episode that is not downloaded yet"
>
@Icon("save", "h-4 w-4")
<span>Download All</span>
</button>
<button
class="btn btn-secondary"
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/refresh" }
hx-swap="none"
title="Check the feed for new episodes now"
>
@Icon("refresh", "h-4 w-4")
<span>Refresh</span>
</button>
<!-- Order override (D5): per-user; refreshes the episode list -->
<label class="text-sm ml-2" for="podcast-order-select" style="color: var(--text-secondary)">Order</label>
<select
id="podcast-order-select"
name="order"
class="input w-auto py-1.5 cursor-pointer"
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
hx-swap="none"
>
<option value="native" selected={ data.Order == "native" }>Feed order</option>
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
<option value="newest" selected={ data.Order == "newest" }>Newest first</option>
</select>
<!-- Serial suggestion (D5): one-click, never silent -->
if data.IsSerial && data.Order == "native" {
<button
class="btn btn-secondary text-sm"
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
hx-vals='{"order": "oldest"}'
hx-swap="none"
title="Serial feed: publishers list newest first — listen oldest-first"
>
<span>Serial? Listen oldest-first</span>
</button>
}
<!-- Auto-download policy (D6) -->
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
<select
id="podcast-autodl-select"
name="auto_download"
class="input w-auto py-1.5 cursor-pointer"
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
hx-swap="none"
>
<option value="off" selected={ data.Feed.AutoDownload == "off" || data.Feed.AutoDownload == "" }>Off</option>
<option value="all" selected={ data.Feed.AutoDownload == "all" }>All episodes</option>
<option value="newest" selected={ data.Feed.AutoDownload == "newest" }>Newest</option>
</select>
if data.Feed.AutoDownload == "newest" {
<input
type="number"
id="podcast-autodl-count"
name="auto_download_count"
min="1"
max="100"
value={ podcastInt(data.Feed.AutoDownloadCount) }
class="input w-20 py-1.5"
aria-label="Newest N episodes"
/>
}
<!-- Unsubscribe: files retained (D6) -->
<button
class="btn btn-secondary ml-auto"
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
hx-confirm="Unsubscribe from this feed? Episodes and files are kept."
hx-swap="none"
>
<span>Unsubscribe</span>
</button>
if data.IsAdmin {
<button
class="btn btn-secondary"
style="color: var(--status-danger);"
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
hx-confirm="ADMIN: remove this feed for everyone? Episode rows AND downloaded files are deleted."
hx-swap="none"
>
@Icon("trash", "h-4 w-4")
<span>Remove Feed</span>
</button>
}
</div>
<!-- Gap banner (D8): middle episodes missing -->
if data.GapCount > 0 {
<div class="mb-4 card p-4 flex items-center gap-3" style="border-color: var(--status-warning, #d97706);">
@Icon("circle", "h-5 w-5")
<div>
<p class="font-semibold text-sm" style="color: var(--text-primary)">{ podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed" }</p>
<p class="text-xs" style="color: var(--text-secondary)">Playback skips them and keeps going — use Download All to fill the gaps.</p>
</div>
</div>
}
<!-- Episode list (self-refreshing htmx partial) -->
@PodcastEpisodeList(data)
</main>
</body>
</html>
}
// PodcastEpisodeList is the refresh unit: re-fetched on the
// podcast:changed event (download/refresh/order endpoints fire it) and
// polled every 4s only while the server says downloads are active.
templ PodcastEpisodeList(data PodcastDetailData) {
<div
id="episode-list"
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
hx-trigger="podcast:changed from:body"
hx-swap="outerHTML"
>
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
if len(data.Episodes) == 0 {
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">
No episodes yet — hit Refresh to check the feed.
</div>
} else {
<div class="flex flex-col gap-2">
for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep)
}
</div>
}
</div>
}
// PodcastEpisodeListPolling renders the poll-active variant of the list
// container (same content; the hx-trigger adds the 4s poll + load). The
// SSR episode route picks one of the two by download activity.
templ PodcastEpisodeListPolling(data PodcastDetailData) {
<div
id="episode-list"
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
hx-trigger="podcast:changed from:body, every 4s"
hx-swap="outerHTML"
>
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
<div class="flex flex-col gap-2">
for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep)
}
</div>
</div>
}
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
<div class="card p-3 flex items-center gap-3">
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
{ podcastInt(ep.Position) }
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<a href={ ep.PlayURL } class="font-medium text-sm hover:underline line-clamp-1" style="color: var(--text-primary)">{ ep.Title }</a>
if ep.DownloadState == "failed" {
<span class="badge shrink-0" style="background-color: color-mix(in srgb, var(--status-danger) 15%, transparent); color: var(--status-danger);">{ ep.StateBadge() }</span>
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
<span class="badge shrink-0 animate-pulse" style="background-color: var(--accent-muted); color: var(--accent);">{ ep.StateBadge() }</span>
} else if ep.DownloadState == "pending" {
<span class="badge shrink-0" style="background-color: var(--surface-secondary); color: var(--text-secondary);">{ ep.StateBadge() }</span>
}
</div>
if ep.DownloadError != "" {
<p class="text-xs mt-0.5 line-clamp-2" style="color: var(--status-danger)">{ ep.DownloadError }</p>
}
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
{ ep.Date }
if ep.Duration != "" {
{ " · " + ep.Duration }
}
if ep.Size != "" {
{ " · " + ep.Size }
}
</p>
</div>
if ep.Downloaded {
<a href={ ep.PlayURL } class="btn btn-secondary shrink-0" title="Play episode">
@Icon("book-open", "h-4 w-4")
<span>Play</span>
</a>
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
<span class="btn btn-secondary shrink-0 pointer-events-none opacity-60" aria-busy="true">
@Icon("refresh", "h-4 w-4")
</span>
} else {
<!-- pending AND failed: (re)download — same endpoint, idempotent -->
<button
class={ "btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed") }
hx-post={ "/api/podcasts/feeds/" + ep.FeedID + "/episodes/" + ep.ID + "/download" }
hx-swap="none"
title={ downloadButtonTitle(ep.DownloadState) }
>
@Icon("save", "h-4 w-4")
<span>{ downloadButtonLabel(ep.DownloadState) }</span>
</button>
}
</div>
}
// downloadButtonLabel: pending → Download; failed → Retry (the reason
// text above it explains what went wrong — D8/D19 inline failure UX).
func downloadButtonLabel(state string) string {
if state == "failed" {
return "Retry"
}
return "Download"
}
func downloadButtonTitle(state string) string {
if state == "failed" {
return "Retry the download"
}
return "Download this episode"
}