Files
bookhoard/templates/collections.templ
T
John O'Keefe cc0e9d7f91 feat(podcasts): the episodes authoring door + curated chain order
"Newest episodes from my selected podcasts" is now buildable in the UI,
end to end — the user could not find ANY podcast-related filter surface
because none existed: the New Collection modal could only create
book-scope smart collections, and the engine's episodes scope (item 3.1)
had no authoring door.

The scope door:
- The create modal's Smart card gains a Rules-scope segmented control
  (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart
  means "match books by rules". Choosing Episodes seeds an EMPTY
  episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the
  collection is born episode-scoped: the editor opens with the episode
  vocabulary (feed/downloaded/played/published date) and membership
  runs the episode engine from the first rule.
- ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay
  invalid) — the seed's legal shape. The root arrives at depth 1 by
  call convention, hence the depth>1 exemption.
- The composers define the empty-tree semantics: zero children matches
  NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring
  EvaluateSmartCollection's zero-enabled-rules convention. Without the
  guard the composed query degrades to the bare scope head and a fresh
  collection would swallow every episode in scope — pinned by
  TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0)
  and TestEmptyRootTreeSeedsEpisodesScope.

The curated chain order (plan §10 R2, user need: the briefing plays
NEWEST-first):
- view_settings.chain_order ("native"/"newest"/"oldest"/"reverse")
  stored on the collection — curated content, not a player-side knob.
- PUT /api/collections/:id/view-settings merges the key into the
  stored JSONB (a blind write would clobber the playlist toggles).
- buildSmartCollectionSequenceContext routes OrderEpisodes through
  handlers.CollectionChainOrder; the detail header's Play-as-Sequence
  gains the order select next to it (PlayCollectionButton), which
  PUTs and reloads. Pinned by chain_follows_the_curated_order
  (reverse flips the chain indexes and hops).

Caught by the new test, a latent clobber fixed: UpdateCollection
treated a NIL view_settings payload as "write NULL" — any edit that
carried no settings dropped every stored key (chain_order, playlist
toggles from API clients, imported provenance). Nil now means
untouched, the same convention filterTree already had.

Composer guards also cover ComposedCount, so the builder's live count
reads the same semantics. Tests: collectionChainOrder unit matrix,
empty-root parse unit pin, two integration pins (seed matches nothing;
curated order drives the chain and survives whole-map edits).
2026-10-31 13:39:24 -04:00

