From 2235b00c59273fa9a784f468f84b4ceb1bcfeee2 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 16:17:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(podcasts):=20web=20UI=20=E2=80=94=20/podca?= =?UTF-8?q?sts=20page,=20feed=20detail,=20download=20lifecycle=20(task=201?= =?UTF-8?q?.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/database/querier.go | 17 +- internal/database/queries.sql.go | 248 +++++- internal/database/queries/queries.sql | 12 + internal/handlers/podcast.go | 61 +- internal/router/helpers.go | 9 + internal/router/podcast_page.go | 398 ++++++++++ internal/router/router.go | 1 + templates/header.templ | 6 + templates/header_templ.go | 570 +++++++------- templates/icons.templ | 6 + templates/icons_templ.go | 87 ++- templates/podcast_detail.templ | 310 ++++++++ templates/podcast_detail_templ.go | 1037 +++++++++++++++++++++++++ templates/podcast_types.go | 170 ++++ templates/podcasts.templ | 230 ++++++ templates/podcasts_templ.go | 639 +++++++++++++++ templates/types.go | 4 + web/src/main.ts | 1 + web/src/podcasts.ts | 61 ++ 19 files changed, 3540 insertions(+), 327 deletions(-) create mode 100644 internal/router/podcast_page.go create mode 100644 templates/podcast_detail.templ create mode 100644 templates/podcast_detail_templ.go create mode 100644 templates/podcast_types.go create mode 100644 templates/podcasts.templ create mode 100644 templates/podcasts_templ.go create mode 100644 web/src/podcasts.ts diff --git a/internal/database/querier.go b/internal/database/querier.go index a1b54018..b2f07a88 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -61,10 +61,12 @@ type Querier interface { // shares the directory; otherwise the sidecar goes to // .opf so one book's metadata can never poison dir-mates. CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error) + CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) CountPodcastFeedSubscribers(ctx context.Context, feedID pgtype.UUID) (int64, error) // Sidebar/page gate (D13): >=1 podcasts-type library the user can see // (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries). CountPodcastLibrariesForUser(ctx context.Context, userID pgtype.UUID) (int64, error) + CountQueuedEpisodes(ctx context.Context) (int64, error) CountUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) // Count unlinked books for a device (ignored rows stay hidden) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) @@ -199,6 +201,7 @@ type Querier interface { // Delete unlinked book DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id pgtype.UUID) error + DeleteUserSettingByKey(ctx context.Context, arg DeleteUserSettingByKeyParams) error DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error // Device deletion (deregister on logout): detach the device's reading // history instead of deleting it — these rows are real captured stats @@ -443,6 +446,8 @@ type Querier interface { // GetMediaScanState reads one item's manifest (per-item rescan paths // and the manifest integration tests). GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error) + // Sequential download worker's claim source (single worker, day one). + GetNextQueuedEpisode(ctx context.Context) (MediaItems, error) GetNextRetryTime(ctx context.Context) (interface{}, error) GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error) // Get OPDS token @@ -451,6 +456,9 @@ type Querier interface { GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error) GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error) GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error) + // Per-user progress for a feed's episodes (row badges + the Phase-2 + // continue fields). reading_progress is UNIQUE per (media_item, user). + GetPodcastEpisodeProgress(ctx context.Context, arg GetPodcastEpisodeProgressParams) ([]GetPodcastEpisodeProgressRow, error) GetPodcastFeed(ctx context.Context, id pgtype.UUID) (PodcastFeeds, error) // URL-migration dedupe: same canonical guid within the library reuses // the existing row. @@ -561,6 +569,12 @@ type Querier interface { // END instant (the push time; NULL falls back to created_at) — the same // value the capture stamps every write with. GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error) + // ============================================================================ + // GENERIC reader_settings KV (podcast order override, plan D5: key + // podcast_order:). The pre-existing reader_settings queries are + // hard-keyed to 'reader_settings'; these two are the generic form. + // ============================================================================ + GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) // Smart ("dynamic") collections: rules are evaluated LIVE at render, so @@ -724,7 +738,7 @@ type Querier interface { // un-downloaded AND historical rows (fetcher persists metadata-only). // RLS already restricts to the caller's subscriptions — the explicit // source filter keeps scan items out of podcast search. - SearchMyPodcastEpisodes(ctx context.Context, dollar_1 interface{}) ([]MediaItems, error) + SearchMyPodcastEpisodes(ctx context.Context, searchQuery string) ([]MediaItems, error) // Feed-title AND subscription-display-name search (D14) + the facet // fields the podcasts page filters on. SearchMyPodcastFeeds(ctx context.Context, arg SearchMyPodcastFeedsParams) ([]SearchMyPodcastFeedsRow, error) @@ -863,6 +877,7 @@ type Querier interface { // Ingest: insert or refresh the queue row for a device file that could not // be identified. One row per (device, content_id); re-syncs bump last_seen. UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error) + UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error } var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 749b29f3..bde82200 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -413,6 +413,18 @@ func (q *Queries) CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherM return column_1, err } +const CountPodcastEpisodesForFeed = `-- name: CountPodcastEpisodesForFeed :one +SELECT COUNT(*) FROM media_items +WHERE source_feed_id = $1 AND source = 'podcast_episode' +` + +func (q *Queries) CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, CountPodcastEpisodesForFeed, sourceFeedID) + var count int64 + err := row.Scan(&count) + return count, err +} + const CountPodcastFeedSubscribers = `-- name: CountPodcastFeedSubscribers :one SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1 ` @@ -441,6 +453,18 @@ func (q *Queries) CountPodcastLibrariesForUser(ctx context.Context, userID pgtyp return count, err } +const CountQueuedEpisodes = `-- name: CountQueuedEpisodes :one +SELECT COUNT(*) FROM media_items +WHERE source = 'podcast_episode' AND download_state IN ('queued', 'downloading') +` + +func (q *Queries) CountQueuedEpisodes(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, CountQueuedEpisodes) + var count int64 + err := row.Scan(&count) + return count, err +} + const CountUndownloadedEpisodes = `-- name: CountUndownloadedEpisodes :one SELECT COUNT(*) FROM media_items WHERE source_feed_id = $1 @@ -2559,6 +2583,20 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error { return err } +const DeleteUserSettingByKey = `-- name: DeleteUserSettingByKey :exec +DELETE FROM reader_settings WHERE user_id = $1 AND setting_key = $2 +` + +type DeleteUserSettingByKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + SettingKey string `db:"setting_key" json:"setting_key"` +} + +func (q *Queries) DeleteUserSettingByKey(ctx context.Context, arg DeleteUserSettingByKeyParams) error { + _, err := q.db.Exec(ctx, DeleteUserSettingByKey, arg.UserID, arg.SettingKey) + return err +} + const DeleteUserSystemCollection = `-- name: DeleteUserSystemCollection :exec DELETE FROM collections WHERE user_id = $1 @@ -8924,6 +8962,111 @@ func (q *Queries) GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID return i, err } +const GetNextQueuedEpisode = `-- name: GetNextQueuedEpisode :one +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items +WHERE source = 'podcast_episode' AND download_state = 'queued' +ORDER BY updated_at ASC +LIMIT 1 +` + +// Sequential download worker's claim source (single worker, day one). +func (q *Queries) GetNextQueuedEpisode(ctx context.Context) (MediaItems, error) { + row := q.db.QueryRow(ctx, GetNextQueuedEpisode) + var i MediaItems + err := row.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Subtitle, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Narrators, + &i.Authors, + &i.AuthorsSearch, + &i.Publishers, + &i.PublishersSearch, + &i.Abridged, + &i.Explicit, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.ImportedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.MangaType, + &i.ReadingDirection, + &i.SeriesCount, + &i.Volume, + &i.Imprint, + &i.AgeRating, + &i.WebUrl, + &i.StoryArc, + &i.IsBlackAndWhite, + &i.MetadataNotes, + &i.CommunityRating, + &i.AlternateInfo, + &i.ScanInformation, + &i.Summary, + &i.MetadataOverrides, + &i.MissingScanCount, + &i.ArchivedAt, + &i.ChapterMetadata, + &i.LibraryTypeName, + &i.AudioMetadata, + &i.TagsSearch, + &i.ContributorsSearch, + &i.NarratorsSearch, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + &i.Source, + &i.SourceFeedID, + &i.EpisodeGuid, + &i.FeedPosition, + &i.SeasonNumber, + &i.EpisodeNumber, + &i.EnclosureUrl, + &i.DeclaredDurationSeconds, + &i.TranscriptUrl, + &i.ChaptersUrl, + &i.Hosts, + &i.Guests, + &i.DownloadState, + &i.DownloadError, + &i.DownloadAttempts, + ) + return i, err +} + const GetNextRetryTime = `-- name: GetNextRetryTime :one SELECT CASE @@ -9269,6 +9412,50 @@ func (q *Queries) GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaI return i, err } +const GetPodcastEpisodeProgress = `-- name: GetPodcastEpisodeProgress :many +SELECT rp.media_item_id, + rp.percentage, + rp.last_read_at +FROM reading_progress rp +JOIN media_items mi ON mi.id = rp.media_item_id +WHERE rp.user_id = $1 + AND mi.source_feed_id = $2 + AND mi.source = 'podcast_episode' +` + +type GetPodcastEpisodeProgressParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"` +} + +type GetPodcastEpisodeProgressRow struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + Percentage pgtype.Float8 `db:"percentage" json:"percentage"` + LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"` +} + +// Per-user progress for a feed's episodes (row badges + the Phase-2 +// continue fields). reading_progress is UNIQUE per (media_item, user). +func (q *Queries) GetPodcastEpisodeProgress(ctx context.Context, arg GetPodcastEpisodeProgressParams) ([]GetPodcastEpisodeProgressRow, error) { + rows, err := q.db.Query(ctx, GetPodcastEpisodeProgress, arg.UserID, arg.SourceFeedID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetPodcastEpisodeProgressRow{} + for rows.Next() { + var i GetPodcastEpisodeProgressRow + if err := rows.Scan(&i.MediaItemID, &i.Percentage, &i.LastReadAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetPodcastFeed = `-- name: GetPodcastFeed :one SELECT id, library_id, feed_url, podcast_guid, title, description, host, publisher, owner_name, owner_email, language, tags, explicit, artwork_path, feed_type, medium, is_complete, etag, last_modified, last_fetched_at, last_build_date, created_by_admin_id, created_at, updated_at FROM podcast_feeds WHERE id = $1 ` @@ -11832,6 +12019,29 @@ func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserRe return items, nil } +const GetUserSettingByKey = `-- name: GetUserSettingByKey :one + +SELECT setting_value FROM reader_settings +WHERE user_id = $1 AND setting_key = $2 +` + +type GetUserSettingByKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + SettingKey string `db:"setting_key" json:"setting_key"` +} + +// ============================================================================ +// GENERIC reader_settings KV (podcast order override, plan D5: key +// podcast_order:). The pre-existing reader_settings queries are +// hard-keyed to 'reader_settings'; these two are the generic form. +// ============================================================================ +func (q *Queries) GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error) { + row := q.db.QueryRow(ctx, GetUserSettingByKey, arg.UserID, arg.SettingKey) + var setting_value []byte + err := row.Scan(&setting_value) + return setting_value, err +} + const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, l.allow_member_feeds, lt.name as type_name, lt.description as type_description, COALESCE(lv.is_visible, true) as is_visible @@ -16819,7 +17029,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt const SearchMyPodcastEpisodes = `-- name: SearchMyPodcastEpisodes :many SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE source = 'podcast_episode' - AND ($1 = '' OR title ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') + AND ($1::text = '' OR title ILIKE '%' || $1::text || '%' OR description ILIKE '%' || $1::text || '%') ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST LIMIT 50 ` @@ -16828,8 +17038,8 @@ LIMIT 50 // un-downloaded AND historical rows (fetcher persists metadata-only). // RLS already restricts to the caller's subscriptions — the explicit // source filter keeps scan items out of podcast search. -func (q *Queries) SearchMyPodcastEpisodes(ctx context.Context, dollar_1 interface{}) ([]MediaItems, error) { - rows, err := q.db.Query(ctx, SearchMyPodcastEpisodes, dollar_1) +func (q *Queries) SearchMyPodcastEpisodes(ctx context.Context, searchQuery string) ([]MediaItems, error) { + rows, err := q.db.Query(ctx, SearchMyPodcastEpisodes, searchQuery) if err != nil { return nil, err } @@ -16947,16 +17157,16 @@ SELECT f.id, f.library_id, f.feed_url, f.podcast_guid, f.title, f.description, f FROM podcast_subscriptions s JOIN podcast_feeds f ON f.id = s.feed_id WHERE s.user_id = $1 - AND ($2 = '' OR f.title ILIKE '%' || $2 || '%' - OR COALESCE(s.display_name, '') ILIKE '%' || $2 || '%' - OR COALESCE(f.host, '') ILIKE '%' || $2 || '%' - OR COALESCE(f.publisher, '') ILIKE '%' || $2 || '%') + AND ($2::text = '' OR f.title ILIKE '%' || $2::text || '%' + OR COALESCE(s.display_name, '') ILIKE '%' || $2::text || '%' + OR COALESCE(f.host, '') ILIKE '%' || $2::text || '%' + OR COALESCE(f.publisher, '') ILIKE '%' || $2::text || '%') ORDER BY COALESCE(s.display_name, f.title) ASC ` type SearchMyPodcastFeedsParams struct { - UserID pgtype.UUID `db:"user_id" json:"user_id"` - Column2 interface{} `db:"column_2" json:"column_2"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + SearchQuery string `db:"search_query" json:"search_query"` } type SearchMyPodcastFeedsRow struct { @@ -16993,7 +17203,7 @@ type SearchMyPodcastFeedsRow struct { // Feed-title AND subscription-display-name search (D14) + the facet // fields the podcasts page filters on. func (q *Queries) SearchMyPodcastFeeds(ctx context.Context, arg SearchMyPodcastFeedsParams) ([]SearchMyPodcastFeedsRow, error) { - rows, err := q.db.Query(ctx, SearchMyPodcastFeeds, arg.UserID, arg.Column2) + rows, err := q.db.Query(ctx, SearchMyPodcastFeeds, arg.UserID, arg.SearchQuery) if err != nil { return nil, err } @@ -20471,3 +20681,21 @@ func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBook ) return i, err } + +const UpsertUserSettingByKey = `-- name: UpsertUserSettingByKey :exec +INSERT INTO reader_settings (user_id, setting_key, setting_value) +VALUES ($1, $2, $3) +ON CONFLICT (user_id, setting_key) +DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW() +` + +type UpsertUserSettingByKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + SettingKey string `db:"setting_key" json:"setting_key"` + SettingValue []byte `db:"setting_value" json:"setting_value"` +} + +func (q *Queries) UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error { + _, err := q.db.Exec(ctx, UpsertUserSettingByKey, arg.UserID, arg.SettingKey, arg.SettingValue) + return err +} diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 59209fce..bcbc48fe 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -4997,3 +4997,15 @@ DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW(); -- name: DeleteUserSettingByKey :exec DELETE FROM reader_settings WHERE user_id = $1 AND setting_key = $2; + +-- name: GetPodcastEpisodeProgress :many +-- Per-user progress for a feed's episodes (row badges + the Phase-2 +-- continue fields). reading_progress is UNIQUE per (media_item, user). +SELECT rp.media_item_id, + rp.percentage, + rp.last_read_at +FROM reading_progress rp +JOIN media_items mi ON mi.id = rp.media_item_id +WHERE rp.user_id = $1 + AND mi.source_feed_id = $2 + AND mi.source = 'podcast_episode'; diff --git a/internal/handlers/podcast.go b/internal/handlers/podcast.go index 0c1e71ed..78fa1d1f 100644 --- a/internal/handlers/podcast.go +++ b/internal/handlers/podcast.go @@ -33,21 +33,39 @@ func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worke // ---- request/response shapes ---- type AddPodcastFeedRequest struct { - LibraryID string `json:"library_id" validate:"required"` - URL string `json:"url" validate:"required"` - DownloadAll bool `json:"download_all"` + LibraryID string `json:"library_id" form:"library_id" validate:"required"` + URL string `json:"url" form:"url" validate:"required"` + DownloadAll bool `json:"download_all" form:"download_all"` } type UpdatePodcastSubscriptionRequest struct { - DisplayName *string `json:"display_name"` - AutoDownload *string `json:"auto_download" validate:"omitempty,oneof=off all newest"` - AutoDownloadCount *int `json:"auto_download_count"` + DisplayName *string `json:"display_name" form:"display_name"` + AutoDownload *string `json:"auto_download" form:"auto_download" validate:"omitempty,oneof=off all newest"` + AutoDownloadCount *int `json:"auto_download_count" form:"auto_download_count"` } type DownloadEpisodesRequest struct { EpisodeIDs []string `json:"episode_ids"` } +// hxAccepted answers an htmx request with 204 + the podcast:changed +// trigger — the episode-list partial re-fetches itself (its hx-trigger +// listens for the event; polling covers download progress). JSON +// callers keep the JSON body. HX-Redirect/HX-Trigger headers are the +// sanctioned Go-handler response channels (CSP discipline). +func hxAccepted(c *echo.Context, redirect string) error { + if redirect != "" { + c.Response().Header().Set("HX-Redirect", redirect) + return c.NoContent(http.StatusNoContent) + } + c.Response().Header().Set("HX-Trigger", "podcast:changed") + return c.NoContent(http.StatusNoContent) +} + +func isHXRequest(c *echo.Context) bool { + return c.Request().Header.Get("HX-Request") == "true" +} + // podcastErrorResponse maps service errors (incl. PodcastError statuses) // onto HTTP responses. func podcastErrorResponse(c *echo.Context, err error) error { @@ -94,6 +112,14 @@ func (h *PodcastHandler) AddFeed(c *echo.Context) error { // episode list states. } } + if isHXRequest(c) { + // Post-redirect-get: land on the new feed's page. Fetch errors + // (subscription stands) surface via the flash query param. + if res.FeedError() != "" { + return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID)+"?fetch_error=1") + } + return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID)) + } return c.JSON(http.StatusCreated, res) } @@ -223,6 +249,11 @@ func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error { if err != nil { return podcastErrorResponse(c, err) } + if isHXRequest(c) { + // Policy changes feed the auto-download union on the next poll. + _, _ = h.worker.ApplyAutoPolicies(ctx, feedID) + return hxAccepted(c, "") + } return c.JSON(http.StatusOK, updated) } @@ -242,6 +273,9 @@ func (h *PodcastHandler) DeleteSubscription(c *echo.Context) error { if rows == 0 { return c.JSON(http.StatusNotFound, map[string]string{"error": "not subscribed"}) } + if isHXRequest(c) { + return hxAccepted(c, "/podcasts") + } return c.JSON(http.StatusOK, map[string]string{"message": "unsubscribed (files retained)"}) } @@ -256,6 +290,9 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error { if err := h.podcasts.RefreshFeed(c.Request().Context(), feedID); err != nil { return podcastErrorResponse(c, err) } + if isHXRequest(c) { + return hxAccepted(c, "") + } return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"}) } @@ -301,6 +338,9 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error { if err != nil { return podcastErrorResponse(c, err) } + if isHXRequest(c) { + return hxAccepted(c, "") + } return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued}) } epID, err := parseUUID(eidRaw) @@ -310,6 +350,9 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error { if err := h.worker.QueueEpisode(ctx, epID, feedID); err != nil { return podcastErrorResponse(c, err) } + if isHXRequest(c) { + return hxAccepted(c, "") + } return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"}) } @@ -367,6 +410,9 @@ func (h *PodcastHandler) DeleteFeed(c *echo.Context) error { } else if rows == 0 { return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"}) } + if isHXRequest(c) { + return hxAccepted(c, "/podcasts") + } return c.JSON(http.StatusOK, map[string]string{"message": "feed removed"}) } @@ -438,6 +484,9 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error { return podcastErrorResponse(c, err) } } + if isHXRequest(c) { + return hxAccepted(c, "") + } return c.JSON(http.StatusOK, map[string]string{"order": req.Order}) } diff --git a/internal/router/helpers.go b/internal/router/helpers.go index ede81d5f..e6d82df7 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -55,6 +55,14 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err token = cookie.Value } + // Sidebar /podcasts gate (plan D13): >=1 visible podcasts library. + // One indexed COUNT per page render — the same class of per-request + // lookup the live-user guard already does. + showPodcasts := false + if n, err := cfg.Queries.CountPodcastLibrariesForUser(c.Request().Context(), uuidToPGType(userUUID)); err == nil && n > 0 { + showPodcasts = true + } + return templates.User{ ID: userID, Email: userEmail, @@ -68,6 +76,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err CreatedAt: userDB.CreatedAt.Time, DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed), KeepScreenAwake: userDB.KeepScreenAwake, + ShowPodcastsNav: showPodcasts, }, nil } diff --git a/internal/router/podcast_page.go b/internal/router/podcast_page.go new file mode 100644 index 00000000..6aef1c4e --- /dev/null +++ b/internal/router/podcast_page.go @@ -0,0 +1,398 @@ +package router + +// Podcast SSR pages (plan task 1.9): /podcasts (subscriptions grid + +// add-feed wizard) and /podcasts/:id (feed detail with the inline +// download lifecycle). Rendering ownership: ALL content is +// templ-rendered server data; htmx swaps partials; the sanctioned JS is +// the modal toggle + event delegation in web/src/podcasts.ts. + +import ( + "bookhoard/internal/database" + "bookhoard/internal/handlers" + "bookhoard/internal/utils" + "bookhoard/templates" + "bytes" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v5" +) + +func registerPodcastPages(cfg *Config) { + pages := cfg.Echo.Group("", createJWTMiddleware(cfg)) + + pages.GET("/podcasts", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + data, err := buildPodcastsPageData(c, cfg) + if err != nil { + data = &templates.PodcastsPageData{ErrorMsg: "Could not load subscriptions"} + } + var buf bytes.Buffer + if err := templates.PodcastsPage(user, *data, "/podcasts").Render(c.Request().Context(), &buf); err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + pages.GET("/podcasts/:id", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + data, err := buildPodcastDetailData(c, cfg, user) + if err != nil { + var buf bytes.Buffer + _ = templates.ErrorPage("Feed not found", "not_found").Render(c.Request().Context(), &buf) + return c.HTML(http.StatusNotFound, buf.String()) + } + var buf bytes.Buffer + if err := templates.PodcastDetailPage(user, *data).Render(c.Request().Context(), &buf); err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Episode list partial — the htmx refresh unit (poll while + // downloads are active; podcast:changed events swap it too). + pages.GET("/podcasts/:id/episodes", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + data, err := buildPodcastDetailData(c, cfg, user) + if err != nil { + return c.NoContent(http.StatusNotFound) + } + var buf bytes.Buffer + if templates.HasActiveDownloads(data.Episodes) { + err = templates.PodcastEpisodeListPolling(*data).Render(c.Request().Context(), &buf) + } else { + err = templates.PodcastEpisodeList(*data).Render(c.Request().Context(), &buf) + } + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) +} + +// buildPodcastsPageData assembles the /podcasts page: subscription +// cards, facet values, server-side filters (q/host/tag/language). +func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPageData, error) { + user := handlers.MustGetAuthenticatedUser(c) + ctx := c.Request().Context() + + libs, err := cfg.Queries.GetPodcastsLibraryForUser(ctx, user.ID) + data := &templates.PodcastsPageData{HasLibrary: len(libs) > 0} + if err != nil || len(libs) == 0 { + return data, err + } + for _, lib := range libs { + data.Libraries = append(data.Libraries, templates.LibraryData{ + ID: uuid.UUID(lib.ID.Bytes).String(), + Name: lib.Name, + TypeName: "podcasts", + }) + } + + feeds, err := cfg.Queries.ListMyPodcastFeeds(ctx, user.ID) + if err != nil { + return data, err + } + for i := range feeds { + row := &feeds[i] + total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID) + downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, row.ID) + data.Feeds = append(data.Feeds, templates.PodcastFeedCard{ + ID: uuid.UUID(row.ID.Bytes).String(), + LibraryID: uuid.UUID(row.LibraryID.Bytes).String(), + Title: row.Title, + DisplayName: row.DisplayName.String, + Description: firstLine(row.Description.String, 220), + Host: row.Host.String, + Publisher: row.Publisher.String, + Language: row.Language.String, + Tags: row.Tags, + Explicit: row.Explicit, + ArtworkPath: row.ArtworkPath.String, + FeedType: row.FeedType.String, + Medium: row.Medium.String, + IsComplete: row.IsComplete, + FeedURL: row.FeedUrl, + LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)), + SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)), + EpisodeTotal: int(total), + DownloadedN: int(downloaded), + UndownloadedN: int(total) - int(downloaded), + NewEpisodes: total-downloaded > 0, + AutoDownload: row.AutoDownload, + AutoDownloadCount: int(row.AutoDownloadCount.Int32), + Subscribed: true, + }) + } + + // Facet values from the unfiltered subscription list (D18). + allData := *data + data.Facets = facetValuesFromCards(allData.Feeds) + + // Server-side filters. + q := strings.ToLower(strings.TrimSpace(c.QueryParam("q"))) + host := c.QueryParam("host") + tag := c.QueryParam("tag") + lang := c.QueryParam("language") + filtered := data.Feeds[:0:0] + for _, f := range data.Feeds { + if q != "" { + hay := strings.ToLower(f.Title + " " + f.DisplayName + " " + f.Host + " " + f.Publisher + " " + f.Description) + if !strings.Contains(hay, q) { + continue + } + } + if host != "" && f.Host != host { + continue + } + if lang != "" && !strings.EqualFold(f.Language, lang) { + continue + } + if tag != "" { + found := false + for _, t := range f.Tags { + if strings.EqualFold(t, tag) { + found = true + break + } + } + if !found { + continue + } + } + filtered = append(filtered, f) + } + data.Feeds = filtered + data.Query = q + return data, nil +} + +// buildPodcastDetailData assembles the feed-detail page + episode list +// partial. RLS scopes the lookup (non-subscribers get sqlc.ErrNoRows → +// 404 semantics — no Go visibility plumbing, D20). +func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User) (*templates.PodcastDetailData, error) { + actor := handlers.MustGetAuthenticatedUser(c) + ctx := c.Request().Context() + + feedID, err := parsePodcastUUID(c.Param("id")) + if err != nil { + return nil, err + } + feed, err := cfg.Queries.GetPodcastFeed(ctx, feedID) + if err != nil { + return nil, err + } + sub, _ := cfg.Queries.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{ + UserID: actor.ID, FeedID: feedID, + }) + eps, err := cfg.Queries.ListPodcastEpisodes(ctx, feedID) + if err != nil { + return nil, err + } + progressRows, _ := cfg.Queries.GetPodcastEpisodeProgress(ctx, database.GetPodcastEpisodeProgressParams{ + UserID: actor.ID, SourceFeedID: feedID, + }) + progressByItem := make(map[string]struct { + Pct float64 + LastRead pgtype.Timestamptz + }, len(progressRows)) + for _, p := range progressRows { + progressByItem[uuid.UUID(p.MediaItemID.Bytes).String()] = struct { + Pct float64 + LastRead pgtype.Timestamptz + }{p.Percentage.Float64, p.LastReadAt} + } + + total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, feedID) + downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, feedID) + + card := templates.PodcastFeedCard{ + ID: uuid.UUID(feed.ID.Bytes).String(), + LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(), + Title: feed.Title, + DisplayName: sub.DisplayName.String, + Description: feed.Description.String, + Host: feed.Host.String, + Publisher: feed.Publisher.String, + Language: feed.Language.String, + Tags: feed.Tags, + Explicit: feed.Explicit, + ArtworkPath: feed.ArtworkPath.String, + FeedType: feed.FeedType.String, + Medium: feed.Medium.String, + IsComplete: feed.IsComplete, + FeedURL: feed.FeedUrl, + LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone), + EpisodeTotal: int(total), + DownloadedN: int(downloaded), + UndownloadedN: int(total) - int(downloaded), + NewEpisodes: total-downloaded > 0, + AutoDownload: sub.AutoDownload, + AutoDownloadCount: int(sub.AutoDownloadCount.Int32), + Subscribed: sub.ID.Valid, + } + + rows := make([]templates.PodcastEpisodeRow, 0, len(eps)) + gaps := 0 + seenDownloadedFrom := false // walk reversed: gap = undownloaded with a downloaded LATER in order + for i := len(eps) - 1; i >= 0; i-- { + ep := &eps[i] + downloadedEp := ep.DownloadState == "downloaded" + if !downloadedEp && seenDownloadedFrom { + gaps++ + } + if downloadedEp { + seenDownloadedFrom = true + } + } + // Forward pass for the actual rows (native order — the list keeps + // feed order; the ORDER selector drives the sequence endpoint, and + // the order control re-renders this list via the override applied + // below). + order := podcastOrderForFeed(cfg, c, feedID.String()) + for i := range eps { + ep := &eps[i] + p := progressByItem[uuid.UUID(ep.ID.Bytes).String()] + dateDisplay := "" + if ep.DatePublished.Valid { + dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006") + } + rows = append(rows, templates.PodcastEpisodeRow{ + ID: uuid.UUID(ep.ID.Bytes).String(), + FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(), + Title: ep.Title, + Description: firstLine(ep.Description.String, 200), + Date: dateDisplay, + Position: int(ep.FeedPosition.Int32), + Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)), + Size: templates.FormatEpisodeSize(ep.FileSize.Int64), + PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(), + ProgressPct: p.Pct, + Downloaded: ep.DownloadState == "downloaded", + DownloadState: ep.DownloadState, + DownloadError: ep.DownloadError.String, + }) + } + // Order override reshapes the LIST too (the sequence endpoint is the + // strict downloaded-only view; the list shows everything in the + // chosen order so pending episodes stay visible). + if order == "oldest" || order == "newest" { + sort.SliceStable(rows, func(a, b int) bool { + ka := rows[a].Date + itoaPad(rows[a].Position) + kb := rows[b].Date + itoaPad(rows[b].Position) + if order == "newest" { + return ka > kb + } + return ka < kb + }) + } + + return &templates.PodcastDetailData{ + Feed: card, + Episodes: rows, + GapCount: gaps, + Order: order, + IsAdmin: actor.Role == "admin", + IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook", + }, nil +} + +// podcastOrderForFeed reads the caller's order override (D5). +func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string { + user := handlers.MustGetAuthenticatedUser(c) + row, err := cfg.Queries.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{ + UserID: user.ID, + SettingKey: "podcast_order:" + feedID, + }) + if err != nil { + return "native" + } + var v struct { + Order string `json:"order"` + } + if err := json.Unmarshal(row, &v); err != nil { + return "native" + } + switch v.Order { + case "oldest", "newest": + return v.Order + } + return "native" +} + +// facetValuesFromCards derives the filter bar values. +func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]string { + hosts, tags, langs := map[string]bool{}, map[string]bool{}, map[string]bool{} + for _, f := range feeds { + if f.Host != "" { + hosts[f.Host] = true + } + for _, t := range f.Tags { + tags[t] = true + } + if f.Language != "" { + langs[f.Language] = true + } + } + return map[string][]string{ + "hosts": sortedSet(hosts), + "tags": sortedSet(tags), + "language": sortedSet(langs), + } +} + +func sortedSet(set map[string]bool) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func firstLine(s string, max int) string { + s = strings.TrimSpace(s) + if i := strings.IndexAny(s, "\n\r"); i > 0 { + s = s[:i] + } + if len(s) > max { + s = s[:max] + "…" + } + return s +} + +func pgTimeDisplay(t pgtype.Timestamptz, tz string) string { + if !t.Valid { + return "" + } + return utils.FormatTimeInTimezone(t.Time, tz) +} + +func itoaPad(n int) string { + return fmt.Sprintf("%06d", n) +} + + +// parsePodcastUUID parses an id path param (handlers.parseUUID is +// package-private). +func parsePodcastUUID(s string) (pgtype.UUID, error) { + id, err := uuid.Parse(strings.TrimSpace(s)) + if err != nil { + return pgtype.UUID{}, err + } + return pgtype.UUID{Bytes: id, Valid: true}, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index de27ce9e..a7761695 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -354,6 +354,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { registerReaderRoutes(cfg) registerWebSocketRoutes(cfg) registerFrontendRoutes(cfg) + registerPodcastPages(cfg) registerDocumentationRoutes(cfg) // Static bundles are served unhashed (reader.js, index.js), so the diff --git a/templates/header.templ b/templates/header.templ index 83e5e006..3f2ff19e 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -70,6 +70,12 @@ templ Header(user User, currentPath string) { @Icon("layers", "h-5 w-5 shrink-0") Series + if user.ShowPodcastsNav { + + @Icon("podcast", "h-5 w-5 shrink-0") + Podcasts + + } @Icon("folder", "h-5 w-5 shrink-0") Collections diff --git a/templates/header_templ.go b/templates/header_templ.go index 2bc1f2fc..2c8254ba 100644 --- a/templates/header_templ.go +++ b/templates/header_templ.go @@ -131,42 +131,44 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/collections")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if user.ShowPodcastsNav { + var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/podcasts")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("podcast", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Podcasts ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("folder", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Collections ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var10 = []any{activeClass(currentPath, "/progress")} + var templ_7745c5c3_Var10 = []any{activeClass(currentPath, "/collections")} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Progress ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Collections ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 = []any{activeClass(currentPath, "/stats")} + var templ_7745c5c3_Var12 = []any{activeClass(currentPath, "/progress")} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Stats ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Progress ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 = []any{activeClass(currentPath, "/devices")} + var templ_7745c5c3_Var14 = []any{activeClass(currentPath, "/stats")} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Stats ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 = []any{activeClass(currentPath, "/devices")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = Icon("device", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Devices
Devices
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"shrink-0 px-3 py-3 space-y-2 border-t\" style=\"border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -275,7 +307,7 @@ func Header(user User, currentPath string) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, opt := range ThemeOptions { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "

Bookshelf

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "

Bookshelf

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, w := range WoodOptions { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" style=\"color: var(--text-secondary);\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if w.Name == "none" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 179, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if user.Role == "admin" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "Administration
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 = []any{activeClass(currentPath, "/admin")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("grid", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "Dashboard ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var34 = []any{activeClass(currentPath, "/admin/library")} + var templ_7745c5c3_Var34 = []any{activeClass(currentPath, "/admin")} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "Libraries ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Dashboard ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var36 = []any{activeClass(currentPath, "/admin/hash-conflicts")} + var templ_7745c5c3_Var36 = []any{activeClass(currentPath, "/admin/library")} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Hash Conflicts ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "Libraries ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/users")} + var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/hash-conflicts")} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "Users ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "Hash Conflicts ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/settings")} + var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/users")} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "Users ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 = []any{activeClass(currentPath, "/admin/settings")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "Settings
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "Settings
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -725,20 +757,20 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -763,12 +795,12 @@ func SidebarUserMenu(user User) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var43 := templ.GetChildren(ctx) - if templ_7745c5c3_Var43 == nil { - templ_7745c5c3_Var43 = templ.NopComponent + templ_7745c5c3_Var45 := templ.GetChildren(ctx) + if templ_7745c5c3_Var45 == nil { + templ_7745c5c3_Var45 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -805,7 +837,7 @@ func SidebarUserMenu(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "Profile
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "Logout
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -839,12 +871,12 @@ func SidebarSignIn(currentPath string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var45 := templ.GetChildren(ctx) - if templ_7745c5c3_Var45 == nil { - templ_7745c5c3_Var45 = templ.NopComponent + templ_7745c5c3_Var47 := templ.GetChildren(ctx) + if templ_7745c5c3_Var47 == nil { + templ_7745c5c3_Var47 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if SignupVisible() { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Create an account") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Create an account") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/icons.templ b/templates/icons.templ index b903fc9d..e78eee1a 100644 --- a/templates/icons.templ +++ b/templates/icons.templ @@ -73,6 +73,12 @@ templ Icon(name string, extra string) { case "library": + case "podcast": + + + + + case "layers": diff --git a/templates/icons_templ.go b/templates/icons_templ.go index c889ec57..3badc272 100644 --- a/templates/icons_templ.go +++ b/templates/icons_templ.go @@ -161,208 +161,213 @@ func Icon(name string, extra string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + case "podcast": + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } case "layers": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "folder": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "chart": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "device": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "trash": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "save": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "filter": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "check": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "check-circle": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "x-circle": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "circle": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "bookmark": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "star": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "arrow-right": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "arrow-left": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "home": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "logout": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "plus": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "edit": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "info": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "alert": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "clock": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "sync": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "download": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "upload": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "copy": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "external": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "list": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "grid": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "play": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "heart": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "calendar": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "tag": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "globe": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "shield": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "database": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "headphones": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "comic": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "manga": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } default: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/podcast_detail.templ b/templates/podcast_detail.templ new file mode 100644 index 00000000..04b61e47 --- /dev/null +++ b/templates/podcast_detail.templ @@ -0,0 +1,310 @@ +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). + +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" { + + } + + + + + if data.Feed.AutoDownload == "newest" { + + } + + + + + 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.

+
+
+ } + + + @PodcastEpisodeList(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 }

+ } +

+ { 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" +} + +func downloadButtonTitle(state string) string { + if state == "failed" { + return "Retry the download" + } + return "Download this episode" +} diff --git a/templates/podcast_detail_templ.go b/templates/podcast_detail_templ.go new file mode 100644 index 00000000..ea1b047a --- /dev/null +++ b/templates/podcast_detail_templ.go @@ -0,0 +1,1037 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +// 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). +func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 17, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Header(user, "/podcasts").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Podcasts
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Feed.ArtworkPath != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = Icon("podcast", "h-12 w-12").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.TypeLabel()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 45, Col: 118} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Feed.IsComplete { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "complete ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Feed.Language != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.Language) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 50, Col: 129} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 53, Col: 118} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Feed.Host != "" || data.Feed.Publisher != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Feed.Host != "" { + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("Host: " + data.Feed.Host) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 57, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Feed.Publisher != "" && data.Feed.Publisher != data.Feed.Host { + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(" · Publisher: " + data.Feed.Publisher) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 60, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Feed.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 65, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(data.Feed.Tags) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, t := range data.Feed.Tags { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(t) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 70, Col: 113} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(data.Feed.DownloadedN) + " of " + podcastInt(data.Feed.EpisodeTotal) + " episodes downloaded") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 75, Col: 113} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Feed.LastFetched != "" { + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(" · last checked " + data.Feed.LastFetched) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 77, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.IsSerial && data.Order == "native" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Feed.AutoDownload == "newest" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.IsAdmin { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.GapCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("circle", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 186, Col: 160} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

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

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PodcastEpisodeList(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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. +func PodcastEpisodeList(data PodcastDetailData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var31 := templ.GetChildren(ctx) + if templ_7745c5c3_Var31 == nil { + templ_7745c5c3_Var31 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

Episodes

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Episodes) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
No episodes yet — hit Refresh to check the feed.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ep := range data.Episodes { + templ_7745c5c3_Err = PodcastEpisodeRowView(ep).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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. +func PodcastEpisodeListPolling(data PodcastDetailData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var33 := templ.GetChildren(ctx) + if templ_7745c5c3_Var33 == nil { + templ_7745c5c3_Var33 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "

Episodes

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ep := range data.Episodes { + templ_7745c5c3_Err = PodcastEpisodeRowView(ep).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func PodcastEpisodeRowView(ep PodcastEpisodeRow) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var35 := templ.GetChildren(ctx) + if templ_7745c5c3_Var35 == nil { + templ_7745c5c3_Var35 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(ep.Position)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 246, Col: 28} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 250, Col: 129} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ep.DownloadState == "failed" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 252, Col: 165} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 254, Col: 134} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if ep.DownloadState == "pending" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 256, Col: 133} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ep.DownloadError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(ep.DownloadError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 260, Col: 97} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Date) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 263, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ep.Duration != "" { + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Duration) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 265, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if ep.Size != "" { + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Size) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 268, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ep.Downloaded { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("book-open", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "Play") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 = []any{"btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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" +} + +func downloadButtonTitle(state string) string { + if state == "failed" { + return "Retry the download" + } + return "Download this episode" +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/podcast_types.go b/templates/podcast_types.go new file mode 100644 index 00000000..1cb5154d --- /dev/null +++ b/templates/podcast_types.go @@ -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) +} diff --git a/templates/podcasts.templ b/templates/podcasts.templ new file mode 100644 index 00000000..a9d4ae8a --- /dev/null +++ b/templates/podcasts.templ @@ -0,0 +1,230 @@ +package templates + +import "net/url" + +// PodcastFacetLink links a facet value back into the podcasts page's +// server-side filter (the /bookshelf FacetLink targets the wrong +// surface for podcasts). +templ PodcastFacetLink(facet string, value string) { + { value } +} + +// The /podcasts page (plan task 1.9, D13/D14/D18): Add Feed wizard, +// subscription-scoped search, facet bar, All-Feeds grid. Server-side +// filtering (GET form, like the series page — no JS needed); the grid +// is templ-rendered server data per the rendering-ownership rule. + +templ PodcastsPage(user User, data PodcastsPageData, currentPath string) { + + + + + + Podcasts - Bookhoard + + + + + @Header(user, currentPath) +
+
+
+ + @Icon("podcast", "h-5 w-5") + +
+

Podcasts

+

Your subscriptions

+
+
+ if data.HasLibrary { + + } +
+ + if data.ErrorMsg != "" { +
+ { data.ErrorMsg } +
+ } + + if !data.HasLibrary { +
+

No podcasts library yet

+

An admin creates a Podcasts library (with a download folder) first — then feeds can be added here.

+
+ } else { + @PodcastsFilterBar(data) + if len(data.Feeds) == 0 { +
+

No subscriptions

+

Add an RSS feed to start listening — episodes land here and download on demand.

+
+ } else { + @PodcastsGrid(data.Feeds) + } + } +
+ @PodcastAddFeedModal(data.Libraries) + + +} + +templ PodcastsFilterBar(data PodcastsPageData) { +
+
+ + @Icon("search", "h-4 w-4") + + +
+ +
+ if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 { +
+ Filter: + for _, h := range data.Facets["hosts"] { + @PodcastFacetLink("host", h) + } + for _, t := range data.Facets["tags"] { + @PodcastFacetLink("tag", t) + } + for _, l := range data.Facets["language"] { + @PodcastFacetLink("language", l) + } + if data.Query != "" { + clear + } +
+ } +} + +templ PodcastsGrid(feeds []PodcastFeedCard) { +
+ for _, feed := range feeds { + @PodcastFeedCardView(feed) + } +
+} + +templ PodcastFeedCardView(feed PodcastFeedCard) { + +
+
+ if feed.ArtworkPath != "" { + { + } else { + @Icon("podcast", "h-7 w-7") + } +
+
+

{ feed.DisplayTitle() }

+

+ { feed.TypeLabel() } + if feed.Host != "" { + { " · " + feed.Host } + } +

+
+
+ if feed.Description != "" { +

{ feed.Description }

+ } +
+ + { podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded" } + + if feed.NewEpisodes { + new episodes + } + if feed.IsComplete { + complete + } + if feed.Explicit { + explicit + } +
+
+} + +templ PodcastAddFeedModal(libraries []LibraryData) { + +} diff --git a/templates/podcasts_templ.go b/templates/podcasts_templ.go new file mode 100644 index 00000000..afdb9c46 --- /dev/null +++ b/templates/podcasts_templ.go @@ -0,0 +1,639 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "net/url" + +// PodcastFacetLink links a facet value back into the podcasts page's +// server-side filter (the /bookshelf FacetLink targets the wrong +// surface for podcasts). +func PodcastFacetLink(facet string, value string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(value) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 9, Col: 227} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// The /podcasts page (plan task 1.9, D13/D14/D18): Add Feed wizard, +// subscription-scoped search, facet bar, All-Feeds grid. Server-side +// filtering (GET form, like the series page — no JS needed); the grid +// is templ-rendered server data per the rendering-ownership rule. +func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Podcasts - Bookhoard") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 = []any{"theme-" + user.Theme} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Header(user, currentPath).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("podcast", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Podcasts

Your subscriptions

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HasLibrary { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.ErrorMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.ErrorMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 53, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if !data.HasLibrary { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

No podcasts library yet

An admin creates a Podcasts library (with a download folder) first — then feeds can be added here.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = PodcastsFilterBar(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Feeds) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

No subscriptions

Add an RSS feed to start listening — episodes land here and download on demand.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = PodcastsGrid(data.Feeds).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PodcastAddFeedModal(data.Libraries).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func PodcastsFilterBar(data PodcastsPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
Filter: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, h := range data.Facets["hosts"] { + templ_7745c5c3_Err = PodcastFacetLink("host", h).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, t := range data.Facets["tags"] { + templ_7745c5c3_Err = PodcastFacetLink("tag", t).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, l := range data.Facets["language"] { + templ_7745c5c3_Err = PodcastFacetLink("language", l).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Query != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "clear") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func PodcastsGrid(feeds []PodcastFeedCard) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, feed := range feeds { + templ_7745c5c3_Err = PodcastFeedCardView(feed).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func PodcastFeedCardView(feed PodcastFeedCard) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if feed.ArtworkPath != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = Icon("podcast", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(feed.DisplayTitle()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 145, Col: 111} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(feed.TypeLabel()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 147, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if feed.Host != "" { + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + feed.Host) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 149, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if feed.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(feed.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 155, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 159, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if feed.NewEpisodes { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "new episodes ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if feed.IsComplete { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "complete ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if feed.Explicit { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "explicit") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func PodcastAddFeedModal(libraries []LibraryData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var20 := templ.GetChildren(ctx) + if templ_7745c5c3_Var20 == nil { + templ_7745c5c3_Var20 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

Add Podcast Feed

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(libraries) > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if len(libraries) == 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/types.go b/templates/types.go index 6dcac8ae..98769097 100644 --- a/templates/types.go +++ b/templates/types.go @@ -22,6 +22,10 @@ type User struct { DefaultAudioSpeed float64 // Whether the web reader/player should hold a Screen Wake Lock. KeepScreenAwake bool + // Sidebar /podcasts entry gate (plan D13): the user can see >=1 + // podcasts-type library. Resolved centrally in + // getTemplateUserWithTheme so every page's Header is correct. + ShowPodcastsNav bool } // SpeedOption is one entry of the default-playback-speed select — the diff --git a/web/src/main.ts b/web/src/main.ts index 50fed7ac..a93db44b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -24,6 +24,7 @@ import "./linking"; import "./login"; import "./password_validation"; import "./processing_issues"; +import "./podcasts"; import "./profile"; import "./profile-modal"; import "./register"; diff --git a/web/src/podcasts.ts b/web/src/podcasts.ts new file mode 100644 index 00000000..1e6d39d9 --- /dev/null +++ b/web/src/podcasts.ts @@ -0,0 +1,61 @@ +// Podcasts UI islands (plan task 1.9): the Add-Feed modal toggle and +// the htmx glue the modal needs (error surfacing). All episode-list / +// download behavior is htmx-native (hx-post + podcast:changed trigger + +// server-driven polling) — this module only owns pure client-local +// presentation state, per the rendering-ownership rule. +import { Alpine } from "./alpine"; + +Alpine.data("podcastsPage", () => ({ + showAddFeed(): void { + const modal = document.getElementById("podcast-add-feed-modal"); + if (modal) modal.classList.remove("hidden"); + }, + hideAddFeed(): void { + const modal = document.getElementById("podcast-add-feed-modal"); + if (modal) modal.classList.add("hidden"); + const err = document.getElementById("podcast-add-feed-error"); + if (err) err.classList.add("hidden"); + }, +})); + +// The detail page's actions are pure htmx (hx-post/hx-put/hx-delete + +// HX-Trigger-driven list refresh); Alpine only hosts the x-data the +// body declares so CSP-bound Alpine stays happy. +Alpine.data("podcastDetail", () => ({})); + +// Add-feed form: surface the server's error text inline (the JSON error +// body arrives on non-2xx; htmx swaps nothing). Success redirects via +// HX-Redirect — nothing to do here. +export function initPodcastAddFeedForm(): void { + const form = document.querySelector( + '#podcast-add-feed-modal form[hx-post="/api/podcasts/feeds"], #podcast-add-feed-modal form', + ); + if (!form) return; + form.addEventListener("htmx:afterRequest", (evt) => { + const custom = evt as CustomEvent<{ + successful: boolean; + xhr: XMLHttpRequest; + }>; + if (custom.detail.successful) return; + let message = "Could not add the feed — check the URL and try again."; + try { + const parsed = JSON.parse(custom.detail.xhr.responseText) as { + error?: string; + }; + if (parsed.error) message = parsed.error; + } catch { + // keep the default text + } + const err = document.getElementById("podcast-add-feed-error"); + if (err) { + err.textContent = message; + err.classList.remove("hidden"); + } + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initPodcastAddFeedForm); +} else { + initPodcastAddFeedForm(); +}