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 {};