691 lines
26 KiB
Templ

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 != "" {
<div class="flex items-center gap-2">
<a
href={ url }
class="btn btn-primary"
title="Play the collection's episodes back-to-back"
>
<span>▶</span>
<span>Play as Sequence</span>
</a>
<select
id="chain-order-select"
class="input !w-auto"
aria-label="Listening order"
title="The order the sequence plays in — stored on this collection"
@change={ "setChainOrder('" + collectionID + "', $event.target.value)" }
>
<option value="native" selected?={ chainOrder == "" || chainOrder == "native" }>Feed order</option>
<option value="reverse" selected?={ chainOrder == "reverse" }>Feed order (reversed)</option>
<option value="oldest" selected?={ chainOrder == "oldest" }>Oldest first (pub date)</option>
<option value="newest" selected?={ chainOrder == "newest" }>Newest first (pub date)</option>
</select>
</div>
}
}
// 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
// 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 {
<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 {
<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">
<a href={ "/media/" + book.MediaItemID }>
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
{ book.Title }
</h3>
</a>
if book.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
by { book.Author }
</p>
}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href={ "/media/" + book.MediaItemID }>
if book.CoverImagePath != "" {
<img
src={ book.CoverImagePath }
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
}
</a>
</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>
}
}
}
templ Collection(user User, collections []CollectionData, errorMessage string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Collections - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
@Header(user, "/collections")
<div id="modal-container"></div>
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8 flex flex-wrap justify-between items-center gap-4">
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("folder", "h-5 w-5")
</span>
<div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">My Collections</h1>
<p class="text-sm" style="color: var(--text-secondary)">Organize your books into custom collections</p>
</div>
</div>
<div class="flex gap-2">
<button
hx-get="/collections/restore-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn btn-secondary"
>
@Icon("refresh", "h-4 w-4")
<span>Restore System</span>
</button>
<button
hx-get="/collections/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn btn-primary"
>
@Icon("plus", "h-4 w-4")
<span>New Collection</span>
</button>
</div>
</div>
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
if len(collections) == 0 {
<div class="card text-center py-16 px-6 col-span-full">
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("folder", "h-7 w-7")
</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
<p class="mb-5" style="color: var(--text-secondary)">Create collections to organize your books</p>
<button
hx-get="/collections/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn btn-primary"
>
@Icon("plus", "h-4 w-4")
<span>Create Your First Collection</span>
</button>
</div>
}
for _, col := range collections {
<div @click="navigateToCollection($el, $event)" data-href={ "/collections/" + col.ID } class="block">
<div
class="card p-6 rounded-2xl border-l-4 cursor-pointer"
data-color={ col.Color }
>
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">{ col.Icon }</div>
<div class="flex gap-1 items-center">
if col.IsSmart {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
}
<button
hx-get={ "/collections/" + col.ID + "/edit-modal" }
hx-target="#modal-container"
hx-swap="innerHTML"
class="icon-btn"
aria-label="Edit collection"
>
@Icon("edit", "h-4 w-4")
</button>
<button
hx-delete={ "/api/collections/" + col.ID + "" }
hx-redirect="/collections"
hx-confirm="Are you sure you want to delete this collection?"
class="icon-btn"
aria-label="Delete collection"
>
@Icon("trash", "h-4 w-4")
</button>
</div>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
<p class="text-sm" style="color: var(--text-secondary)">{ col.Description }</p>
<p class="text-xs mt-3 flex items-center gap-1" style="color: var(--text-secondary);">
<span class="font-medium">{ fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)) }</span>
if col.IsSmart {
<span>· rules-matched live</span>
}
</p>
</div>
</div>
}
</div>
</div>
@ErrorToast(errorMessage)
</body>
</html>
}
templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string, libData []LibraryData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ collection.Name } - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="collections" x-init="initCollectionsPage()" class={ "theme-" + user.Theme }>
@Header(user, "/collections")
@LibrarySwitcher(libData, libraryID, "", nil)
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<a href="/collections" class="btn btn-secondary mb-4">
@Icon("arrow-left", "h-4 w-4")
<span>Back to Collections</span>
</a>
<div class="flex items-center gap-4">
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style={ "background-color: var(--accent-muted); color: " + collection.Color }>{ collection.Icon }</div>
<div>
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2" style="color: var(--text-primary)">
{ collection.Name }
if collection.IsSmart {
<span class="badge text-xs" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
}
</h1>
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p>
if collection.IsSmart {
<p class="text-xs mt-1" style="color: var(--text-secondary)">Membership is rules-matched live — always current, nothing to refresh.</p>
}
</div>
</div>
</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)">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, collection.ChainOrder, books)
<div class="flex-1 min-w-[200px] max-w-md">
<input
type="text"
id="collection-search"
placeholder="Search within collection..."
@input="filterCollectionBooks()"
class="input"
/>
</div>
if !collection.IsSystem {
<a
href={ "/collections/" + collection.ID + "/rules" }
class="btn btn-secondary"
>
@Icon("filter", "h-4 w-4")
<span>Edit Rules</span>
</a>
if !collection.IsSmart {
<button
@click="requestBulkRemove()"
:disabled="selectedBooks.length === 0"
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
class="btn btn-danger"
>
@Icon("trash", "h-4 w-4")
<span>Remove Selected</span>
</button>
<button
@click="$store.bookPicker.open()"
class="btn btn-primary"
>
@Icon("plus", "h-4 w-4")
<span>Add Items</span>
</button>
}
}
</div>
</div>
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
@CollectionBooksGrid(collection, books)
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
<div
x-data="bookPicker"
x-show="$store.bookPicker.isOpen"
@keyup.escape.window="$store.bookPicker.close()"
@click.self="$store.bookPicker.close()"
class="fixed inset-0 z-[65] flex items-center justify-center p-4"
style="display: none; background-color: var(--surface-overlay);"
>
<div
@click.stop
x-show="$store.bookPicker.isOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="card rounded-2xl w-full max-w-6xl flex flex-col max-h-[85vh]"
style="display: none; box-shadow: var(--shadow-pop);"
>
<div
class="flex justify-between items-center p-6 border-b shrink-0"
style="border-color: var(--border);"
>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">
Add Items to Collection
</h2>
<button
@click="$store.bookPicker.close()"
class="icon-btn"
aria-label="Close"
>
@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 shrink-0">
<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 shrink-0"
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]">
<input
type="text"
name="q"
placeholder="Search books..."
class="input"
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
hx-target="#book-picker-grid"
hx-trigger="keyup changed delay:300ms"
hx-include="#book-picker-filters"
/>
</div>
<div class="flex-1 min-w-[150px]">
<input
type="text"
name="author_filter"
placeholder="Author"
class="input"
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
hx-target="#book-picker-grid"
hx-trigger="change"
hx-include="#book-picker-filters"
/>
</div>
<div class="flex-1 min-w-[150px]">
<input
type="text"
name="genre_filter"
placeholder="Genre"
class="input"
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
hx-target="#book-picker-grid"
hx-trigger="change"
hx-include="#book-picker-filters"
/>
</div>
<button
@click="$store.bookPicker.clearFilters()"
class="btn btn-ghost"
>
@Icon("close", "h-4 w-4")
<span>Clear</span>
</button>
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</div>
</div>
<div
id="book-picker-grid"
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 flex-1 min-h-0 overflow-y-auto"
hx-get={ "/api/media-items/search?show_checkbox=true&collection_id=" + collection.ID }
hx-trigger="loadBooks"
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 shrink-0"
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 flex-1 min-h-0 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 shrink-0"
style="border-color: var(--border);"
>
<div class="text-sm" style="color: var(--text-secondary);">
<span x-text="$store.bookPicker.selectedCount"></span> selected
</div>
<div class="flex gap-2">
<button
@click="$store.bookPicker.close()"
class="btn btn-secondary"
>
Cancel
</button>
<button
@click="$store.bookPicker.submit()"
class="btn btn-primary"
>
Add Selected
</button>
</div>
</div>
</div>
</div>
}
<div
x-show="showConfirm"
@click.self="closeConfirm()"
class="fixed inset-0 z-[70] flex items-center justify-center p-4"
style="display: none; background-color: var(--surface-overlay);"
>
<div
@click.stop
class="card p-6 w-full max-w-md mx-4"
style="box-shadow: var(--shadow-pop);"
>
<h3 class="text-lg font-bold mb-2" style="color: var(--text-primary)">Remove from Collection</h3>
<p x-text="confirmMessage" class="text-sm mb-6" style="color: var(--text-secondary)"></p>
<div class="flex justify-end gap-3">
<button @click="closeConfirm()" class="btn btn-secondary">Cancel</button>
<button @click="executeConfirmed()" class="btn btn-danger">
@Icon("trash", "h-4 w-4")
<span x-text="confirmLabel"></span>
</button>
</div>
</div>
</div>
</body>
<div
id="collection-data"
data-id={ collection.ID }
data-library-id={ libraryID }
data-is-system={ collection.IsSystem }
data-is-smart={ collection.IsSmart }
style="display: none;"
></div>
</html>
}
templ BookPickerGrid(books []handlers.BookInfo) {
for _, book := range books {
<div
class="relative cursor-pointer rounded-lg overflow-hidden"
@click={ "$store.bookPicker.toggleBook('" + book.MediaItemID + "')" }
>
<div class="relative">
if book.CoverImagePath != "" {
<img src={ book.CoverImagePath } alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy" />
} else {
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy"/>
}
<div
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
class="absolute inset-0 border-4 rounded-lg pointer-events-none"
style="border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);"
></div>
<div
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
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);"
>
✓
</div>
</div>
<p class="text-xs mt-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</p>
</div>
}
}