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.
510 lines
20 KiB
Templ
510 lines
20 KiB
Templ
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).
|
|
|
|
import "fmt"
|
|
import "strings"
|
|
|
|
// boolAttr renders a boolean data attribute value.
|
|
func boolAttr(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
|
|
// podcastPct renders a listen percentage for the row's data attribute
|
|
// (one decimal; the client-side hide-played filter reads it).
|
|
func podcastPct(p float64) string {
|
|
return fmt.Sprintf("%.1f", p)
|
|
}
|
|
|
|
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" x-init="initPodcastFilterPersistence()" 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-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
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-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
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-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<option value="native" selected?={ data.Order == "native" }>Feed order</option>
|
|
<option value="reverse" selected?={ data.Order == "reverse" }>Feed order (reversed)</option>
|
|
<option value="oldest" selected?={ data.Order == "oldest" }>Oldest first (pub date)</option>
|
|
<option value="newest" selected?={ data.Order == "newest" }>Newest first (pub date)</option>
|
|
</select>
|
|
|
|
<!-- Serial suggestion (D5, §0 item 3 rename): one-click,
|
|
never silent. "reverse" sorts by FEED POSITION DESC —
|
|
no pub dates involved, so messy dates can't break it. -->
|
|
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": "reverse"}'
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
title="Serial feed: publishers list newest first — reverse the feed order to start at chapter 1"
|
|
>
|
|
<span>Reverse feed order</span>
|
|
</button>
|
|
}
|
|
|
|
<!-- Auto-download policy (D6) -->
|
|
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
|
|
@PodcastAutoDownloadSelect(data.Feed)
|
|
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 (issues #4/#7): the sole subscriber who is
|
|
an admin gets the three-choice modal (unsubscribe /
|
|
remove feed / cancel); everyone else gets a plain
|
|
confirm — file retention is admin knowledge only. -->
|
|
if data.IsAdmin && data.SubscriberCount <= 1 {
|
|
<button class="btn btn-secondary ml-auto" @click="unsubOpen = true">
|
|
<span>Unsubscribe</span>
|
|
</button>
|
|
} else if data.IsAdmin {
|
|
<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>
|
|
} else {
|
|
<button
|
|
class="btn btn-secondary ml-auto"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
|
hx-confirm="Unsubscribe from this feed?"
|
|
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 filters (§0 item 6): pure client-local
|
|
presentation state (Alpine; survives the list's htmx
|
|
swaps because this toolbar lives OUTSIDE #episode-list).
|
|
Hidden rows re-apply after every swap via the component's
|
|
htmx:afterSwap listener. -->
|
|
<div class="flex flex-wrap items-center gap-2 mb-2">
|
|
<input
|
|
type="search"
|
|
class="input w-auto py-1.5 text-sm flex-1 min-w-[150px]"
|
|
placeholder="Filter episodes…"
|
|
aria-label="Filter episodes by title or description"
|
|
x-ref="episodeFilterInput"
|
|
@input="applyEpisodeFilter($event)"
|
|
/>
|
|
<label class="flex items-center gap-1.5 text-sm cursor-pointer select-none" style="color: var(--text-secondary)">
|
|
<input type="checkbox" class="cursor-pointer" @change="applyEpisodeFilter($event)" x-ref="hideFinished"/>
|
|
Hide played/downloaded
|
|
</label>
|
|
<span class="text-xs hidden" x-ref="filterEmptyNote" style="color: var(--text-secondary)">No episodes match the filter.</span>
|
|
</div>
|
|
|
|
<!-- Episode list (self-refreshing htmx partial). Pick the
|
|
polling variant at page-render time when downloads are
|
|
active — otherwise a fresh page load mid-download
|
|
renders the non-polling container and never updates
|
|
(the polling variant was previously only reachable via
|
|
the /episodes partial route). -->
|
|
if HasActiveDownloads(data.Episodes) {
|
|
@PodcastEpisodeListPolling(data)
|
|
} else {
|
|
@PodcastEpisodeList(data)
|
|
}
|
|
</main>
|
|
@PodcastUnsubscribeModal(data)
|
|
</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 2s"
|
|
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"
|
|
data-episode-row="true"
|
|
data-downloaded={ boolAttr(ep.Downloaded) }
|
|
data-listen-pct={ podcastPct(ep.ListenPct) }
|
|
data-search-text={ strings.ToLower(ep.Title + " " + ep.Description) }
|
|
>
|
|
<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>
|
|
}
|
|
if ep.DownloadState == "downloading" && ep.ProgressKnown {
|
|
<div class="mt-1.5">
|
|
if ep.ProgressTotal > 0 {
|
|
<div class="h-1.5 w-full rounded-full overflow-hidden" style="background-color: var(--surface-secondary);">
|
|
<div class="h-full rounded-full transition-all" style={ progressWidth(ep.ProgressPct) + " background-color: var(--accent);" }></div>
|
|
</div>
|
|
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">
|
|
{ FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal) }
|
|
</p>
|
|
} else {
|
|
<div class="podcast-progress-indeterminate h-1.5 w-full rounded-full overflow-hidden" style="background-color: var(--surface-secondary);">
|
|
<div class="h-full rounded-full" style="background-color: var(--accent);"></div>
|
|
</div>
|
|
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">{ FormatEpisodeSize(ep.ProgressReceived) }</p>
|
|
}
|
|
</div>
|
|
}
|
|
<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 — chains into the next downloaded episode (append ?single=1 to the URL to play just this one)">
|
|
@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"
|
|
}
|
|
|
|
// progressWidth renders the bar width as a quote-free inline style
|
|
// fragment (templ gotcha #8: style attribute values must not carry
|
|
// quote characters).
|
|
func progressWidth(pct float64) string {
|
|
if pct < 1 {
|
|
pct = 1
|
|
}
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
return fmt.Sprintf("width: %.1f%%;", pct)
|
|
}
|
|
|
|
func downloadButtonTitle(state string) string {
|
|
if state == "failed" {
|
|
return "Retry the download"
|
|
}
|
|
return "Download this episode"
|
|
}
|
|
|
|
|
|
// PodcastAutoDownloadSelect is the policy dropdown (issue #3): extracted
|
|
// so the Download All response can out-of-band-swap it after flipping
|
|
// the caller's policy — the dropdown must reflect what the user gets.
|
|
templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
|
|
<select
|
|
id="podcast-autodl-select"
|
|
name="auto_download"
|
|
class="input w-auto py-1.5 cursor-pointer"
|
|
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<option value="off" selected?={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
|
<option value="all" selected?={ feed.AutoDownload == "all" }>All episodes</option>
|
|
<option value="newest" selected?={ feed.AutoDownload == "newest" }>Newest</option>
|
|
</select>
|
|
}
|
|
|
|
// PodcastOrderSelectOOB is the out-of-band variant of the order
|
|
// dropdown: same element id + hx-swap-oob so htmx replaces the
|
|
// action-row select in place (§0 item 3 — the "Reverse feed order"
|
|
// button flips the order server-side; the dropdown must show it).
|
|
templ PodcastOrderSelectOOB(feedID string, order string) {
|
|
<select
|
|
id="podcast-order-select"
|
|
name="order"
|
|
hx-swap-oob="true"
|
|
class="input w-auto py-1.5 cursor-pointer"
|
|
hx-put={ "/api/podcasts/feeds/" + feedID + "/order" }
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<option value="native" selected?={ order == "native" }>Feed order</option>
|
|
<option value="reverse" selected?={ order == "reverse" }>Feed order (reversed)</option>
|
|
<option value="oldest" selected?={ order == "oldest" }>Oldest first (pub date)</option>
|
|
<option value="newest" selected?={ order == "newest" }>Newest first (pub date)</option>
|
|
</select>
|
|
}
|
|
|
|
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
|
|
// id + hx-swap-oob so htmx replaces the action-row select in place.
|
|
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
|
|
<select
|
|
id="podcast-autodl-select"
|
|
name="auto_download"
|
|
hx-swap-oob="true"
|
|
class="input w-auto py-1.5 cursor-pointer"
|
|
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<option value="off" selected?={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
|
<option value="all" selected?={ feed.AutoDownload == "all" }>All episodes</option>
|
|
<option value="newest" selected?={ feed.AutoDownload == "newest" }>Newest</option>
|
|
</select>
|
|
}
|
|
|
|
// PodcastUnsubscribeModal is the last-subscriber admin choice (issue
|
|
// #7): unsubscribe (files kept — the D6 default), remove feed + files
|
|
// entirely, or cancel. Reachable only for admins (the button that opens
|
|
// it renders for admins alone).
|
|
templ PodcastUnsubscribeModal(data PodcastDetailData) {
|
|
<div
|
|
id="podcast-unsub-modal"
|
|
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
|
style="background-color: var(--surface-overlay);"
|
|
x-show="unsubOpen"
|
|
x-cloak
|
|
@click.self="unsubOpen = false"
|
|
>
|
|
<div class="card w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
|
<div class="p-6 border-b" style="border-color: var(--border);">
|
|
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Unsubscribe from { data.Feed.DisplayTitle() }?</h2>
|
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">You are the only subscriber.</p>
|
|
</div>
|
|
<div class="p-6 flex flex-col gap-2">
|
|
<button
|
|
class="btn btn-secondary w-full"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
|
hx-swap="none"
|
|
@click="unsubOpen = false"
|
|
>
|
|
<span>Unsubscribe — files kept</span>
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary w-full"
|
|
style="color: var(--status-danger);"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
|
|
hx-swap="none"
|
|
@click="unsubOpen = false"
|
|
>
|
|
<span>Remove feed — delete episodes and files</span>
|
|
</button>
|
|
<button type="button" class="btn btn-secondary w-full" @click="unsubOpen = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|