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.
387 lines
14 KiB
Templ
387 lines
14 KiB
Templ
package templates
|
|
|
|
import "bookhoard/internal/handlers"
|
|
|
|
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>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
</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">
|
|
<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>
|
|
</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>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
</head>
|
|
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
|
|
@Header(user, "/collections")
|
|
@LibrarySwitcher(libData, libraryID)
|
|
<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" style="color: var(--text-primary)">{ collection.Name }</h1>
|
|
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</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)">Books 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">
|
|
<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>
|
|
<button
|
|
@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")
|
|
<span>Remove Selected</span>
|
|
</button>
|
|
<button
|
|
@click="$store.bookPicker.open()"
|
|
class="btn btn-primary"
|
|
>
|
|
@Icon("plus", "h-4 w-4")
|
|
<span>Add Books</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
if len(books) == 0 {
|
|
<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 {
|
|
<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"
|
|
class="w-5 h-5"
|
|
:checked="selectedBooks.includes('{ book.MediaItemID }')"
|
|
@change="toggleSelection('{ book.MediaItemID }')"
|
|
/>
|
|
</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>
|
|
</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"
|
|
onerror="this.src='/static/placeholder-book.svg'"
|
|
/>
|
|
} else {
|
|
<img
|
|
src="/static/placeholder-book.svg"
|
|
alt="Cover"
|
|
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
|
/>
|
|
}
|
|
</a>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div
|
|
x-data="bookPicker"
|
|
@keyup.escape.window="$store.bookPicker.close()"
|
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
|
style="display: none;"
|
|
>
|
|
<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 Books to Collection
|
|
</h2>
|
|
<button
|
|
@click="$store.bookPicker.close()"
|
|
class="icon-btn"
|
|
aria-label="Close"
|
|
>
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="p-4 border-b"
|
|
style="border-color: var(--border);"
|
|
>
|
|
<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>
|
|
<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> books 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 Books
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
<div
|
|
id="collection-data"
|
|
data-id={ collection.ID }
|
|
data-library-id={ libraryID }
|
|
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" 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>
|
|
}
|
|
}
|