// Book Picker for Collections - multi-select book picker modal // CRITICAL: Selection state must persist across HTMX grid updates import { authHeaders } from "./storage"; 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(), // Picker scope (plan item 2.3): "books" (default) or "episodes" — // YOUR subscriptions' episodes. Pure client-local tab state; the // selection store is shared, ids never collide. scope: "books", open() { this.isOpen = true; this.selectedBooks.clear(); this.scope = "books"; this.loadBooks(); }, close() { this.isOpen = false; this.selectedBooks.clear(); }, switchScope(scope: "books" | "episodes") { this.scope = scope; if (scope === "episodes") { this.loadEpisodes(); } else { this.loadBooks(); } }, 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"); }, async loadEpisodes() { const grid = document.getElementById("episode-picker-grid"); if (!grid) return; window.htmx.trigger(grid, "loadEpisodes"); }, clearFilters() { const filterDiv = document.getElementById("book-picker-filters"); if (!filterDiv) return; const inputs = filterDiv.querySelectorAll('input[type="text"]') as NodeListOf; inputs.forEach((input) => { input.value = ""; }); this.loadBooks(); }, async submit() { 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: { ...authHeaders(), "Content-Type": "application/json", }, body: JSON.stringify({ book_ids: Array.from(this.selectedBooks), }), }); if (response.ok) { showToast(`Added ${this.selectedBooks.size} items to collection`, "success"); this.close(); location.reload(); } else { showToast("Failed to add items", "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.Alpine?.$store.bookPicker.isOpen; }, get selectedCount() { return window.Alpine?.$store.bookPicker.selectedCount; }, toggleBook(bookId: string) { window.Alpine?.$store.bookPicker.toggleBook(bookId); }, isSelected(bookId: string): boolean { return window.Alpine?.$store.bookPicker.isSelected(bookId); }, clearFilters() { window.Alpine?.$store.bookPicker.clearFilters(); }, close() { window.Alpine?.$store.bookPicker.close(); }, submit() { window.Alpine?.$store.bookPicker.submit(); }, }));