Files
bookhoard/templates/collections.templ
T
john-okeefe e74eeb5c5b feat: implement collections book picker with Alpine.store
Add multi-select book picker modal for collections using Alpine.js patterns:
- Alpine.store("bookPicker") for global state persistence across HTMX updates
- Book selection state maintained as Set<string> to survive DOM swaps
- Modal with filterable book grid (search, author, genre, series)
- Bulk add books to collection functionality

Templates:
- collections.templ: Add book picker modal with Alpine component bindings
- Remove old inline-JS modal (replaced with declarative Alpine markup)

TypeScript:
- web/src/bookPicker.ts: New module with Alpine.store and Alpine.data definitions
- web/src/main.ts: Import bookPicker module
- web/src/collections.ts: Remove old modal functions (replaced by Alpine)

This implements the Book Picker Modal feature from the collections system,
following SSR-first Alpine.js patterns with HTMX for dynamic updates.

Fixes "Add Books" button being disabled - modal now fully functional.
2026-03-20 22:58:09 -04:00

374 lines
13 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="initializeCollectionWebSocket() setupHTMXModalInit()" class="theme-{ user.Theme }">
@Header(user, "/collections")
<!-- Modal Container -->
<div id="modal-container"></div>
<!-- Actual container page -->
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8 flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">My Collections</h1>
<p style="color: var(--text-secondary)">Organize your books into custom collections</p>
</div>
<div class="flex gap-3">
<button
hx-get="/collections/restore-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-secondary px-4 py-2 rounded-lg"
>
🔄 Restore System
</button>
<button
hx-get="/collections/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-primary px-4 py-2 rounded-lg"
>
New Collection
</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="text-center py-16 col-span-full" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
<p class="mb-4">Create collections to organize your books</p>
<button
hx-get="/collections/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-primary px-4 py-2 rounded-lg"
>
Create Your First Collection
</button>
</div>
}
for _, col := range collections {
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
<div
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary);"
data-color={ col.Color }
>
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">{ col.Icon }</div>
<div class="flex space-x-2">
<button
hx-get={ "/collections/" + col.ID + "/edit-modal" }
hx-target="#modal-container"
hx-swap="innerHTML"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);"
>
✏️
</button>
<button
hx-delete={ "/api/collections/" + col.ID + "" }
hx-redirect="/collections"
hx-confirm="Are you sure you want to delete this collection?"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);"
>
🗑️
</button>
</div>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
<p class="text-sm mb-4" 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) {
<!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="initializeCollectionWebSocket" class="theme-{ user.Theme }">
@Header(user, "/collections")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<button @click="backToCollections" class="btn-secondary px-4 py-2 rounded-lg mb-4">
Back to Collections
</button>
<div class="flex items-center gap-4">
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
<div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
<p style="color: var(--text-secondary)">{ collection.Description }</p>
</div>
</div>
</div>
<div class="mb-6 flex justify-between items-center">
<div class="flex items-center gap-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
<span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);">
0 selected
</span>
</div>
<div class="flex gap-3">
<div class="flex-1 max-w-md">
<input
type="text"
id="collection-search"
placeholder="Search within collection..."
onkeyup="filterCollectionBooks()"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div>
<button
id="bulk-remove-btn"
disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
🗑️ Remove Selected
</button>
<button
@click="$store.bookPicker.open()"
class="btn-primary px-4 py-2 rounded-lg"
>
Add Books
</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 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-lg border hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<div class="flex gap-4">
<!-- Checkbox and Book Info -->
<div class="flex-shrink-0 pt-1">
<input
type="checkbox"
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
class="w-5 h-5"
/>
</div>
<div class="flex-1 min-w-0">
<h3
class="font-semibold text-lg mb-1 line-clamp-2"
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>
<!-- Book Cover -->
<div class="flex-shrink-0 w-16 sm:w-20">
if book.CoverImagePath != "" {
<img
src={ book.CoverImagePath }
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded 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 shadow-md"
/>
}
</div>
</div>
<!-- Remove Button -->
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click="removeBook('{ book.MediaItemID }')"
class="px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);"
>
🗑️ Remove from Collection
</button>
</div>
</div>
}
</div>
</div>
<!-- Book Picker Modal -->
<!-- CRITICAL: Selection state stored in Alpine.store to persist across HTMX swaps -->
<div
x-data="bookPicker"
@keyup.escape.window="$store.bookPicker.close()"
class="fixed inset-0 z-50 flex items-center justify-center"
style="display: none;"
>
<!-- Modal content with same filtering UI as bookshelf + multi-select -->
<!-- CRITICAL: x-show manages visibility, style="display: none;" prevents FOUC -->
<!-- Following ALPINE_COMPLETION_GUIDE.md principles: Alpine state for UI, not class="hidden" -->
<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-lg w-full max-w-6xl mx-4 my-8"
style="background-color: var(--bg-secondary); border-color: var(--border); display: none;"
>
<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="p-2 hover:opacity-80 rounded"
style="color: var(--text-primary)"
></button>
</div>
<!-- Filter Bar (same as bookshelf) -->
<div
class="p-4 border-b"
style="border-color: var(--border);"
>
<div class="flex flex-wrap gap-4 items-center">
<!-- Search -->
<div class="flex-1 min-w-[200px]">
<input
type="text"
name="search"
placeholder="Search books..."
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
hx-target="#book-picker-grid"
hx-trigger="keyup changed delay:300ms"
hx-include="#book-picker-filters"
/>
</div>
<!-- Author -->
<div class="flex-1 min-w-[150px]">
<input
type="text"
name="author_filter"
placeholder="Author"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
hx-target="#book-picker-grid"
hx-trigger="change"
hx-include="#book-picker-filters"
/>
</div>
<!-- Genre -->
<div class="flex-1 min-w-[150px]">
<input
type="text"
name="genre_filter"
placeholder="Genre"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
hx-target="#book-picker-grid"
hx-trigger="change"
hx-include="#book-picker-filters"
/>
</div>
<!-- Clear -->
<div>
<button
@click="$store.bookPicker.clearFilters()"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Clear
</button>
</div>
</div>
<!-- Hidden form for HTMX -->
<!-- NOTE: class="hidden" is acceptable here because form is never shown to user -->
<!-- HTMX uses it for parameter inclusion only -->
<form id="filter-form" class="hidden">
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form>
</div>
<!-- Books Grid with Multi-select -->
<!-- CRITICAL: HTMX swaps this div's content, but Alpine.store preserves selection state -->
<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"
>
<!-- Books loaded via HTMX -->
</div>
<!-- Selected Count & Submit -->
<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="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Cancel
</button>
<button
@click="$store.bookPicker.submit()"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-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>
}