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
@@ -0,0 +1,230 @@
|
||||
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 & Subscribe</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
Reference in new issue
Block a user