- Add book picker modal with Alpine.js state management for selecting books - Add toggleBookPickerBook, isBookPickerBookSelected, getBookPickerSelectedCount methods - Add clearBookPickerFilters function to reset filter form - Fix icon picker: add showAllIcons function to reset icon search - Fix setupHTMXModalInit to properly initialize Alpine tree after HTMX swap - Update collections template with book picker modal structure
384 lines
14 KiB
Templ
384 lines
14 KiB
Templ
package templates
|
||
|
||
import (
|
||
"bookhoard/internal/handlers"
|
||
"fmt"
|
||
)
|
||
|
||
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="openBookPicker()"
|
||
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>
|
||
<div id="add-books-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
|
||
<button @click="hideAddBooksModal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||
</div>
|
||
<p class="mb-4" style="color: var(--text-secondary)">Search and select books to add to this collection.</p>
|
||
<!-- Library filter toggle - hidden by default, shown by JS when library_id present -->
|
||
<div id="library-filter-container" class="mb-4 hidden">
|
||
<label class="flex items-center gap-2 text-sm" style="color: var(--text-secondary);">
|
||
<input type="checkbox" id="filter-by-library" class="w-4 h-4" onchange="searchBooksForCollection()"/>
|
||
<span>Only show books from this library</span>
|
||
</label>
|
||
</div>
|
||
<div class="mb-4">
|
||
<input
|
||
type="text"
|
||
id="book-search"
|
||
placeholder="Search books..."
|
||
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>
|
||
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
|
||
<div class="flex justify-end space-x-3">
|
||
<button type="button" @click="hideAddBooksModal" class="btn-secondary px-4 py-2 rounded-lg">
|
||
Cancel
|
||
</button>
|
||
<button type="button" @click="addbooksToAdd" class="btn-primary px-4 py-2 rounded-lg">
|
||
Add Selected Books
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
x-show="bookPickerOpen"
|
||
x-transition:enter="transition ease-out duration-200"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-150"
|
||
x-transition:leave-start="opacity-100"
|
||
x-transition:leave-end="opacity-0"
|
||
@click.self="closeBookPicker()"
|
||
@keyup.escape.window="closeBookPicker()"
|
||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
|
||
>
|
||
<div
|
||
@click.stop
|
||
class="card rounded-lg w-full max-w-4xl mx-4 my-8"
|
||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||
>
|
||
<!-- Header -->
|
||
<div
|
||
class="flex justify-between items-center p-4 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="closeBookPicker()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||
</div>
|
||
<!-- Filters -->
|
||
<div class="p-4 border-b" style="border-color: var(--border);">
|
||
<div class="flex flex-wrap gap-4 items-center">
|
||
<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={ fmt.Sprintf("/api/media-items/filtered?show_checkbox=true&collection_id=%s", 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="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={ fmt.Sprintf("/api/media-items/filtered?show_checkbox=true&collection_id=%s", collection.ID) }
|
||
hx-target="#book-picker-grid"
|
||
hx-trigger="change"
|
||
hx-include="#book-picker-filters"
|
||
/>
|
||
</div>
|
||
<button
|
||
@click="clearBookPickerFilters()"
|
||
class="px-4 py-2 rounded-lg border"
|
||
style="border-color: var(--border); color: var(--text-primary);"
|
||
>
|
||
✕ Clear
|
||
</button>
|
||
</div>
|
||
<!-- Hidden form for HTMX -->
|
||
<form id="book-picker-filters" class="hidden">
|
||
<input type="hidden" name="limit" value="50"/>
|
||
<input type="hidden" name="offset" value="0"/>
|
||
</form>
|
||
</div>
|
||
<!-- Books Grid with Checkboxes -->
|
||
<div
|
||
id="book-picker-grid"
|
||
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4 max-h-96 overflow-y-auto"
|
||
>
|
||
<!-- Books rendered via HTMX -->
|
||
</div>
|
||
<!-- Footer with Selection Count and Submit -->
|
||
<div
|
||
class="p-4 border-t flex justify-between items-center"
|
||
style="border-color: var(--border);"
|
||
>
|
||
<span class="text-sm" style="color: var(--text-secondary);">
|
||
<span x-text="bookPickerSelected.length"></span> books selected
|
||
</span>
|
||
<div class="flex gap-2">
|
||
<button
|
||
@click="closeBookPicker()"
|
||
class="px-4 py-2 rounded-lg border"
|
||
style="border-color: var(--border); color: var(--text-primary);"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<!-- HTMX Form Submission -->
|
||
<form
|
||
hx-post={ fmt.Sprintf("/api/collections/%s/books", collection.ID) }
|
||
hx-on::after-request="if(event.detail.successful) { closeBookPicker(); htmx.trigger(htmx.find('#books-container'), 'refresh'); }"
|
||
@submit.prevent="if(bookPickerSelected.length === 0) return; $el.querySelector('input[name=book_ids]').value = bookPickerSelected.join(','); $el.submit()"
|
||
>
|
||
<input type="hidden" name="book_ids" value=""/>
|
||
<button
|
||
type="submit"
|
||
class="px-4 py-2 rounded-lg font-medium"
|
||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||
>
|
||
Add Selected Books
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
<div
|
||
id="collection-data"
|
||
data-id="{collection.ID}"
|
||
data-library-id="{ libraryID }"
|
||
style="display: none;"
|
||
></div>
|
||
</html>
|
||
}
|