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.
142 lines
3.4 KiB
TypeScript
142 lines
3.4 KiB
TypeScript
// Book Picker for Collections - multi-select book picker modal
|
|
// CRITICAL: Selection state must persist across HTMX grid updates
|
|
|
|
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
|
|
// Alpine.store for global book picker state
|
|
// Using store ensures state persists across HTMX DOM swaps
|
|
Alpine.store("bookPicker", {
|
|
isOpen: false,
|
|
selectedBooks: new Set<string>(),
|
|
|
|
open() {
|
|
this.isOpen = true;
|
|
this.selectedBooks.clear();
|
|
this.loadBooks();
|
|
},
|
|
|
|
close() {
|
|
this.isOpen = false;
|
|
this.selectedBooks.clear();
|
|
},
|
|
|
|
toggleBook(bookId: string) {
|
|
if (this.selectedBooks.has(bookId)) {
|
|
this.selectedBooks.delete(bookId);
|
|
} else {
|
|
this.selectedBooks.add(bookId);
|
|
}
|
|
// Trigger Alpine reactivity by creating new Set
|
|
this.selectedBooks = new Set(this.selectedBooks);
|
|
},
|
|
|
|
isSelected(bookId: string): boolean {
|
|
return this.selectedBooks.has(bookId);
|
|
},
|
|
|
|
get selectedCount(): number {
|
|
return this.selectedBooks.size;
|
|
},
|
|
|
|
async loadBooks() {
|
|
const grid = document.getElementById("book-picker-grid");
|
|
if (!grid) return;
|
|
|
|
// Trigger initial HTMX load
|
|
window.htmx.trigger(grid, "loadBooks");
|
|
},
|
|
|
|
clearFilters() {
|
|
const filterDiv = document.getElementById("book-picker-filters");
|
|
if (!filterDiv) return;
|
|
|
|
const inputs = filterDiv.querySelectorAll(
|
|
'input[type="text"]',
|
|
) as NodeListOf<HTMLInputElement>;
|
|
inputs.forEach((input) => {
|
|
input.value = "";
|
|
});
|
|
|
|
this.loadBooks();
|
|
},
|
|
|
|
async submit() {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) {
|
|
showToast("Not authenticated", "error");
|
|
return;
|
|
}
|
|
|
|
if (this.selectedBooks.size === 0) {
|
|
showToast("No books selected", "error");
|
|
return;
|
|
}
|
|
|
|
// Get collection ID from current page
|
|
const pathParts = window.location.pathname.split("/");
|
|
const collectionId = pathParts[pathParts.length - 1];
|
|
|
|
try {
|
|
const response = await fetch(`/api/collections/${collectionId}/books`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
book_ids: Array.from(this.selectedBooks),
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast(
|
|
`Added ${this.selectedBooks.size} books to collection`,
|
|
"success",
|
|
);
|
|
this.close();
|
|
location.reload();
|
|
} else {
|
|
showToast("Failed to add books", "error");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to add books:", error);
|
|
showToast("Error adding books", "error");
|
|
}
|
|
},
|
|
});
|
|
|
|
// Alpine component for book picker
|
|
// Provides methods for template to access store
|
|
Alpine.data("bookPicker", () => ({
|
|
get isOpen() {
|
|
return (window as any).Alpine?.$store.bookPicker.isOpen;
|
|
},
|
|
|
|
get selectedCount() {
|
|
return (window as any).Alpine?.$store.bookPicker.selectedCount;
|
|
},
|
|
|
|
toggleBook(bookId: string) {
|
|
(window as any).Alpine?.$store.bookPicker.toggleBook(bookId);
|
|
},
|
|
|
|
isSelected(bookId: string): boolean {
|
|
return (window as any).Alpine?.$store.bookPicker.isSelected(bookId);
|
|
},
|
|
|
|
clearFilters() {
|
|
(window as any).Alpine?.$store.bookPicker.clearFilters();
|
|
},
|
|
|
|
close() {
|
|
(window as any).Alpine?.$store.bookPicker.close();
|
|
},
|
|
|
|
submit() {
|
|
(window as any).Alpine?.$store.bookPicker.submit();
|
|
},
|
|
}));
|
|
|
|
export {};
|