package templates import ( "fmt" "bookhoard/internal/database" "bookhoard/internal/handlers" ) // pluralizeBooks renders "book"/"books" for count displays. func pluralizeBooks(n int) string { if n == 1 { return "book" } 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 — alongside the // collection's CURATED listening order select (plan §10 R2: the chain // order lives on the collection, honored by the chain producer; the // player shows no order control for collection sources). templ PlayCollectionButton(collectionID string, chainOrder 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 // library switcher refreshes the grid through it — rendering ownership: // templ renders, JS only swaps). templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) { if len(books) == 0 {
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 {
}

{ book.Title }

if book.Author != "" {

by { book.Author }

}
if !collection.IsSystem && !collection.IsSmart {
}
} } } templ Collection(user User, collections []CollectionData, errorMessage string) { Collections - Bookhoard @Header(user, "/collections")
@Icon("folder", "h-5 w-5")

My Collections

Organize your books into custom collections

if len(collections) == 0 {
@Icon("folder", "h-7 w-7")

No Collections Yet

Create collections to organize your books

} for _, col := range collections {
{ col.Icon }
if col.IsSmart { ⚡ Smart }

{ col.Name }

{ col.Description }

{ fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)) } if col.IsSmart { · rules-matched live }

}
@ErrorToast(errorMessage) } templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string, libData []LibraryData) { { collection.Name } - Bookhoard @Header(user, "/collections") @LibrarySwitcher(libData, libraryID, "", nil)
@Icon("arrow-left", "h-4 w-4") Back to Collections
{ collection.Icon }

{ collection.Name } if collection.IsSmart { ⚡ Smart }

{ collection.Description }

if collection.IsSmart {

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

}

Items in this Collection

@PlayCollectionButton(collection.ID, collection.ChainOrder, books)
if !collection.IsSystem { @Icon("filter", "h-4 w-4") Edit Rules if !collection.IsSmart { } }
@CollectionBooksGrid(collection, books)
if !collection.IsSystem && !collection.IsSmart { } } templ BookPickerGrid(books []handlers.BookInfo) { for _, book := range books {
if book.CoverImagePath != "" { { } else { { }
✓

{ book.Title }

} }