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.
239 lines
6.8 KiB
Go
239 lines
6.8 KiB
Go
// 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"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookhoard/internal/utils"
|
|
)
|
|
|
|
// PodcastFeedCard is one grid card on /podcasts (or the hero on the
|
|
// detail page).
|
|
// FacetValue is one facet-bar chip: the value plus how many subscribed
|
|
// feeds carry it (§0 follow-up, 2026-10-30 — at scale the bar caps per
|
|
// group and overflows into a +N more expander, frequency-sorted).
|
|
type FacetValue struct {
|
|
Value string
|
|
Count int
|
|
}
|
|
|
|
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
|
|
|
|
// EpisodeMatches (issue #8): when a search query is active, the
|
|
// number of the feed's episodes whose title/description matched —
|
|
// feeds match on EPISODES too, not just their own metadata.
|
|
EpisodeMatches int
|
|
|
|
// Continue aggregation (plan 2.5): the episode the user is midway
|
|
// through, most recent first across feeds.
|
|
HasContinue bool
|
|
ContinueTitle string
|
|
ContinueEpisode int
|
|
ContinuePercent float64
|
|
ContinueLastRead time.Time // section sort key (most recent first)
|
|
}
|
|
|
|
// 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)
|
|
SortDate string // lexical-comparable pub-date key (2006-01-02) for ordering
|
|
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
|
|
Downloaded bool
|
|
DownloadState string // pending|queued|downloading|downloaded|failed
|
|
DownloadError string
|
|
|
|
// Playback progress (reading_progress; the row badge) — reuses the
|
|
// ProgressPct field below when the row is downloading? No: keep the
|
|
// two concerns separate (issue #1).
|
|
ListenPct float64
|
|
|
|
// Live byte progress for rows in the downloading state (issue #1):
|
|
// the worker's in-flight snapshot. ProgressKnown=false renders the
|
|
// indeterminate bar; ProgressPct (0-100) drives the width when the
|
|
// total is authoritative.
|
|
ProgressKnown bool
|
|
ProgressPct float64
|
|
ProgressReceived int64
|
|
ProgressTotal int64
|
|
}
|
|
|
|
// 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 {
|
|
// ContinueFeeds: the in-progress subset, most recently listened
|
|
// first (plan 2.5) — split in the page builder (templ bodies can't
|
|
// declare locals).
|
|
ContinueFeeds []PodcastFeedCard
|
|
Feeds []PodcastFeedCard
|
|
Libraries []LibraryData // podcasts-type libraries (add-feed targets)
|
|
Facets map[string][]FacetValue
|
|
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)
|
|
// SubscriberCount (issue #7): drives the last-subscriber
|
|
// unsubscribe flow — the sole subscriber who is an admin gets the
|
|
// three-choice modal (unsubscribe / remove feed / cancel).
|
|
SubscriberCount int
|
|
}
|
|
|
|
// podcastInt renders an int for templates.
|
|
func podcastInt(n int) string {
|
|
return fmt.Sprintf("%d", n)
|
|
}
|
|
|
|
// podcastPlural returns "" for 1 and "s" otherwise.
|
|
func podcastPlural(n int) string {
|
|
if n == 1 {
|
|
return ""
|
|
}
|
|
return "s"
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
|
|
// PodcastContinueFeeds returns the in-progress feeds, most recently
|
|
// listened first (plan 2.5). ≥95% progress counts as finished and is
|
|
// excluded (the continue query already applies that bound).
|
|
func PodcastContinueFeeds(feeds []PodcastFeedCard) []PodcastFeedCard {
|
|
out := make([]PodcastFeedCard, 0, len(feeds))
|
|
for _, f := range feeds {
|
|
if f.HasContinue {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
return out[i].ContinueLastRead.After(out[j].ContinueLastRead)
|
|
})
|
|
return out
|
|
}
|