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:
@@ -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 {};
|
||||
Reference in New Issue
Block a user