Files
bookhoard/templates/collections.templ
T
John O'Keefe d5dbaa48ea fix(ui): user-test round 4 — five browser-reported control defects
Every one of these was silent at the code level and loud in a real
browser; each fix carries its own tripwire (render pin, unit pin, or
CSP-safe rewrite) so the class cannot quietly return.

1. Theme picker dead since the CSP migration (eb57b720): the Appearance
   panel's theme buttons emitted `@click="selectTheme(opt.Name)"` as
   LITERAL text (templ gotcha #1 — quoted attribute values are never
   interpolated; `opt` is a Go loop var), so Alpine threw "Undefined
   variable: opt" on every click and no PUT /api/auth/theme ever fired.
   The wood-paneling sibling always used the correct expression form,
   which is why the panel half-worked. Fixed to the interpolated form;
   TestThemePickerHandlersAreInterpolated pins that real theme names
   reach the HTML and the raw identifier never does.

2. Filter-builder op dropdown "does nothing": the field ingest
   hardcoded `ops: []` into every field's meta while only mirroring the
   ops into the global label registry — opsFor() returned [] for EVERY
   field, so the op menu rendered zero items: an invisible menu, no
   console error (user tried author/publisher/title/subtitle, all
   equally dead). FieldMeta.ops is now the wire's op objects, opsFor()
   is a standalone exported helper, and opsForField unit tests pin the
   round-trip. Verified live: the field menu opens/closes while the op
   menu stayed shut, both with identical wiring — the difference was
   the data, not the wiring.

3. AND/OR connective chip dead: the chip carried an `if` STATEMENT in
   the Alpine attribute (`@click="if (row.chipClickable) ..."`) which
   the CSP build's evaluator rejects as a statement — the whole
   expression silently never ran. Moved the clickability check into a
   toggleConnective(row) component method; the attribute is now a plain
   method call.

4. Pencil vs card navigation race: navigateToCollection guarded with
   `event.target instanceof HTMLElement`, which is FALSE for the
   pencil glyph's SVGElement — a click landing on the glyph sailed past
   the guard and navigated while the htmx edit-modal request still
   fired (the reported "modal flashes, then the builder page"). The
   guard now uses target.closest("button") (works on SVGElement) and
   takes $event explicitly instead of the deprecated window.event.
   .icon-btn is already a 36px square, so no hit-area change needed.

5. Add Items modal under the sidebar + taller than the window: the
   picker overlay sat at z-50 UNDER the app-sidebar's z-60 (its left
   edge rendered beneath the nav), and the card had no height bound —
   header + tabs + filters + grid + footer overflow short windows with
   the footer unreachable (fixed overlay, no page scroll). The picker
   and create-modal overlays move to z-[65], both confirm dialogs to
   z-[70], and the picker card is now a flex column capped at
   max-h-[85vh] whose grids flex-fill (they grow on tall windows,
   shrink on short ones). The ladder is documented at .app-sidebar in
   input.css: sidebar 60 / overlays 65 / confirm 70 / toasts 9999.

Found by clicking every control the way a user does (panel clicks +
computed-style reads + screenshots) after the previous sweep trusted
DOM-text snapshots, which show hidden menus' text and cannot see
occlusion, stacking, or geometry at all.
2026-10-31 13:14:05 -04:00

674 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.
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, $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, 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>
}
}