Files
bookhoard/templates/collections.templ
T
John O'Keefe adb93cba9e 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).
2026-10-30 16:18:29 -04:00

674 lines
25 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.
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
// 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)" 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, 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-50 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 mx-4 my-8"
style="display: none; box-shadow: var(--shadow-pop);"
>
<div
class="flex justify-between items-center p-6 border-b"
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">
<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]">
<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 max-h-96 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"
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> 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-[60] 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>
}
}