feat(podcasts): episode collections — picker scope, hint rows, the chain (item 2.3)
D11 lifts: podcast episodes (media_items rows) join collections, and "Play collection as sequence" feeds the player from the collection's order. No new tables — collection_items already carries ordered membership (item 2.1 audit). PICKER: the collection modal gains a Books/Episodes tab pair. The episodes tab hits /api/media-items/search?scope=episodes — a dedicated picker query (SearchEpisodesForPicker) whose visibility is entirely RLS: the media_items subscription predicate drops unsubscribed feeds' episodes, and the podcast_feeds join is governed the same way. The books scope keeps its podcasts exclusion (plan §9) — this scope is the only door in. Pending episodes are pickable (plan ahead; the detail page shows them as not downloaded). Selection rides the existing bookPicker store — ids never collide across scopes, and AddBooks works unchanged on episode ids. DETAIL: the All-Libraries membership view switches to GetCollectionItemsDetailed — LEFT JOIN + per-row visibility. An unsubscribed episode (or family-lens hidden book) survives as a Hidden card rendered as an "Unavailable — remove entry" hint row instead of silently vanishing; visible episodes render feed title, download state, and a Play link. The library-scoped view keeps its existing dashboard-query behavior (the switcher's contract is untouched for books). CHAIN: ?queue=collection:<id> on any reader URL makes buildPodcastSequenceContext produce the COLLECTION context (§10 R2 same shape, source=collection): downloaded episodes in collection order (positions are content — R1; no feed-order override), prev/next URLs carrying the queue param so the chain survives hops, and backToDetail landing on the collection page. Ownership gates the chain — foreign/deleted ids fall back to the feed queue; the order control stays hidden (2.2's source gate). The header's "Play as Sequence" button starts from the first downloaded episode in list order. Pinned by TestPodcastEpisodeCollections (7 subtests): RLS-scoped picker + family-lens auditor case (subscribed episode in a hidden library must not reach the picker), books-scope exclusion intact, hint rows, chain carrying the queue param, start-from-first, foreign fallback, and reorder driving the chain (the R4 primitive works on episode ids unchanged).
This commit is contained in:
1 parent
a90e7debfd
commit
adb93cba9e
11 files changed
+1636
-394
No files matched your search
+206
-6
@@ -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 != "" {
|
||||
<a
|
||||
href={ url }
|
||||
class="btn btn-primary"
|
||||
title="Play the collection's episodes back-to-back in list order"
|
||||
>
|
||||
<span>▶</span>
|
||||
<span>Play as Sequence</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
<div
|
||||
class="relative cursor-pointer rounded-lg border p-3 flex items-center gap-3"
|
||||
style="border-color: var(--border);"
|
||||
@click={ "$store.bookPicker.toggleBook('" + ep.ID.String() + "')" }
|
||||
>
|
||||
<div class="w-9 h-9 rounded-md grid place-items-center shrink-0 text-lg" style="background-color: var(--surface-hover);">🎧</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm truncate" style="color: var(--text-primary)">{ ep.Title }</p>
|
||||
<p class="text-xs truncate" style="color: var(--text-secondary)">{ ep.FeedTitle }</p>
|
||||
</div>
|
||||
if ep.DownloadState != "downloaded" {
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" style="background-color: var(--surface-hover); color: var(--text-secondary)">not downloaded</span>
|
||||
}
|
||||
<div
|
||||
x-show={ "$store.bookPicker.isSelected('" + ep.ID.String() + "')" }
|
||||
class="absolute top-2 right-2 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);"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
if len(eps) == 0 {
|
||||
<p class="text-sm text-center py-8" style="color: var(--text-secondary)">No episodes from your subscriptions match.</p>
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">Nothing in this collection yet.</div>
|
||||
}
|
||||
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.
|
||||
<div class="card p-4 rounded-2xl opacity-75" data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="w-10 h-10 rounded-lg grid place-items-center shrink-0 text-lg" style="background-color: var(--surface-hover); color: var(--text-secondary)">◌</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-lg" style="color: var(--text-secondary)">Unavailable</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Not subscribed (or not visible to you) — the feed's episodes leave the queue until you follow it again.</p>
|
||||
</div>
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<button
|
||||
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove entry</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} else if book.IsEpisode {
|
||||
// Episode card (plan item 2.3): play resumes the
|
||||
// collection chain — ?queue=collection:<id> makes
|
||||
// the reader feed prev/next from the collection's
|
||||
// order (Play collection as sequence).
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4">
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-5 h-5"
|
||||
:checked={ "selectedBooks.includes(" + JSONArg(book.MediaItemID) + ")" }
|
||||
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-col items-center gap-0.5 mt-1">
|
||||
<button type="button" class="icon-btn text-xs" title="Move earlier (playlist order)"
|
||||
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", -1)" }>↑</button>
|
||||
<button type="button" class="icon-btn text-xs" title="Move later (playlist order)"
|
||||
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", 1)" }>↓</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-lg mb-0.5 line-clamp-2" style="color: var(--text-primary)">
|
||||
{ book.Title }
|
||||
</h3>
|
||||
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
|
||||
🎧 { book.FeedTitle }
|
||||
</p>
|
||||
if book.DownloadState == "downloaded" {
|
||||
<a
|
||||
href={ "/readers/" + book.MediaItemID + "?queue=collection:" + collection.ID }
|
||||
class="btn btn-primary text-sm mt-2"
|
||||
>
|
||||
<span>▶</span>
|
||||
<span>Play</span>
|
||||
</a>
|
||||
} else {
|
||||
<span class="badge text-xs mt-2 inline-block" style="background-color: var(--surface-hover); color: var(--text-secondary)">Not downloaded</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove from Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4">
|
||||
if !collection.IsSystem && !collection.IsSmart {
|
||||
@@ -87,6 +230,7 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,10 +385,11 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</div>
|
||||
<div class="mb-6 flex flex-wrap justify-between items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Books in this Collection</h2>
|
||||
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Items in this Collection</h2>
|
||||
<span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
@PlayCollectionButton(collection.ID, books)
|
||||
<div class="flex-1 min-w-[200px] max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
@@ -277,7 +422,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("plus", "h-4 w-4")
|
||||
<span>Add Books</span>
|
||||
<span>Add Items</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -313,7 +458,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
style="border-color: var(--border);"
|
||||
>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">
|
||||
Add Books to Collection
|
||||
Add Items to Collection
|
||||
</h2>
|
||||
<button
|
||||
@click="$store.bookPicker.close()"
|
||||
@@ -323,9 +468,30 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<!-- Scope tabs (plan item 2.3): books stay the default; the
|
||||
episodes tab is YOUR subscriptions' episodes (RLS scopes).
|
||||
Pure client-local state — the tab click triggers the
|
||||
hidden grid's htmx load. -->
|
||||
<div class="flex gap-2 px-6 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$store.bookPicker.switchScope('books')"
|
||||
:class="$store.bookPicker.scope === 'episodes' ? 'btn btn-ghost' : 'btn btn-secondary'"
|
||||
>
|
||||
📚 Books
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="$store.bookPicker.switchScope('episodes')"
|
||||
:class="$store.bookPicker.scope === 'episodes' ? 'btn btn-secondary' : 'btn btn-ghost'"
|
||||
>
|
||||
🎧 Episodes
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 border-b"
|
||||
style="border-color: var(--border);"
|
||||
x-show="$store.bookPicker.scope !== 'episodes'"
|
||||
>
|
||||
<div id="book-picker-filters" class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
@@ -383,12 +549,46 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
hx-include="#book-picker-filters"
|
||||
>
|
||||
</div>
|
||||
<!-- Episode scope (plan item 2.3): one search field — the
|
||||
episode list is feed-ordered, facets make no sense here.
|
||||
RLS scopes to YOUR subscriptions server-side. -->
|
||||
<div
|
||||
class="p-4 border-b"
|
||||
style="border-color: var(--border);"
|
||||
x-show="$store.bookPicker.scope === 'episodes'"
|
||||
x-cloak
|
||||
>
|
||||
<div id="episode-picker-filters" class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder="Search episodes..."
|
||||
class="input"
|
||||
hx-get={ "/api/media-items/search?show_checkbox=true&scope=episodes&collection_id=" + collection.ID }
|
||||
hx-target="#episode-picker-grid"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-include="#episode-picker-filters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="episode-picker-grid"
|
||||
class="flex flex-col gap-2 p-4 max-h-96 overflow-y-auto"
|
||||
x-show="$store.bookPicker.scope === 'episodes'"
|
||||
x-cloak
|
||||
hx-get={ "/api/media-items/search?show_checkbox=true&scope=episodes&collection_id=" + collection.ID }
|
||||
hx-trigger="loadEpisodes"
|
||||
hx-include="#episode-picker-filters"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 border-t flex justify-between items-center"
|
||||
style="border-color: var(--border);"
|
||||
>
|
||||
<div class="text-sm" style="color: var(--text-secondary);">
|
||||
<span x-text="$store.bookPicker.selectedCount"></span> books selected
|
||||
<span x-text="$store.bookPicker.selectedCount"></span> selected
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -401,7 +601,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
@click="$store.bookPicker.submit()"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Add Selected Books
|
||||
Add Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+808
-382
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user