fix(ui): wire up collection detail page interactions

The /collections/:id page had several broken features because three
referenced functions (removeBook, toggleBookForRemoval,
filterCollectionBooks) were never defined, and every book card was
wrapped in <a href="/media/..."> so clicking the checkbox or remove
button navigated to the book detail page instead.

Card restructure:
- Remove the <a> wrapper; title and cover are now individual links.
- Checkbox sits in a <label> with expanded click area (p-2 -m-2).
- Checkbox uses Alpine :checked/@change bound to a reactive
  selectedBooks array on the collections component.

Remove (single + bulk):
- Add removeBook(id) and bulkRemove() methods with confirm() dialogs.
- Wire the "Remove Selected" button with :disabled binding and @click.
- Selected-count badge is now Alpine-reactive (x-show/x-text).

Search within collection:
- Add filterCollectionBooks() that filters cards client-side by
  title/author via data-* attributes and @input.

Book picker ("Add Books"):
- Point the HTMX search inputs at the existing /api/media-items/search
  endpoint instead of the non-existent /api/media-items/filtered.
- Add hx-trigger="loadBooks" + hx-get to the grid so loadBooks()
  actually fires an initial request when the picker opens.
- Merge the hidden limit/offset inputs into the #book-picker-filters
  div so hx-include picks them up (was a separate <form id=filter-form>
  that nobody referenced).
- Add show_checkbox mode to handleSearchHTML: when present, render a
  new BookPickerGrid template with clickable, selectable cards instead
  of the reader BookCard.
- Fix bookPicker submit() to location.reload() instead of a non-existent
  reloadCollection HTMX event, and clearFilters() to target text inputs.
This commit is contained in:
2026-08-06 10:40:18 -04:00
parent 73a2852ee3
commit 816ee0ec80
5 changed files with 445 additions and 116 deletions
+75 -46
View File
@@ -137,9 +137,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<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>
<span id="selected-count" class="hidden badge" style="background-color: var(--accent); color: var(--bg-primary);">
0 selected
</span>
<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">
<div class="flex-1 min-w-[200px] max-w-md">
@@ -147,13 +145,14 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
type="text"
id="collection-search"
placeholder="Search within collection..."
onkeyup="filterCollectionBooks()"
@input="filterCollectionBooks()"
class="input"
/>
</div>
<button
id="bulk-remove-btn"
disabled
@click="bulkRemove()"
:disabled="selectedBooks.length === 0"
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
class="btn btn-danger"
>
@Icon("trash", "h-4 w-4")
@@ -173,33 +172,32 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<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>
}
for _, book := range books {
<a href={ "/media/" + book.MediaItemID }>
<div class="card p-4 rounded-2xl">
<div class="flex gap-4">
<div class="flex-shrink-0 pt-1">
<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">
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input
type="checkbox"
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
class="w-5 h-5"
:checked="selectedBooks.includes('{ book.MediaItemID }')"
@change="toggleSelection('{ book.MediaItemID }')"
/>
</div>
<div class="flex-1 min-w-0">
<h3
class="font-semibold text-lg mb-1 line-clamp-2"
style="color: var(--text-primary)"
>
</label>
</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>
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>
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 }
@@ -214,19 +212,19 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
}
</div>
</div>
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click="removeBook('{ book.MediaItemID }')"
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</a>
</div>
</div>
</a>
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click="removeBook('{ book.MediaItemID }')"
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</div>
</div>
}
</div>
</div>
@@ -267,14 +265,14 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
class="p-4 border-b"
style="border-color: var(--border);"
>
<div class="flex flex-wrap gap-3 items-center">
<div id="book-picker-filters" class="flex flex-wrap gap-3 items-center">
<div class="flex-1 min-w-[200px]">
<input
type="text"
name="search"
name="q"
placeholder="Search books..."
class="input"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
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"
@@ -286,7 +284,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
name="author_filter"
placeholder="Author"
class="input"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
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"
@@ -298,7 +296,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
name="genre_filter"
placeholder="Genre"
class="input"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
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"
@@ -311,15 +309,16 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
@Icon("close", "h-4 w-4")
<span>Clear</span>
</button>
</div>
<form id="filter-form" class="hidden">
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form>
</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>
<div
@@ -355,3 +354,33 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
></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" onerror="this.src='/static/placeholder-book.svg'"/>
} 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>
}
}