Files
bookhoard/templates/podcasts.templ
T
John O'Keefe 2235b00c59 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.
2026-10-29 16:17:55 -04:00

231 lines
8.5 KiB
Templ

package templates
import "net/url"
// PodcastFacetLink links a facet value back into the podcasts page's
// server-side filter (the /bookshelf FacetLink targets the wrong
// surface for podcasts).
templ PodcastFacetLink(facet string, value string) {
<a href={ "/podcasts?" + facet + "=" + url.QueryEscape(value) } class="badge hover:opacity-80 transition-opacity" style="background-color: var(--surface-secondary); color: var(--text-secondary); text-decoration: none;">{ value }</a>
}
// The /podcasts page (plan task 1.9, D13/D14/D18): Add Feed wizard,
// subscription-scoped search, facet bar, All-Feeds grid. Server-side
// filtering (GET form, like the series page — no JS needed); the grid
// is templ-rendered server data per the rendering-ownership rule.
templ PodcastsPage(user User, data PodcastsPageData, currentPath string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Podcasts - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="podcastsPage" class={ "theme-" + user.Theme }>
@Header(user, currentPath)
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("podcast", "h-5 w-5")
</span>
<div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Podcasts</h1>
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Your subscriptions</p>
</div>
</div>
if data.HasLibrary {
<button
class="btn btn-primary"
@click="showAddFeed()"
>
@Icon("plus", "h-4 w-4")
<span>Add Feed</span>
</button>
}
</div>
if data.ErrorMsg != "" {
<div class="mb-4 card p-4" style="border-color: var(--status-danger); color: var(--status-danger);">
{ data.ErrorMsg }
</div>
}
if !data.HasLibrary {
<div class="card p-8 text-center">
<p class="mb-2 font-semibold" style="color: var(--text-primary)">No podcasts library yet</p>
<p class="text-sm" style="color: var(--text-secondary)">An admin creates a Podcasts library (with a download folder) first — then feeds can be added here.</p>
</div>
} else {
@PodcastsFilterBar(data)
if len(data.Feeds) == 0 {
<div class="card p-8 text-center">
<p class="mb-2 font-semibold" style="color: var(--text-primary)">No subscriptions</p>
<p class="text-sm" style="color: var(--text-secondary)">Add an RSS feed to start listening — episodes land here and download on demand.</p>
</div>
} else {
@PodcastsGrid(data.Feeds)
}
}
</main>
@PodcastAddFeedModal(data.Libraries)
</body>
</html>
}
templ PodcastsFilterBar(data PodcastsPageData) {
<form method="get" action="/podcasts" class="mb-6 flex flex-wrap items-center gap-3">
<div class="relative flex-1 min-w-48">
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary)">
@Icon("search", "h-4 w-4")
</span>
<input
type="text"
name="q"
value={ data.Query }
placeholder="Search feeds, hosts, episodes…"
class="input w-full pl-9"
aria-label="Search your subscriptions"
/>
</div>
<button type="submit" class="btn btn-secondary" aria-label="Search">
@Icon("search", "h-4 w-4")
<span>Search</span>
</button>
</form>
if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 {
<div class="mb-6 flex flex-wrap items-center gap-2 text-sm">
<span class="font-semibold mr-1" style="color: var(--text-secondary)">Filter:</span>
for _, h := range data.Facets["hosts"] {
@PodcastFacetLink("host", h)
}
for _, t := range data.Facets["tags"] {
@PodcastFacetLink("tag", t)
}
for _, l := range data.Facets["language"] {
@PodcastFacetLink("language", l)
}
if data.Query != "" {
<a href="/podcasts" class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">clear</a>
}
</div>
}
}
templ PodcastsGrid(feeds []PodcastFeedCard) {
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
for _, feed := range feeds {
@PodcastFeedCardView(feed)
}
</div>
}
templ PodcastFeedCardView(feed PodcastFeedCard) {
<a
href={ "/podcasts/" + feed.ID }
class="card p-4 flex flex-col gap-3 hover:-translate-y-0.5 transition-transform"
>
<div class="flex items-start gap-3">
<div class="h-16 w-16 rounded-lg shrink-0 overflow-hidden grid place-items-center" style="background-color: var(--surface-secondary); color: var(--text-secondary);">
if feed.ArtworkPath != "" {
<img
src={ "/uploads/library-" + feed.LibraryID + "/" + feed.ArtworkPath }
alt={ "Cover for " + feed.DisplayTitle() }
class="h-full w-full object-cover"
data-image-fallback
/>
} else {
@Icon("podcast", "h-7 w-7")
}
</div>
<div class="min-w-0">
<p class="font-semibold leading-snug line-clamp-2" style="color: var(--text-primary)">{ feed.DisplayTitle() }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
{ feed.TypeLabel() }
if feed.Host != "" {
{ " · " + feed.Host }
}
</p>
</div>
</div>
if feed.Description != "" {
<p class="text-xs line-clamp-2" style="color: var(--text-secondary)">{ feed.Description }</p>
}
<div class="mt-auto flex flex-wrap items-center gap-1.5 text-xs">
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">
{ podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded" }
</span>
if feed.NewEpisodes {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">new episodes</span>
}
if feed.IsComplete {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">complete</span>
}
if feed.Explicit {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">explicit</span>
}
</div>
</a>
}
templ PodcastAddFeedModal(libraries []LibraryData) {
<div
id="podcast-add-feed-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
style="background-color: var(--surface-overlay);"
>
<div class="card w-full max-w-lg" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);">
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Add Podcast Feed</h2>
<button class="icon-btn" aria-label="Close" @click="hideAddFeed()">
@Icon("close", "h-5 w-5")
</button>
</div>
<form
hx-post="/api/podcasts/feeds"
hx-swap="none"
class="p-6 flex flex-col gap-4"
>
<div>
<label class="text-sm font-medium mb-1 block" for="podcast-feed-url" style="color: var(--text-secondary)">RSS feed URL</label>
<input
type="url"
id="podcast-feed-url"
name="url"
placeholder="https://example.com/feed.xml"
class="input w-full"
required
/>
</div>
if len(libraries) > 1 {
<div>
<label class="text-sm font-medium mb-1 block" for="podcast-feed-library" style="color: var(--text-secondary)">Library</label>
<select id="podcast-feed-library" name="library_id" class="input w-full">
for _, lib := range libraries {
<option value={ lib.ID }>{ lib.Name }</option>
}
</select>
</div>
} else if len(libraries) == 1 {
<input type="hidden" name="library_id" value={ libraries[0].ID }/>
}
<label class="flex items-center gap-2 text-sm" style="color: var(--text-secondary)">
<input type="checkbox" name="download_all" value="true" class="cursor-pointer"/>
Download all episodes now
</label>
<div id="podcast-add-feed-error" class="text-sm hidden" style="color: var(--status-danger)"></div>
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="hideAddFeed()">Cancel</button>
<button type="submit" class="btn btn-primary">
@Icon("plus", "h-4 w-4")
<span>Add &amp; Subscribe</span>
</button>
</div>
</form>
</div>
</div>
}