WHY: the /podcasts search box filtered feed CARDS only (title, display name, host, publisher, feed description). D14's episode search existed only as the /api/podcasts/search endpoint and was never wired into the page — searching 'Quarter' (present only in episode titles) cleared the grid while 'Trader' (feed metadata) survived. The page query now consults SearchMyPodcastEpisodes (subscription-scoped via RLS — the caller's identity) when a query is present; a feed surfaces when its OWN metadata OR any of its episodes matches, with an 'N matching episode(s)' badge on the card. PIN: SSR test with a needle that exists only in an episode title — the feed must surface with the match count.
257 lines
9.6 KiB
Templ
257 lines
9.6 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 {
|
|
@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>
|
|
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>
|
|
}
|
|
}
|
|
|
|
// 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 }/>
|
|
}
|
|
<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>
|
|
}
|