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) {
{ 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.
}
No episodes match the filter.
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)
}
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) {