feat: create book picker module for collections

Add new bookPicker.ts module for multi-select book picker modal:

Alpine.store for global state:
- isOpen: Modal visibility state
- selectedBooks: Set<string> for persistent selection across HTMX swaps
- Methods: open, close, toggleBook, isSelected, loadBooks, clearFilters, submit

Key features:
- Selection persists across filter changes (Alpine.store)
- Multi-select with checkbox state management
- Adds books to collection via POST /api/collections/:id/books
- Trigger collection page reload after successful add
- Clear filters resets form fields (preserves selections)
- Uses HTMX for dynamic book grid updates

Critical SSR-first implementation:
- Alpine.store ensures state survives HTMX DOM swaps
- Checkboxes re-rendered by HTMX maintain state via store
- Selection persists across pagination and filter changes
- No DOM state, all state in Alpine reactive store

Replaces non-functional add books button in collections.
This commit is contained in:
2026-03-20 11:50:33 -04:00
parent 3cf5d764a2
commit af1e61a0d7
+144
View File
@@ -0,0 +1,144 @@
// 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 filterForm = document.getElementById(
"book-picker-filters",
) as HTMLFormElement;
if (!filterForm) return;
// Reset all form fields except checkbox state (that's in Alpine.store)
const inputs = filterForm.querySelectorAll("input:not([type='checkbox'])");
inputs.forEach((input) => {
(input as HTMLInputElement).value = "";
});
// Reload books - selection state preserved in Alpine.store
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();
// Reload collection detail page
window.htmx.trigger(document.body, "reloadCollection");
} 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 {};