diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 098d967d..5744b2a9 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -560,6 +560,211 @@ func TestPodcastPlayerOrderControl(t *testing.T) { }) } +// ---- item 2.3: episode collections — picker scope, hint rows, the +// collection chain (queue=collection:) ---- + +func TestPodcastEpisodeCollections(t *testing.T) { + setup := setupTestServer(t) + regUUID := getRegularUserID(t, setup.DB) + regToken := setup.RegularToken + + lib, _ := createPodcastsLibrary(t, setup, "test EpiColl Podcasts") + marker := fmt.Sprintf("epicoll%d", time.Now().UnixNano()%100000) + + // Feed A: the regular user SUBSCRIBES. Feed B: collected-but-UNSUBSCRIBED + // (the plan's hint-row case). Admin identity seeds both feeds' rows. + feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ + LibraryID: lib.ID, + FeedUrl: "https://feed.invalid/" + marker + "-a.xml", + Title: "EpiColl Feed A " + marker, + }) + require.NoError(t, err) + feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ + LibraryID: lib.ID, + FeedUrl: "https://feed.invalid/" + marker + "-b.xml", + Title: "EpiColl Feed B " + marker, + }) + require.NoError(t, err) + + a0 := upsertTestEpisode(t, setup, feedA, marker+"-a0", "EpiColl A Zero", 1) + a1 := upsertTestEpisode(t, setup, feedA, marker+"-a1", "EpiColl A One", 2) + a3 := upsertTestEpisode(t, setup, feedA, marker+"-a3", "EpiColl A Three", 4) + a2 := upsertTestEpisode(t, setup, feedA, marker+"-a2", "EpiColl A Two Pending", 3) + b1 := upsertTestEpisode(t, setup, feedB, marker+"-b1", "EpiColl B One", 1) + adminScopeExec(t, setup, adminCtxValue(t, setup), + `UPDATE media_items SET download_state = 'downloaded', file_size = 1000 + WHERE id IN ($1, $2, $3, $4) AND source = 'podcast_episode'`, + a0.ID, a1.ID, a3.ID, b1.ID) // a2 stays pending + subscribeRaw(t, setup, regUUID, feedA.ID) + + // The collection (regular user's own) via the real API path. + code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", + `{"name":"test Up Next `+marker+`","description":"episode collection test"}`) + require.Equal(t, http.StatusCreated, code, "%s", string(body)) + var coll struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(body, &coll)) + collID := coll.ID + + // Add episodes through the same endpoint the picker submits — + // book_ids is the key for ANY media id. Includes the unsubscribed + // b1: the insert must succeed; the DETAIL page decides visibility. + code, body = podcastAuthed(t, setup, regToken, "POST", "/api/collections/"+collID+"/books", + `{"book_ids":["`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a1.ID.Bytes).String()+`","`+uuid.UUID(a3.ID.Bytes).String()+`","`+uuid.UUID(a2.ID.Bytes).String()+`","`+uuid.UUID(b1.ID.Bytes).String()+`"]}`) + require.Equal(t, http.StatusNoContent, code, "%s", string(body)) + + // Pin the collection order explicitly (added_at ties are possible — + // same-transaction inserts share NOW(); positions make it + // deterministic). Order: A3, A0, A1 (+ pending A2, invisible B1). + code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/items/order", + `{"item_ids":["`+uuid.UUID(a3.ID.Bytes).String()+`","`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a1.ID.Bytes).String()+`"]}`) + require.Equal(t, http.StatusOK, code, "%s", string(body)) + + // Sequence config rides a data-json ATTRIBUTE — templ escapes the + // quotes ("); pins must match the escaped form. + esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) } + + t.Run("picker_scope_episodes_rls_scoped", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true&scope=episodes", nil) + req.Header.Set("HX-Request", "true") + req.Header.Set("Authorization", "Bearer "+regToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + var buf bytes.Buffer + _, _ = buf.ReadFrom(resp.Body) + html := buf.String() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, html, "EpiColl A Zero", "subscribed feed's episodes appear") + assert.Contains(t, html, "EpiColl Feed A", "rows carry the feed title") + assert.Contains(t, html, "EpiColl A Two Pending", "pending episodes are pickable — the detail page shows them as not downloaded") + assert.Contains(t, html, "not downloaded", "pending picker rows carry the honest badge") + assert.NotContains(t, html, "EpiColl B One", "unsubscribed feed's episodes are RLS-dropped from the picker") + }) + + t.Run("picker_books_scope_still_excludes_podcasts", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true", nil) + req.Header.Set("HX-Request", "true") + req.Header.Set("Authorization", "Bearer "+regToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + var buf bytes.Buffer + _, _ = buf.ReadFrom(resp.Body) + assert.NotContains(t, buf.String(), "EpiColl A Zero", + "the books scope keeps its podcasts exclusion (plan §9) — episodes enter ONLY through the episode scope") + }) + + t.Run("picker_respects_family_lens_hidden_libraries", func(t *testing.T) { + // Invariant-13 auditor case: a SUBSCRIBED episode in a + // family-HIDDEN library still drops — the episode predicate + // ANDs subscription with the family lens. (RLS auditor: + // membership-visible surfaces leak nothing from hidden libs.) + libH, _ := createPodcastsLibrary(t, setup, "test EpiColl Hidden") + feedH, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ + LibraryID: libH.ID, + FeedUrl: "https://feed.invalid/" + marker + "-h.xml", + Title: "EpiColl Feed Hidden " + marker, + }) + require.NoError(t, err) + upsertTestEpisode(t, setup, feedH, marker+"-h1", "EpiColl Hidden One", 1) + subscribeRaw(t, setup, regUUID, feedH.ID) + _, err = setup.DB.SetLibraryVisibility(seedCtx(), database.SetLibraryVisibilityParams{ + UserID: pgtype.UUID{Bytes: regUUID, Valid: true}, + LibraryID: libH.ID, + IsVisible: false, + }) + require.NoError(t, err) + + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true&scope=episodes", nil) + req.Header.Set("HX-Request", "true") + req.Header.Set("Authorization", "Bearer "+regToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + var buf bytes.Buffer + _, _ = buf.ReadFrom(resp.Body) + assert.NotContains(t, buf.String(), "EpiColl Hidden One", + "LEAK: a subscribed episode in a family-hidden library must not reach the picker") + }) + + t.Run("detail_renders_episode_rows_and_unsubscribed_hint", func(t *testing.T) { + // The membership view (All Libraries cookie) shows the full + // collection including hint rows; the library-scoped view keeps + // its existing INNER-JOIN behavior (that's the switcher's + // contract, unchanged for books). + req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil) + req.Header.Set("Authorization", "Bearer "+regToken) + req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"}) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + var buf bytes.Buffer + _, _ = buf.ReadFrom(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode) + html := buf.String() + assert.Contains(t, html, "EpiColl A One", "subscribed downloaded episode renders") + assert.Contains(t, html, "EpiColl Feed A", "episode rows carry the feed title") + assert.Contains(t, html, "Play", "downloaded episodes get the play affordance") + assert.Contains(t, html, "Not downloaded", "pending episode renders the honest state") + assert.Contains(t, html, "Unavailable", "collected-but-unsubscribed episode renders the hint row (LEFT JOIN)") + assert.Contains(t, html, "Remove entry", "hint rows stay removable") + assert.Contains(t, html, "Play as Sequence", "header sequence-start button renders when something can play") + }) + + t.Run("collection_chain_carries_queue_param", func(t *testing.T) { + code, body := podcastAuthed(t, setup, regToken, "GET", + "/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+collID, "") + require.Equal(t, http.StatusOK, code) + html := string(body) + // The collection's pinned order is A3 → A0 → A1; a1 is LAST: + // prev = A0 (second-to-last), no next. + assert.Contains(t, html, esc(`"source":"collection"`)) + assert.Contains(t, html, esc(`"episode_index":3`)) + assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`), + "prev hop carries the queue param — the chain survives") + assert.NotContains(t, html, esc(`"next_url"`), "end of the collection queue") + assert.NotContains(t, html, `id="player-order-select"`, + "the order control is FEED-mode only (§10 R2) — collection order is content") + }) + + t.Run("start_button_plays_from_first_in_order", func(t *testing.T) { + // The header button starts from the FIRST playable episode in + // collection order (a3) — and that page chains forward to a0. + code, body := podcastAuthed(t, setup, regToken, "GET", + "/readers/"+uuid.UUID(a3.ID.Bytes).String()+"?queue=collection:"+collID, "") + require.Equal(t, http.StatusOK, code) + html := string(body) + assert.Contains(t, html, esc(`"episode_index":1`)) + assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`)) + }) + + t.Run("foreign_collection_falls_back_to_feed_queue", func(t *testing.T) { + foreign := uuid.New().String() + code, body := podcastAuthed(t, setup, regToken, "GET", + "/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+foreign, "") + require.Equal(t, http.StatusOK, code) + html := string(body) + assert.Contains(t, html, esc(`"source":"feed"`), "not-owned collection id falls back to the feed queue") + assert.Contains(t, html, `id="player-order-select"`, "feed mode renders its order control") + }) + + t.Run("reorder_changes_the_chain", func(t *testing.T) { + // Reverse to A1 → A0 → A3: the same reader page now chains the + // other way — collection order IS the queue order (R1 content). + code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/items/order", + `{"item_ids":["`+uuid.UUID(a1.ID.Bytes).String()+`","`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a3.ID.Bytes).String()+`"]}`) + require.Equal(t, http.StatusOK, code, "%s", string(body)) + code, body = podcastAuthed(t, setup, regToken, "GET", + "/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+collID, "") + require.Equal(t, http.StatusOK, code) + html := string(body) + assert.Contains(t, html, esc(`"episode_index":1`)) + assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`)) + }) +} + // ---- download worker: state machine, idempotency, file layout ---- func TestPodcastDownloadWorker(t *testing.T) { diff --git a/internal/database/querier.go b/internal/database/querier.go index c0709474..1450e5a5 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -262,11 +262,25 @@ type Querier interface { // rename can't spawn duplicates on the next scan. GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error) GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error) + // The collection queue's downloaded episodes in collection order + // (plan item 2.3 "Play collection as sequence"): position-first, then + // added_at. INNER JOIN — the chain steps only through episodes the + // requester can see (RLS drops unsubscribed feeds) and that are + // downloaded (invariant 6: un-downloaded episodes are never tracked). + GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error) // Get collection items // A2c: position-first ordering (playlist reading order); un-positioned // legacy items keep added_at ASC after the positioned tail — NOT DESC, // which walked a playlist backwards. GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error) + // Episode-collections detail (plan item 2.3): the collection's full + // membership for the SSR detail page, with per-row visibility. LEFT + // JOIN + RLS: rows whose media is invisible to the requester + // (unsubscribed episode — or a family-lens hidden book) survive with + // NULL media columns and visible=false, so the page can render an + // "unavailable" hint instead of silently vanishing them. Rows visible + // but archived/missing-scan keep the old INNER-JOIN behavior (dropped). + GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error) // The excluded filter lives here (was: callers filtered ci.excluded in // Go over a 69-field row + a 45-field converter to MediaItems; embed // returns the real row so neither exists). @@ -762,6 +776,14 @@ type Querier interface { RevokeOpdsToken(ctx context.Context, token string) error RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, error) + // Episode picker scope (plan item 2.3): YOUR subscriptions' episodes. + // The podcasts exclusion in SearchMediaItemsUnified stays (books + // surfaces never list podcasts — plan §9); THIS query is the episode + // picker's dedicated scope. Visibility is the engine's job (D20): the + // media_items policy drops episodes from unsubscribed feeds and the + // podcast_feeds join is governed the same way — non-subscribers see + // nothing here without any Go-side clause. + SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error) SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error) SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error) // Search Media Items queries diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 56c6e99d..270ed699 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -4480,6 +4480,55 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect return i, err } +const GetCollectionEpisodesOrdered = `-- name: GetCollectionEpisodesOrdered :many +SELECT mi.id, mi.title, mi.feed_position, mi.date_published +FROM collection_items ci +JOIN media_items mi ON ci.media_item_id = mi.id +WHERE ci.collection_id = $1 + AND mi.source = 'podcast_episode' + AND mi.download_state = 'downloaded' + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 +ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC +` + +type GetCollectionEpisodesOrderedRow struct { + ID pgtype.UUID `db:"id" json:"id"` + Title string `db:"title" json:"title"` + FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"` + DatePublished pgtype.Date `db:"date_published" json:"date_published"` +} + +// The collection queue's downloaded episodes in collection order +// (plan item 2.3 "Play collection as sequence"): position-first, then +// added_at. INNER JOIN — the chain steps only through episodes the +// requester can see (RLS drops unsubscribed feeds) and that are +// downloaded (invariant 6: un-downloaded episodes are never tracked). +func (q *Queries) GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error) { + rows, err := q.db.Query(ctx, GetCollectionEpisodesOrdered, collectionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetCollectionEpisodesOrderedRow{} + for rows.Next() { + var i GetCollectionEpisodesOrderedRow + if err := rows.Scan( + &i.ID, + &i.Title, + &i.FeedPosition, + &i.DatePublished, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetCollectionItems = `-- name: GetCollectionItems :many SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, ci.excluded, ci.position, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group FROM collection_items ci @@ -4542,6 +4591,73 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU return items, nil } +const GetCollectionItemsDetailed = `-- name: GetCollectionItemsDetailed :many +SELECT ci.media_item_id, ci.position, ci.added_at, + (mi.id IS NOT NULL)::bool AS visible, + mi.title, mi.author, mi.cover_image_path, mi.format_group, + mi.source, mi.download_state, + pf.title AS feed_title +FROM collection_items ci +LEFT JOIN media_items mi ON ci.media_item_id = mi.id +LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id +WHERE ci.collection_id = $1 + AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0)) +ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC +` + +type GetCollectionItemsDetailedRow struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + Position pgtype.Int4 `db:"position" json:"position"` + AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` + Visible bool `db:"visible" json:"visible"` + Title pgtype.Text `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + FormatGroup pgtype.Text `db:"format_group" json:"format_group"` + Source pgtype.Text `db:"source" json:"source"` + DownloadState pgtype.Text `db:"download_state" json:"download_state"` + FeedTitle pgtype.Text `db:"feed_title" json:"feed_title"` +} + +// Episode-collections detail (plan item 2.3): the collection's full +// membership for the SSR detail page, with per-row visibility. LEFT +// JOIN + RLS: rows whose media is invisible to the requester +// (unsubscribed episode — or a family-lens hidden book) survive with +// NULL media columns and visible=false, so the page can render an +// "unavailable" hint instead of silently vanishing them. Rows visible +// but archived/missing-scan keep the old INNER-JOIN behavior (dropped). +func (q *Queries) GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error) { + rows, err := q.db.Query(ctx, GetCollectionItemsDetailed, collectionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetCollectionItemsDetailedRow{} + for rows.Next() { + var i GetCollectionItemsDetailedRow + if err := rows.Scan( + &i.MediaItemID, + &i.Position, + &i.AddedAt, + &i.Visible, + &i.Title, + &i.Author, + &i.CoverImagePath, + &i.FormatGroup, + &i.Source, + &i.DownloadState, + &i.FeedTitle, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, rp.percentage AS viewer_percentage FROM media_items mi INNER JOIN collection_items ci ON ci.media_item_id = mi.id @@ -16478,6 +16594,67 @@ func (q *Queries) SearchAuthorValues(ctx context.Context, arg SearchAuthorValues return items, nil } +const SearchEpisodesForPicker = `-- name: SearchEpisodesForPicker :many +SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id, + mi.format_group, mi.download_state, + pf.title AS feed_title +FROM media_items mi +JOIN podcast_feeds pf ON mi.source_feed_id = pf.id +WHERE mi.source = 'podcast_episode' + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND ($1::text = '' OR mi.title ILIKE '%' || $1::text || '%') +ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST +LIMIT 60 +` + +type SearchEpisodesForPickerRow struct { + ID pgtype.UUID `db:"id" json:"id"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + FormatGroup string `db:"format_group" json:"format_group"` + DownloadState string `db:"download_state" json:"download_state"` + FeedTitle string `db:"feed_title" json:"feed_title"` +} + +// Episode picker scope (plan item 2.3): YOUR subscriptions' episodes. +// The podcasts exclusion in SearchMediaItemsUnified stays (books +// surfaces never list podcasts — plan §9); THIS query is the episode +// picker's dedicated scope. Visibility is the engine's job (D20): the +// media_items policy drops episodes from unsubscribed feeds and the +// podcast_feeds join is governed the same way — non-subscribers see +// nothing here without any Go-side clause. +func (q *Queries) SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error) { + rows, err := q.db.Query(ctx, SearchEpisodesForPicker, q_) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchEpisodesForPickerRow{} + for rows.Next() { + var i SearchEpisodesForPickerRow + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Author, + &i.CoverImagePath, + &i.LibraryID, + &i.FormatGroup, + &i.DownloadState, + &i.FeedTitle, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const SearchGenreValues = `-- name: SearchGenreValues :many SELECT mi.genre as value, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 8b08e8b7..b046a666 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -932,6 +932,26 @@ ORDER BY mi.title ASC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); +-- name: SearchEpisodesForPicker :many +-- Episode picker scope (plan item 2.3): YOUR subscriptions' episodes. +-- The podcasts exclusion in SearchMediaItemsUnified stays (books +-- surfaces never list podcasts — plan §9); THIS query is the episode +-- picker's dedicated scope. Visibility is the engine's job (D20): the +-- media_items policy drops episodes from unsubscribed feeds and the +-- podcast_feeds join is governed the same way — non-subscribers see +-- nothing here without any Go-side clause. +SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id, + mi.format_group, mi.download_state, + pf.title AS feed_title +FROM media_items mi +JOIN podcast_feeds pf ON mi.source_feed_id = pf.id +WHERE mi.source = 'podcast_episode' + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND (sqlc.arg('q')::text = '' OR mi.title ILIKE '%' || sqlc.arg('q')::text || '%') +ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST +LIMIT 60; + -- name: SearchAuthorValues :many SELECT mi.author as value, @@ -3576,6 +3596,42 @@ WHERE ci.collection_id = $1 AND mi.missing_scan_count = 0 ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC; +-- name: GetCollectionItemsDetailed :many +-- Episode-collections detail (plan item 2.3): the collection's full +-- membership for the SSR detail page, with per-row visibility. LEFT +-- JOIN + RLS: rows whose media is invisible to the requester +-- (unsubscribed episode — or a family-lens hidden book) survive with +-- NULL media columns and visible=false, so the page can render an +-- "unavailable" hint instead of silently vanishing them. Rows visible +-- but archived/missing-scan keep the old INNER-JOIN behavior (dropped). +SELECT ci.media_item_id, ci.position, ci.added_at, + (mi.id IS NOT NULL)::bool AS visible, + mi.title, mi.author, mi.cover_image_path, mi.format_group, + mi.source, mi.download_state, + pf.title AS feed_title +FROM collection_items ci +LEFT JOIN media_items mi ON ci.media_item_id = mi.id +LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id +WHERE ci.collection_id = $1 + AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0)) +ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC; + +-- name: GetCollectionEpisodesOrdered :many +-- The collection queue's downloaded episodes in collection order +-- (plan item 2.3 "Play collection as sequence"): position-first, then +-- added_at. INNER JOIN — the chain steps only through episodes the +-- requester can see (RLS drops unsubscribed feeds) and that are +-- downloaded (invariant 6: un-downloaded episodes are never tracked). +SELECT mi.id, mi.title, mi.feed_position, mi.date_published +FROM collection_items ci +JOIN media_items mi ON ci.media_item_id = mi.id +WHERE ci.collection_id = $1 + AND mi.source = 'podcast_episode' + AND mi.download_state = 'downloaded' + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 +ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC; + -- Get collections for book -- name: GetCollectionsForBook :many SELECT c.* diff --git a/internal/handlers/cards.go b/internal/handlers/cards.go index 5cb7d8a9..b20ac44a 100644 --- a/internal/handlers/cards.go +++ b/internal/handlers/cards.go @@ -43,6 +43,18 @@ type BookInfo struct { // it empty rather than pay a join). ViewerPercentage *float64 `json:"viewer_percentage"` LibraryName string `json:"library_name"` + // Episode-collections extras (plan item 2.3): populated only by the + // collection-detail adapter. IsEpisode renders the episode-row + // variant in the grid; FeedTitle disambiguates same-named episodes; + // DownloadState gates the play affordance. Hidden=true means the + // LEFT JOIN missed under RLS (unsubscribed episode, or a + // family-lens hidden book) — the row renders as an "unavailable" + // hint instead of silently vanishing. Adapters that don't load + // visibility leave Hidden false (cards stay fully rendered). + IsEpisode bool `json:"is_episode,omitempty"` + FeedTitle string `json:"feed_title,omitempty"` + DownloadState string `json:"download_state,omitempty"` + Hidden bool `json:"hidden,omitempty"` } // LibraryTypeMap builds libraryID→library-type-name for badge resolution @@ -151,8 +163,7 @@ func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashbo ID: uuid.UUID(mi.ID.Bytes), Title: mi.Title, Author: utils.PgTextString(mi.Author), - LibraryID: mi.LibraryID, - CoverPath: mi.CoverImagePath, + LibraryID: mi.LibraryID, CoverPath: mi.CoverImagePath, FormatGroup: mi.FormatGroup, LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()], })) @@ -180,6 +191,39 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT return cards } +// BookCardsFromCollectionDetailed builds cards from the +// GetCollectionItemsDetailed rows (plan item 2.3): the collection's +// full membership with per-row visibility. RLS-invisible rows +// (unsubscribed episode, family-lens hidden book) become Hidden cards +// — the grid renders them as "unavailable" hint rows; episode rows +// carry feed title + download state for the play affordance. Unknown +// library types resolve no badge; hidden rows need none anyway. +func BookCardsFromCollectionDetailed(rows []database.GetCollectionItemsDetailedRow, libTypes map[string]string) []BookInfo { + cards := make([]BookInfo, 0, len(rows)) + for _, r := range rows { + if !r.Visible { + cards = append(cards, BookInfo{ + MediaItemID: uuid.UUID(r.MediaItemID.Bytes).String(), + Hidden: true, + }) + continue + } + isEpisode := r.Source.String == "podcast_episode" + card := buildBookCard(cardSource{ + ID: uuid.UUID(r.MediaItemID.Bytes), + Title: utils.PgTextString(r.Title), + Author: utils.PgTextString(r.Author), + CoverPath: r.CoverImagePath, + FormatGroup: r.FormatGroup.String, + }) + card.IsEpisode = isEpisode + card.FeedTitle = r.FeedTitle.String + card.DownloadState = r.DownloadState.String + cards = append(cards, card) + } + return cards +} + // BookCardsFromUnified builds cards from search-unified rows. Those // rows carry the library type name as a SQL column, so this adapter // needs no LibraryTypeMap query at all — and the viewer's own diff --git a/internal/router/helpers.go b/internal/router/helpers.go index e6d82df7..c4e2c321 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -331,11 +331,17 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes) } } else { - collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID) + // Episode collections (plan item 2.3): the detailed query + // keeps RLS-invisible rows as Hidden cards (unsubscribed + // episodes render an "unavailable" hint) and tags episode + // rows with feed title + download state. Same position + // ordering; the legacy INNER-JOIN shape remains on the + // library-scoped branch above. + collItems, collErr := cfg.Queries.GetCollectionItemsDetailed(c.Request().Context(), collUUID) if collErr != nil { collItems = nil } - books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes) + books = handlers.BookCardsFromCollectionDetailed(collItems, libTypes) } } return books, isSmart, "" diff --git a/internal/router/reader.go b/internal/router/reader.go index 467fb9e9..c7f2588f 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -10,6 +10,7 @@ import ( "bytes" "encoding/json" "net/http" + "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -166,6 +167,17 @@ func registerReaderRoutes(cfg *Config) { // reads; non-subscribers never reach this path in the first place. func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, libraryType string) *templates.PodcastSequenceContext { ctx := c.Request().Context() + // Queue override (plan item 2.3): ?queue=collection: plays the + // episode inside an Up Next collection chain. Absent/malformed/ + // not-owned ids fall back to the feed queue — the reader page must + // never hard-fail on a stale link. + if q := c.QueryParam("queue"); strings.HasPrefix(q, "collection:") { + if collID, err := uuid.Parse(strings.TrimPrefix(q, "collection:")); err == nil { + if seq := buildCollectionSequenceContext(c, cfg, item, userID, collID); seq != nil { + return seq + } + } + } eps, err := cfg.Queries.ListPodcastEpisodes(ctx, item.SourceFeedID) if err != nil || len(eps) == 0 { return nil @@ -250,3 +262,59 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me } return seq } + +// buildCollectionSequenceContext is the COLLECTION queue producer (plan +// item 2.3, §10 R2: same context shape, different owner). The queue is +// the collection's ordered downloaded episodes — the collection's +// order IS content (R1), so no feed-order override applies here and +// the player's order control stays hidden (source=collection). +// Ownership gates the chain: collections are user-scoped app state +// (no RLS on the table), and a foreign or deleted collection id falls +// back to the feed queue. Prev/next URLs carry ?queue=collection: +// so the chain survives hops; backToDetail lands on the collection. +func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, collID uuid.UUID) *templates.PodcastSequenceContext { + ctx := c.Request().Context() + owns, err := cfg.CollectionHandler.CollectionService().UserOwnsCollection(ctx, collID, uuid.UUID(userID.Bytes)) + if err != nil || !owns { + return nil + } + coll, err := cfg.Queries.GetCollection(ctx, pgtype.UUID{Bytes: collID, Valid: true}) + if err != nil { + return nil + } + // Downloaded episodes in collection order (SQL orders; RLS scopes + // the join — unsubscribed feeds drop out and the chain skips them). + eps, err := cfg.Queries.GetCollectionEpisodesOrdered(ctx, pgtype.UUID{Bytes: collID, Valid: true}) + if err != nil || len(eps) == 0 { + return nil + } + + currentID := uuid.UUID(item.ID.Bytes).String() + idx := -1 + for i, ep := range eps { + if uuid.UUID(ep.ID.Bytes).String() == currentID { + idx = i + break + } + } + if idx < 0 { + return nil // current episode not downloaded (or not in sequence) + } + + queueParam := "?queue=collection:" + collID.String() + seq := &templates.PodcastSequenceContext{ + FeedURL: "/collections/" + collID.String(), + FeedTitle: coll.Name, + Source: "collection", + EpisodeIndex: idx + 1, + IndexTotal: len(eps), + } + if idx > 0 { + seq.PrevURL = "/readers/" + uuid.UUID(eps[idx-1].ID.Bytes).String() + queueParam + } + if idx < len(eps)-1 { + seq.NextURL = "/readers/" + uuid.UUID(eps[idx+1].ID.Bytes).String() + queueParam + seq.NextTitle = eps[idx+1].Title + } + return seq +} diff --git a/internal/router/search.go b/internal/router/search.go index e36b73bf..0a0664fb 100644 --- a/internal/router/search.go +++ b/internal/router/search.go @@ -41,6 +41,24 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error { if !ok { return c.String(http.StatusUnauthorized, "Unauthorized") } + + // Episode picker scope (plan item 2.3): ?scope=episodes&show_checkbox=true + // swaps the book cards for YOUR subscriptions' episodes — visibility + // is the RLS policy's job (D20), the Go side adds nothing. The books + // branch keeps its podcasts exclusion; this is the only door in. + if c.QueryParam("scope") == "episodes" && c.QueryParam("show_checkbox") == "true" { + eps, err := cfg.Queries.SearchEpisodesForPicker(c.Request().Context(), c.QueryParam("q")) + if err != nil { + log.Printf("Episode picker search error: %v", err) + return c.HTML(http.StatusInternalServerError, `
Search error
`) + } + var buf bytes.Buffer + if err := templates.EpisodePickerGrid(eps).Render(c.Request().Context(), &buf); err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + } + // Extract filters via the SHARED parser (handlers.ParseMediaSearchParams) // — the HTMX grid partial and the JSON endpoint can never disagree on // which facet params exist or how they parse. (This branch previously diff --git a/templates/collections.templ b/templates/collections.templ index 0aea7989..df6c1248 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -3,6 +3,7 @@ package templates import ( "fmt" + "bookhoard/internal/database" "bookhoard/internal/handlers" ) @@ -14,6 +15,67 @@ func pluralizeBooks(n int) string { return "books" } +// firstPlayableEpisodeURL returns the reader URL that starts the +// collection sequence from the first downloaded episode (plan item 2.3 +// "Play collection as sequence" — the queue param makes the reader +// build the collection chain), or "" when nothing can play. +func firstPlayableEpisodeURL(collectionID string, books []handlers.BookInfo) string { + for _, b := range books { + if b.IsEpisode && !b.Hidden && b.DownloadState == "downloaded" { + return "/readers/" + b.MediaItemID + "?queue=collection:" + collectionID + } + } + return "" +} + +// PlayCollectionButton renders the header's sequence-start button when +// the collection holds at least one downloaded episode. +templ PlayCollectionButton(collectionID string, books []handlers.BookInfo) { + if url := firstPlayableEpisodeURL(collectionID, books); url != "" { + + ▶ + Play as Sequence + + } +} + +// EpisodePickerGrid renders the episode scope of the collection picker +// (plan item 2.3): YOUR subscriptions' episodes (RLS scopes), compact +// rows with the shared bookPicker selection store. Checksum ✓ overlay +// mirrors BookPickerGrid so selection survives htmx swaps. +templ EpisodePickerGrid(eps []database.SearchEpisodesForPickerRow) { + for _, ep := range eps { +
+
🎧
+
+

{ ep.Title }

+

{ ep.FeedTitle }

+
+ if ep.DownloadState != "downloaded" { + not downloaded + } +
+ ✓ +
+
+ } + if len(eps) == 0 { +

No episodes from your subscriptions match.

+ } +} + // CollectionBooksGrid renders the content of #books-container: the // collection's book cards, or the empty state. Shared by the collection // detail page and the /collections/:id/books-grid partial route (the @@ -21,9 +83,90 @@ func pluralizeBooks(n int) string { // templ renders, JS only swaps). templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) { if len(books) == 0 { -
No books in this collection yet.
+
Nothing in this collection yet.
} for _, book := range books { + if book.Hidden { + // RLS-invisible membership (plan item 2.3): an + // unsubscribed episode — or a family-lens hidden + // book. The entry stays in the list (removable); + // its facts are withheld by the engine, not the UI. +
+
+
◌
+
+

Unavailable

+

Not subscribed (or not visible to you) — the feed's episodes leave the queue until you follow it again.

+
+ if !collection.IsSystem && !collection.IsSmart { + + } +
+
+ } else if book.IsEpisode { + // Episode card (plan item 2.3): play resumes the + // collection chain — ?queue=collection: makes + // the reader feed prev/next from the collection's + // order (Play collection as sequence). +
+
+ if !collection.IsSystem && !collection.IsSmart { +
+ +
+ + +
+
+ } +
+

+ { book.Title } +

+

+ 🎧 { book.FeedTitle } +

+ if book.DownloadState == "downloaded" { + + ▶ + Play + + } else { + Not downloaded + } +
+
+ if !collection.IsSystem && !collection.IsSmart { +
+ +
+ } +
+ } else {
if !collection.IsSystem && !collection.IsSmart { @@ -87,6 +230,7 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo)
}
+ } } } @@ -241,10 +385,11 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
-

Books in this Collection

+

Items in this Collection

+ @PlayCollectionButton(collection.ID, books)
@Icon("plus", "h-4 w-4") - Add Books + Add Items } } @@ -313,7 +458,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo style="border-color: var(--border);" >

- Add Books to Collection + Add Items to Collection

+ +
+ + +
@@ -383,12 +549,46 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo hx-include="#book-picker-filters" >
+ +
+
+
+ +
+
+
+
+
- books selected + selected
diff --git a/templates/collections_templ.go b/templates/collections_templ.go index 609d87a4..dfa26f1a 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -11,6 +11,7 @@ import templruntime "github.com/a-h/templ/runtime" import ( "fmt" + "bookhoard/internal/database" "bookhoard/internal/handlers" ) @@ -22,6 +23,168 @@ func pluralizeBooks(n int) string { return "books" } +// firstPlayableEpisodeURL returns the reader URL that starts the +// collection sequence from the first downloaded episode (plan item 2.3 +// "Play collection as sequence" — the queue param makes the reader +// build the collection chain), or "" when nothing can play. +func firstPlayableEpisodeURL(collectionID string, books []handlers.BookInfo) string { + for _, b := range books { + if b.IsEpisode && !b.Hidden && b.DownloadState == "downloaded" { + return "/readers/" + b.MediaItemID + "?queue=collection:" + collectionID + } + } + return "" +} + +// PlayCollectionButton renders the header's sequence-start button when +// the collection holds at least one downloaded episode. +func PlayCollectionButton(collectionID string, books []handlers.BookInfo) 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) + if url := firstPlayableEpisodeURL(collectionID, books); url != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "▶ Play as Sequence") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// EpisodePickerGrid renders the episode scope of the collection picker +// (plan item 2.3): YOUR subscriptions' episodes (RLS scopes), compact +// rows with the shared bookPicker selection store. Checksum ✓ overlay +// mirrors BookPickerGrid so selection survives htmx swaps. +func EpisodePickerGrid(eps []database.SearchEpisodesForPickerRow) 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_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + for _, ep := range eps { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
🎧

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 59, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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 + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ep.FeedTitle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 60, Col: 83} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ep.DownloadState != "downloaded" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "not downloaded") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
✓
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(eps) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

No episodes from your subscriptions match.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + // CollectionBooksGrid renders the content of #books-container: the // collection's book cards, or the empty state. Shared by the collection // detail page and the /collections/:id/books-grid partial route (the @@ -43,243 +206,472 @@ func CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) t }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if len(books) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
No books in this collection yet.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
Nothing in this collection yet.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } for _, book := range books { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">
") - 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 - } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 50, Col: 23} - } - _, 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, 13, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.Author != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

by ") + } else if book.IsEpisode { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-media-id=\"") 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 book.CoverImagePath != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\">") + if !collection.IsSystem && !collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + 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_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 139, Col: 23} + } + _, 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, 27, "

🎧 ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.FeedTitle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 142, Col: 32} + } + _, 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, 28, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.DownloadState == "downloaded" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
▶ Play") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Not downloaded") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !collection.IsSystem && !collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + 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 } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"Cover\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 170, Col: 147} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) + 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 !collection.IsSystem && !collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + 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 + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 193, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

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

by ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 198, Col: 27} + } + _, 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, 50, "

") + 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 !collection.IsSystem && !collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") 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 @@ -302,34 +694,34 @@ func Collection(user User, collections []CollectionData, errorMessage string) te }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var15 := templ.GetChildren(ctx) - if templ_7745c5c3_Var15 == nil { - templ_7745c5c3_Var15 = templ.NopComponent + templ_7745c5c3_Var34 := templ.GetChildren(ctx) + if templ_7745c5c3_Var34 == nil { + templ_7745c5c3_Var34 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Collections - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Collections - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 = []any{"theme-" + user.Theme} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...) + var templ_7745c5c3_Var35 = []any{"theme-" + user.Theme} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -337,7 +729,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -345,7 +737,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

My Collections

Organize your books into custom collections

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "New Collection
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(collections) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -374,7 +766,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

No Collections Yet

Create collections to organize your books

No Collections Yet

Create collections to organize your books

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Create Your First Collection
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } for _, col := range collections { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 164, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 308, Col: 41} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + _, 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, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if col.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "⚡ Smart ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "⚡ Smart ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 189, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 333, Col: 92} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + _, 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, 46, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 190, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 334, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + _, 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, 47, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount))) + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 103} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 336, Col: 103} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + _, 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, 48, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if col.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "· rules-matched live") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "· rules-matched live") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -541,7 +933,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te 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, 87, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -565,47 +957,47 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var26 := templ.GetChildren(ctx) - if templ_7745c5c3_Var26 == nil { - templ_7745c5c3_Var26 = 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, 53, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 213, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 357, Col: 27} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var28 = []any{"theme-" + user.Theme} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...) + var templ_7745c5c3_Var47 = []any{"theme-" + user.Theme} + 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, 55, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -617,7 +1009,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -625,97 +1017,105 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "Back to Collections
Back to Collections
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 227, Col: 169} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 371, Col: 169} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 230, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 374, Col: 25} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "⚡ Smart") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "⚡ Smart") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 235, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 379, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) 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, 99, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

Membership is rules-matched live — always current, nothing to refresh.

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

Membership is rules-matched live — always current, nothing to refresh.

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

Books in this Collection

0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

Items in this Collection

0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\">
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PlayCollectionButton(collection.ID, books).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSystem { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" class=\"btn btn-secondary\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -723,12 +1123,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "Edit Rules ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Edit Rules ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "Add Items") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -758,12 +1158,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !collection.IsSystem && !collection.IsSmart { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "

Add Books to Collection

Clear
books selected
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" hx-trigger=\"loadBooks\" hx-include=\"#book-picker-filters\">
selected
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

Remove from Collection

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" style=\"display: none;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -920,119 +1346,119 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var43 := templ.GetChildren(ctx) - if templ_7745c5c3_Var43 == nil { - templ_7745c5c3_Var43 = templ.NopComponent + templ_7745c5c3_Var64 := templ.GetChildren(ctx) + if templ_7745c5c3_Var64 == nil { + templ_7745c5c3_Var64 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, book := range books { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.CoverImagePath != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
✓

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\" class=\"absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none\" style=\"background-color: var(--accent); color: var(--bg-primary);\">✓

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + var templ_7745c5c3_Var71 string + templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 470, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 670, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/bookPicker.ts b/web/src/bookPicker.ts index eeaf7428..caf78052 100644 --- a/web/src/bookPicker.ts +++ b/web/src/bookPicker.ts @@ -9,10 +9,15 @@ import { showToast } from "./toast"; Alpine.store("bookPicker", { isOpen: false, selectedBooks: new Set(), + // Picker scope (plan item 2.3): "books" (default) or "episodes" — + // YOUR subscriptions' episodes. Pure client-local tab state; the + // selection store is shared, ids never collide. + scope: "books", open() { this.isOpen = true; this.selectedBooks.clear(); + this.scope = "books"; this.loadBooks(); }, @@ -21,6 +26,15 @@ Alpine.store("bookPicker", { this.selectedBooks.clear(); }, + switchScope(scope: "books" | "episodes") { + this.scope = scope; + if (scope === "episodes") { + this.loadEpisodes(); + } else { + this.loadBooks(); + } + }, + toggleBook(bookId: string) { if (this.selectedBooks.has(bookId)) { this.selectedBooks.delete(bookId); @@ -47,6 +61,12 @@ Alpine.store("bookPicker", { window.htmx.trigger(grid, "loadBooks"); }, + async loadEpisodes() { + const grid = document.getElementById("episode-picker-grid"); + if (!grid) return; + window.htmx.trigger(grid, "loadEpisodes"); + }, + clearFilters() { const filterDiv = document.getElementById("book-picker-filters"); if (!filterDiv) return; @@ -88,11 +108,11 @@ Alpine.store("bookPicker", { }); if (response.ok) { - showToast(`Added ${this.selectedBooks.size} books to collection`, "success"); + showToast(`Added ${this.selectedBooks.size} items to collection`, "success"); this.close(); location.reload(); } else { - showToast("Failed to add books", "error"); + showToast("Failed to add items", "error"); } } catch (error) { console.error("Failed to add books:", error);