feat(podcasts): web UI — /podcasts page, feed detail, download lifecycle (task 1.9)
Rendering ownership holds throughout: every piece of server data is
templ-rendered; htmx swaps partials; the only TS is the Add-Feed modal
toggle + error surfacing (pure client-local state, bundled via
web/src/podcasts.ts with automatic bundle-hash stamping).
Sidebar: /podcasts nav entry (D13, between Series and Collections),
gated by a ShowPodcastsNav flag resolved centrally in
getTemplateUserWithTheme (one indexed COUNT — same class of
per-request lookup as the live-user guard). New 'podcast' icon.
/podcasts: header + Add Feed modal (htmx post → HX-Redirect to the
feed page; inline error surfacing on failure), subscription-scoped
search + facet bar (host/tag/language — server-side GET filters, no
JS), grid cards with download counts / new-episodes / complete /
explicit badges. Empty states for no-library (admin creates it first)
and no-subscriptions.
Feed detail: hero (artwork, host/publisher, tags, fetch state), action
row — Download All, Refresh, ORDER SELECTOR (D5 override), serial
one-click oldest-first suggestion (never silent), auto-download policy
select (D6), Unsubscribe (files retained), admin Remove Feed — gap
banner (D8 count), and the EPISODE LIST with the full lifecycle inline
(D8/D19): pending greyed + Download button (every non-downloaded
episode has one), queued/downloading pulse, failed RED + reason text +
Retry (same endpoint). The list is a self-refreshing partial: re-fetch
on the podcast:changed HX-Trigger and polls every 4s ONLY while the
server says downloads are active (it renders the polling variant).
API endpoints grew HX awareness: 204 + HX-Trigger podcast:changed (or
HX-Redirect for add/unsubscribe/remove) — the sanctioned Go-handler
response channels; JSON callers keep JSON. AddFeed result exposes the
fetch-error text for the redirect.
Dates render in the requesting user's timezone (D17) via
utils.FormatTimeInTimezone; episodes play through the existing
/readers/{id} audio reader (episodes ARE audiobook items — no new
player code day one). CSP tripwire (templates/csp_source_test.go)
passes: no inline scripts, no on*= handlers, no hx-on/js: vals.
This commit is contained in:
1 parent
54d7d6e7a1
commit
2235b00c59
19 files changed
+3540
-327
No files matched your search
@@ -0,0 +1,170 @@
|
||||
// Podcast template view types (plan task 1.9): the SSR shape shared by
|
||||
// the /podcasts page and the feed-detail page. The server formats
|
||||
// everything display-ready (dates in the requesting user's timezone —
|
||||
// D17; durations human) so templates stay logic-free.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/utils"
|
||||
)
|
||||
|
||||
// PodcastFeedCard is one grid card on /podcasts (or the hero on the
|
||||
// detail page).
|
||||
type PodcastFeedCard struct {
|
||||
ID string
|
||||
LibraryID string
|
||||
Title string
|
||||
DisplayName string // subscription-owned; renders instead of Title when set
|
||||
Description string
|
||||
Host string
|
||||
Publisher string
|
||||
Language string
|
||||
Tags []string
|
||||
Explicit bool
|
||||
ArtworkPath string
|
||||
FeedType string // serial | episodic
|
||||
Medium string
|
||||
IsComplete bool
|
||||
FeedURL string
|
||||
LastFetched string // display-formatted
|
||||
SubscribedAt string
|
||||
|
||||
EpisodeTotal int
|
||||
DownloadedN int
|
||||
UndownloadedN int
|
||||
NewEpisodes bool
|
||||
|
||||
// Subscription-owned policy (detail page settings block).
|
||||
AutoDownload string
|
||||
AutoDownloadCount int
|
||||
Subscribed bool
|
||||
}
|
||||
|
||||
// DisplayTitle renders the subscription display name when set (D9).
|
||||
func (c PodcastFeedCard) DisplayTitle() string {
|
||||
if c.DisplayName != "" {
|
||||
return c.DisplayName
|
||||
}
|
||||
return c.Title
|
||||
}
|
||||
|
||||
// TypeLabel renders the feed's shape for the card badge.
|
||||
func (c PodcastFeedCard) TypeLabel() string {
|
||||
if c.FeedType == "serial" {
|
||||
return "Serial"
|
||||
}
|
||||
if c.Medium != "" {
|
||||
return strings.ToUpper(c.Medium[:1]) + c.Medium[1:]
|
||||
}
|
||||
return "Podcast"
|
||||
}
|
||||
|
||||
// PodcastEpisodeRow is one episode-list line with the download
|
||||
// lifecycle inline (D8/D19: pending greyed + download button,
|
||||
// queued/downloading spinner, failed/rejected red + reason + retry).
|
||||
type PodcastEpisodeRow struct {
|
||||
ID string
|
||||
FeedID string
|
||||
Title string
|
||||
Description string
|
||||
Date string // display-formatted (user tz)
|
||||
Position int
|
||||
Duration string // human ("58:02" / "1:02:10")
|
||||
Size string // human MB/GB when known
|
||||
PlayURL string // reader URL (audio reader serves episodes like audiobooks)
|
||||
CoverImage string
|
||||
ProgressPct float64
|
||||
Downloaded bool
|
||||
DownloadState string // pending|queued|downloading|downloaded|failed
|
||||
DownloadError string
|
||||
}
|
||||
|
||||
// StateBadge renders the canonical state label.
|
||||
func (r PodcastEpisodeRow) StateBadge() string {
|
||||
switch r.DownloadState {
|
||||
case "pending":
|
||||
return "Not downloaded"
|
||||
case "queued":
|
||||
return "Queued"
|
||||
case "downloading":
|
||||
return "Downloading…"
|
||||
case "failed":
|
||||
return "Failed"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// HasActiveDownloads: any episode queued/downloading — the partial
|
||||
// polls only while this is true (server-driven polling cadence).
|
||||
func HasActiveDownloads(rows []PodcastEpisodeRow) bool {
|
||||
for _, r := range rows {
|
||||
if r.DownloadState == "queued" || r.DownloadState == "downloading" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormatEpisodeDuration renders seconds as H:MM:SS or M:SS.
|
||||
func FormatEpisodeDuration(seconds int) string {
|
||||
if seconds <= 0 {
|
||||
return ""
|
||||
}
|
||||
h := seconds / 3600
|
||||
m := (seconds % 3600) / 60
|
||||
s := seconds % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, s)
|
||||
}
|
||||
|
||||
// FormatEpisodeSize renders bytes human (MB/GB).
|
||||
func FormatEpisodeSize(bytes int64) string {
|
||||
switch {
|
||||
case bytes <= 0:
|
||||
return ""
|
||||
case bytes >= 1<<30:
|
||||
return fmt.Sprintf("%.1f GB", float64(bytes)/(1<<30))
|
||||
default:
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/(1<<20))
|
||||
}
|
||||
}
|
||||
|
||||
// PodcastsPageData carries everything the /podcasts page renders.
|
||||
type PodcastsPageData struct {
|
||||
Feeds []PodcastFeedCard
|
||||
Libraries []LibraryData // podcasts-type libraries (add-feed targets)
|
||||
Facets map[string][]string
|
||||
Query string
|
||||
ErrorMsg string
|
||||
HasLibrary bool
|
||||
}
|
||||
|
||||
// PodcastDetailData carries the feed-detail page render.
|
||||
type PodcastDetailData struct {
|
||||
Feed PodcastFeedCard
|
||||
Episodes []PodcastEpisodeRow
|
||||
GapCount int
|
||||
Order string // native | oldest | newest (the caller's override)
|
||||
IsAdmin bool
|
||||
IsSerial bool // serial/audiobook feeds get the oldest-first suggestion (D5)
|
||||
}
|
||||
|
||||
// podcastInt renders an int for templates.
|
||||
func podcastInt(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// timeStringPtr helper: display-format with empty fallback.
|
||||
func podcastTimeOrEmpty(t time.Time, valid bool, tz string) string {
|
||||
if !valid || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return utils.FormatTimeInTimezone(t, tz)
|
||||
}
|
||||
Reference in new issue
Block a user