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) { { data.Feed.DisplayTitle() } - Bookhoard @Header(user, "/podcasts")
@Icon("arrow-left", "h-4 w-4") Podcasts
if data.Feed.ArtworkPath != "" { { } else { @Icon("podcast", "h-12 w-12") }
{ data.Feed.TypeLabel() } if data.Feed.IsComplete { complete } if data.Feed.Language != "" { { data.Feed.Language } }

{ data.Feed.DisplayTitle() }

if data.Feed.Host != "" || data.Feed.Publisher != "" {

if data.Feed.Host != "" { { "Host: " + data.Feed.Host } } if data.Feed.Publisher != "" && data.Feed.Publisher != data.Feed.Host { { " · Publisher: " + data.Feed.Publisher } }

} if data.Feed.Description != "" {

{ data.Feed.Description }

} if len(data.Feed.Tags) > 0 {
for _, t := range data.Feed.Tags { { t } }
}

{ podcastInt(data.Feed.DownloadedN) + " of " + podcastInt(data.Feed.EpisodeTotal) + " episodes downloaded" } if data.Feed.LastFetched != "" { { " · last checked " + data.Feed.LastFetched } }

if data.IsSerial && data.Order == "native" { } @PodcastAutoDownloadSelect(data.Feed) if data.Feed.AutoDownload == "newest" { } if data.IsAdmin && data.SubscriberCount <= 1 { } else if data.IsAdmin { } else { } if data.IsAdmin { }
if data.GapCount > 0 {
@Icon("circle", "h-5 w-5")

{ podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed" }

Playback skips them and keeps going — use Download All to fill the gaps.

}
if HasActiveDownloads(data.Episodes) { @PodcastEpisodeListPolling(data) } else { @PodcastEpisodeList(data) }
@PodcastUnsubscribeModal(data) } // 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) {

Episodes

if len(data.Episodes) == 0 {
No episodes yet — hit Refresh to check the feed.
} else {
for _, ep := range data.Episodes { @PodcastEpisodeRowView(ep) }
}
} // 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) {

Episodes

for _, ep := range data.Episodes { @PodcastEpisodeRowView(ep) }
} templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
{ podcastInt(ep.Position) }
{ ep.Title } if ep.DownloadState == "failed" { { ep.StateBadge() } } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { { ep.StateBadge() } } else if ep.DownloadState == "pending" { { ep.StateBadge() } }
if ep.DownloadError != "" {

{ ep.DownloadError }

} if ep.DownloadState == "downloading" && ep.ProgressKnown {
if ep.ProgressTotal > 0 {

{ FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal) }

} else {

{ FormatEpisodeSize(ep.ProgressReceived) }

}
}

{ ep.Date } if ep.Duration != "" { { " · " + ep.Duration } } if ep.Size != "" { { " · " + ep.Size } }

if ep.Downloaded { @Icon("book-open", "h-4 w-4") Play } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { @Icon("refresh", "h-4 w-4") } else { }
} // 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) { } // 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) { } // 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) { } // 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) {

Unsubscribe from { data.Feed.DisplayTitle() }?

You are the only subscriber.

}