1. Add-modal 'Download order' never revealed: the CSS sibling selector
(#podcast-download-all:checked ~ .podcast-add-order) can't fire —
the checkbox was nested inside a wrapper div, so the select row was
no SIBLING of the input. Restructured: input, label, and order row
are now siblings under one flex-wrap wrapper.
2. Order + auto-download dropdowns always displayed the LAST option
('Newest first'/'Newest') regardless of the saved value: templ's
selected={ boolExpr } compiles to selected="false" — and the mere
PRESENCE of the attribute selects the option, so every option was
selected and the browser honored the last. Sweep to the boolean-
attribute form selected?={...} (omits on false) across
podcast_detail (14), book_detail_modals (10: manga type/reading
direction selects + abridged/explicit checkboxes — same defect,
always-checked), collection_modal (2: hide-finished/show-up-next
always-checked). Values were SAVING correctly all along; this was
pure display. audio_reader/collections :selected/:checked are
Alpine bindings — unaffected.
3. Remove Feed (and every hx-confirm) still showed the browser dialog
for signed-in users: the global confirm modal rendered at the end
of SidebarSignIn — signed-IN pages have no SidebarSignIn, so no
#confirm-modal existed and confirmDialog() degraded to
window.confirm. Moved the modal into Header proper (rendered on
every page, after </aside>).
4. Facet bar at scale: values now carry feed COUNTS, sort by frequency
DESC then alphabetically, cap at 6 chips per group flat, and the
rest collapse into a native <details> '+N more' expander — hundreds
of hosts/tags degrade gracefully instead of a wall of chips.
Verified live on the dev instance (order select reflects the saved
override, override row persists, reveal fires, modal renders on
signed-in pages). Podcast + logout + media-pin suites green; vitest
167 green.
298 lines
12 KiB
Templ
298 lines
12 KiB
Templ
package templates
|
|
|
|
import "net/url"
|
|
|
|
// PodcastFacetChip is the facet-bar chip: value + feed count, linking
|
|
// back into the podcasts page's server-side filter (the /bookshelf
|
|
// FacetLink targets the wrong surface for podcasts).
|
|
templ PodcastFacetChip(facet string, v FacetValue) {
|
|
<a href={ "/podcasts?" + facet + "=" + url.QueryEscape(v.Value) } class="badge hover:opacity-80 transition-opacity" style="background-color: var(--surface-secondary); color: var(--text-secondary); text-decoration: none;">
|
|
{ v.Value }
|
|
if v.Count > 1 {
|
|
<span class="opacity-70 ml-1">{ podcastInt(v.Count) }</span>
|
|
}
|
|
</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 {
|
|
@PodcastsSections(data)
|
|
}
|
|
}
|
|
</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>
|
|
@podcastFacetGroup("host", data.Facets["hosts"])
|
|
@podcastFacetGroup("tag", data.Facets["tags"])
|
|
@podcastFacetGroup("language", data.Facets["language"])
|
|
if data.Query != "" {
|
|
<a href="/podcasts" class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">clear</a>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// podcastFacetBatchSize: how many facet chips render flat before the
|
|
// rest collapse into a "+N more" expander (native <details>, no JS).
|
|
// With hundreds of feeds the flat row would be a wall of chips; the
|
|
// frequency sort keeps the useful ones up front.
|
|
var podcastFacetBatchSize = 6
|
|
|
|
templ podcastFacetGroup(facet string, values []FacetValue) {
|
|
if len(values) > 0 {
|
|
for _, v := range values[:min(len(values), podcastFacetBatchSize)] {
|
|
@PodcastFacetChip(facet, v)
|
|
}
|
|
if len(values) > podcastFacetBatchSize {
|
|
<details class="inline-block">
|
|
<summary class="badge cursor-pointer select-none" style="background-color: var(--surface-secondary); color: var(--text-secondary)">+{ podcastInt(len(values) - podcastFacetBatchSize) } more</summary>
|
|
<span class="inline-flex flex-wrap gap-2 align-middle ml-2">
|
|
for _, v := range values[podcastFacetBatchSize:] {
|
|
@PodcastFacetChip(facet, v)
|
|
}
|
|
</span>
|
|
</details>
|
|
}
|
|
}
|
|
}
|
|
|
|
// PodcastsSections renders the Continue section (feeds with in-progress
|
|
// episodes, most recently listened first — plan 2.5) ahead of the
|
|
// alphabetical All Feeds grid.
|
|
templ PodcastsSections(data PodcastsPageData) {
|
|
if len(data.ContinueFeeds) > 0 {
|
|
<h2 class="text-lg font-bold mb-3 mt-2" style="color: var(--text-primary)">Continue</h2>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8">
|
|
for _, feed := range data.ContinueFeeds {
|
|
@PodcastFeedCardView(feed)
|
|
}
|
|
</div>
|
|
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">All Feeds</h2>
|
|
}
|
|
@PodcastsGrid(data.Feeds)
|
|
}
|
|
|
|
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.HasContinue {
|
|
<p class="text-xs font-medium line-clamp-1" style="color: var(--accent)">
|
|
@Icon("book-open", "h-3.5 w-3.5 inline")
|
|
{ " " + podcastInt(feed.ContinueEpisode) + ". " + feed.ContinueTitle }
|
|
</p>
|
|
} else 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.EpisodeMatches > 0 {
|
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">
|
|
{ podcastInt(feed.EpisodeMatches) + " matching episode" + podcastPlural(feed.EpisodeMatches) }
|
|
</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 }/>
|
|
}
|
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm">
|
|
<input type="checkbox" id="podcast-download-all" name="download_all" value="true" class="cursor-pointer"/>
|
|
<label for="podcast-download-all" style="color: var(--text-secondary)">Download all episodes now</label>
|
|
<!-- Download order (§0 item 5c): revealed by pure CSS when
|
|
the checkbox is checked — #podcast-download-all:checked ~
|
|
.podcast-add-order. The checkbox and this row MUST stay
|
|
siblings under this wrapper for the selector to fire. -->
|
|
<div class="podcast-add-order flex items-center gap-2 pl-6">
|
|
<label for="podcast-download-order" style="color: var(--text-secondary)">Download order</label>
|
|
<select
|
|
id="podcast-download-order"
|
|
name="download_order"
|
|
class="input w-auto py-1.5 cursor-pointer"
|
|
title="Listen/download order for this feed — chapters land in this order"
|
|
>
|
|
<option value="feed" selected>Feed order</option>
|
|
<option value="reverse">Feed order (reversed)</option>
|
|
<option value="oldest">Oldest first (pub date)</option>
|
|
<option value="newest">Newest first (pub date)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
}
|