From af1e61a0d7c2fcd1230a1c0d463976f4f3067f24 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 20 Mar 2026 11:42:09 -0400 Subject: [PATCH] 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 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. --- web/src/bookPicker.ts | 144 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 web/src/bookPicker.ts diff --git a/web/src/bookPicker.ts b/web/src/bookPicker.ts new file mode 100644 index 0000000..2befb2f --- /dev/null +++ b/web/src/bookPicker.ts @@ -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(), + + 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 {};