From 6e2d304b35931264bb5c5bc02faa7708b7b69df6 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 17 May 2026 21:12:45 -0400 Subject: [PATCH] feat(frontend): integrate shared library switcher into all pages Wire up the shared library switcher module on dashboard, collections list, collection detail, and collection rules pages. All pages use SSR for initial load and AJAX with fade transitions on library switch. web/src/collections.ts: - Add initCollectionsPage() that auto-detects list vs detail page by checking for #collection-data element - Collections list: onSwitch fetches /api/collections?library_id=X and re-renders the grid with per-library book counts - Collection detail: onSwitch fetches /api/collections/:id?library_id=X and re-renders the books grid - Add renderCollectionsGrid() and renderCollectionBooks() with Alpine.initTree() calls for dynamic content - Collection cards now link with ?library_id= from selected library - Update hidden #collection-data data-library-id on switch web/src/dashboard.ts: - Replace standalone switchLibrary() with initLibrarySwitcher() + switchWithTransition() from shared module - Extract fetchAndRenderSections() helper shared by onSwitch callback, reloadPage(), and saveDashboardSettings() - Remove inline #library-select change listener and switch-library data-action handler (now handled by shared module) - Scan-complete event handler unchanged (independent incremental logic) web/src/collection-rules.ts: - Update backToCollection() to preserve library context by appending ?library_id= from localStorage selectedLibrary key --- web/src/collection-rules.ts | 6 +- web/src/collections.ts | 192 +++++++++++++++++++++++++++--------- web/src/dashboard.ts | 138 +++++++------------------- 3 files changed, 184 insertions(+), 152 deletions(-) diff --git a/web/src/collection-rules.ts b/web/src/collection-rules.ts index fcb3fad..3295bd4 100644 --- a/web/src/collection-rules.ts +++ b/web/src/collection-rules.ts @@ -37,7 +37,11 @@ function setupEventDelegation(): void { function backToCollection(): void { if (!collectionId) return; - window.location.href = `/collections/${collectionId}`; + const libraryId = localStorage.getItem("selectedLibrary"); + const url = libraryId + ? `/collections/${collectionId}?library_id=${libraryId}` + : `/collections/${collectionId}`; + window.location.href = url; } function getFieldLabel(field: string): string { diff --git a/web/src/collections.ts b/web/src/collections.ts index a72c897..168e5b8 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -1,6 +1,11 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; import { createWebSocket } from "./websocket"; +import { + getCurrentLibraryId, + initLibrarySwitcher, + switchWithTransition, +} from "./library-switcher"; let collectionId: string | null = null; @@ -60,39 +65,104 @@ async function loadCollections(): Promise { if (response.ok) { const data = await response.json(); - renderCollections(data.collections || []); + renderCollectionsGrid(data.collections || []); } } catch (error) { console.error("Failed to load collections:", error); } } -function renderCollections(collections: CollectionData[]): void { +function renderCollectionsGrid(collections: CollectionData[]): void { const container = document.getElementById("collections-list"); if (!container) return; if (collections.length === 0) { - container.innerHTML = - '

No collections yet

'; + container.innerHTML = ` +
+
📚
+

No Collections Yet

+

Create collections to organize your books

+ +
`; return; } + const libraryId = getCurrentLibraryId(); + const libraryParam = libraryId ? `?library_id=${libraryId}` : ""; + container.innerHTML = collections .map( - (collection) => ` - -
- ${collection.icon || "📁"} -
-

${collection.name}

- ${collection.description ? `

${collection.description}

` : ""} -
+ (col) => ` +
+
+
+
${col.icon}
+
+ + +
-
+

${col.name}

+

${col.description}

+ ${col.book_count > 0 ? `

${col.book_count} books

` : ""} +
+
`, ) .join(""); + + initColorSelection(); + Alpine.initTree(container); +} + +function renderCollectionBooks(books: BookInfo[]): void { + const container = document.getElementById("books-container"); + if (!container) return; + + if (books.length === 0) { + container.innerHTML = `
No books in this collection yet.
`; + return; + } + + container.innerHTML = books + .map( + (book) => ` + +
+
+
+ +
+
+

${book.title}

+ ${book.author ? `

by ${book.author}

` : ""} +
+
+ Cover +
+
+
+ +
+
+
+ `, + ) + .join(""); + + Alpine.initTree(container); } async function loadCollectionRules(collectionId: string): Promise { @@ -240,7 +310,6 @@ function renderTestResults(results: unknown[]): void { container.innerHTML = `

${Array.isArray(results) ? results.length : 0} matching books

`; } -// Add authorization header to all HTMX requests function setupHTMXAuth(): void { document.body.addEventListener("htmx:configRequest", function (evt: Event) { const token = localStorage.getItem("token"); @@ -249,27 +318,20 @@ function setupHTMXAuth(): void { } function navigateToCollection(element: HTMLElement): void { - // Check if the click target is a button const event = window.event as Event; if (event && event.target instanceof HTMLElement) { const target = event.target as HTMLElement; - - // If user clicked a button or button icon, don't navigate if (target.tagName === "BUTTON" || target.closest("button") !== null) { - return; // Let HTMX handle the button action + return; } } - // Only navigate if clicking the card body const href = element.getAttribute("data-href"); if (href) { window.location.href = href; } } -// ============================================================================ -// Collection Modal UI Helpers -// ============================================================================ const borderClasses: Record = { blue: "border-blue-500", red: "border-red-500", @@ -278,7 +340,6 @@ const borderClasses: Record = { purple: "border-purple-500", }; -// Color selection for create/edit modal function selectColor(color: string): void { const colorInput = document.getElementById( "collection-color", @@ -286,7 +347,6 @@ function selectColor(color: string): void { if (colorInput) { colorInput.value = color; } - // Update visual selection document.querySelectorAll(".color-option").forEach((btn) => { (btn as HTMLElement).style.outline = "none"; (btn as HTMLElement).style.outlineOffset = "0"; @@ -299,7 +359,7 @@ function selectColor(color: string): void { selectedBtn.style.outlineOffset = "3px"; } } -// Close modal (removes from DOM) + function closeCollectionModal(): void { const modal = document.querySelector(".fixed.inset-0"); if (modal) { @@ -307,7 +367,6 @@ function closeCollectionModal(): void { } } -// Initialize color selection on page load function initColorSelection(): void { const colorInput = document.getElementById( "collection-color", @@ -316,21 +375,17 @@ function initColorSelection(): void { selectColor(colorInput.value); } - // Apply border color classes to collection cards document.querySelectorAll("[data-color]").forEach((card) => { const color = (card as HTMLElement).getAttribute("data-color"); if (color && borderClasses[color]) { - // Remove old border color classes Object.values(borderClasses).forEach((cls) => { (card as HTMLElement).classList.remove(cls); }); - // Add new border color class (card as HTMLElement).classList.add(borderClasses[color]); } }); } -// Initialize icon grid when modal is loaded via HTMX function setupHTMXModalInit(): void { document.body.addEventListener("htmx:afterSwap", function (evt: CustomEvent) { const target = evt.detail.target; @@ -342,10 +397,8 @@ function setupHTMXModalInit(): void { } function showAllIcons(): void { - // Populate grid if empty populateIconGrid(); - // Clear search filter const searchInput = document.getElementById( "icon-search", ) as HTMLInputElement; @@ -353,7 +406,6 @@ function showAllIcons(): void { searchInput.value = ""; } - // Show all icons const iconGrid = document.getElementById("icon-grid"); if (!iconGrid) return; @@ -363,9 +415,7 @@ function showAllIcons(): void { }); } -// Icon data with keywords (single source of truth) const iconData: Record = { - // Books & Reading "📚": ["book", "books", "library", "read", "reading"], "📖": ["book", "open", "read", "reading"], "📝": ["memo", "note", "write", "writing", "edit"], @@ -379,8 +429,6 @@ const iconData: Record = { "📗": ["book", "read", "green"], "📘": ["book", "read", "blue"], "📙": ["book", "read", "orange"], - - // Favorites & Activities "⭐": ["star", "favorite", "like", "rating"], "❤️": ["heart", "love", "favorite", "like"], "🔥": ["fire", "hot", "popular", "trending", "flame"], @@ -392,8 +440,6 @@ const iconData: Record = { "❌": ["cross", "x", "wrong", "error", "fail"], "⚡️": ["bolt", "fast", "quick", "energy"], "🚀": ["rocket", "fast", "launch", "space"], - - // Places & Objects "💎": ["gem", "diamond", "stone", "rich"], "👍": ["thumb", "up", "good", "yes", "like"], "👎": ["thumb", "down", "bad", "no", "dislike"], @@ -403,14 +449,11 @@ const iconData: Record = { "✈️": ["plane", "airplane", "fly", "travel"], "🎮": ["game", "play", "video", "gaming"], }; -// Helper: Get just the emoji list function populateIconGrid(): void { const iconGrid = document.getElementById("icon-grid"); if (!iconGrid) return; - // Clear any existing content iconGrid.innerHTML = ""; - // Generate buttons from iconData Object.entries(iconData).forEach(([emoji, keywords]) => { const button = document.createElement("button"); button.type = "button"; @@ -433,7 +476,6 @@ function selectIcon(icon: string): void { if (iconInput) iconInput.value = icon; if (searchInput) searchInput.value = icon; - // Visual feedback document.querySelectorAll(".icon-btn").forEach((btn) => { (btn as HTMLElement).style.outline = "none"; (btn as HTMLElement).style.backgroundColor = ""; @@ -447,12 +489,12 @@ function selectIcon(icon: string): void { selectedBtn.style.backgroundColor = "var(--bg-secondary)"; } } + function filterIcons(searchTerm: string): void { const iconGrid = document.getElementById("icon-grid"); const iconInput = document.getElementById( "collection-icon", ) as HTMLInputElement; - // Update hidden input with typed value if (iconInput) iconInput.value = searchTerm; if (!iconGrid) return; const buttons = iconGrid.querySelectorAll(".icon-btn"); @@ -462,7 +504,6 @@ function filterIcons(searchTerm: string): void { const emoji = (btn as HTMLElement).textContent || ""; const keywords = iconData[emoji] || []; - // Search in keywords OR emoji itself const matches = searchTerm === "" || emoji.includes(searchTerm) || @@ -474,7 +515,61 @@ function filterIcons(searchTerm: string): void { }); } -// Export functions globally +function isDetailPage(): boolean { + const dataEl = document.getElementById("collection-data"); + return !!dataEl?.dataset.id; +} + +function initCollectionsPage(): void { + if (isDetailPage()) { + const dataEl = document.getElementById("collection-data"); + const collId = dataEl?.dataset.id || ""; + + initLibrarySwitcher({ + onSwitch: async (libraryId) => { + const dataEl = document.getElementById("collection-data"); + if (dataEl) dataEl.dataset.libraryId = libraryId; + + await switchWithTransition("books-container", async () => { + const param = libraryId ? `?library_id=${libraryId}` : ""; + const response = await fetch( + `/api/collections/${collId}${param}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + "Content-Type": "application/json", + }, + }, + ); + if (!response.ok) throw new Error("Failed to load collection"); + const data = await response.json(); + renderCollectionBooks(data.books || []); + }); + }, + }); + } else { + initLibrarySwitcher({ + onSwitch: async (libraryId) => { + await switchWithTransition("collections-list", async () => { + const param = libraryId ? `?library_id=${libraryId}` : ""; + const response = await fetch(`/api/collections${param}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) throw new Error("Failed to load collections"); + const data = await response.json(); + renderCollectionsGrid(data.collections || []); + }); + }, + }); + } + + initializeCollectionWebSocket(); + setupHTMXModalInit(); +} + export { closeCollectionModal, createRule, @@ -482,6 +577,7 @@ export { filterIcons, initColorSelection, initializeCollectionWebSocket, + initCollectionsPage, loadCollectionRules, loadCollections, navigateToCollection, @@ -495,13 +591,13 @@ export { }; Alpine.data("collections", () => ({ - // Collection Methods closeCollectionModal, createRule, deleteRule, filterIcons, initColorSelection, initializeCollectionWebSocket, + initCollectionsPage, loadCollectionRules, loadCollections, navigateToCollection, diff --git a/web/src/dashboard.ts b/web/src/dashboard.ts index aeed763..36e0e46 100644 --- a/web/src/dashboard.ts +++ b/web/src/dashboard.ts @@ -1,9 +1,7 @@ import { Alpine } from "./alpine"; import { apiPost, apiPut } from "./api"; import { showToast } from "./toast"; - -// Dashboard functionality with unified collections architecture -// Procedural/imperative style (no OOP) +import { initLibrarySwitcher, switchWithTransition } from "./library-switcher"; const SCROLL_AMOUNT = 300; @@ -40,7 +38,7 @@ async function openDashboardSettings(): Promise { } else { showToast("Failed to load library preferences", "error"); console.error("API Error:", response.status, response.statusText); - return; // Don't open modal with stale data. + return; } const modal = document.getElementById( @@ -64,7 +62,6 @@ function applyPreferencesToModal(prefs: any): void { display.textContent = String(prefs.items_per_section); } - // Update checkboxes and reorder items const items = collectionList.querySelectorAll( "[data-collection-id]", ) as NodeListOf; @@ -76,11 +73,9 @@ function applyPreferencesToModal(prefs: any): void { 'input[type="checkbox"]', ) as HTMLInputElement; - // Update checkbox state const isHidden = prefs.hidden_collections.includes(collectionId); if (checkbox) checkbox.checked = !isHidden; - // Sort according to collection_order const orderIndex = prefs.collection_order.indexOf(collectionId); if (orderIndex !== -1) { orderedItems[orderIndex] = item; @@ -89,7 +84,6 @@ function applyPreferencesToModal(prefs: any): void { } }); - // Reorder in DOM orderedItems.forEach((item) => collectionList.appendChild(item)); } @@ -99,6 +93,23 @@ function closeDashboardSettings(): void { ) as HTMLElement; modal?.classList.add("hidden"); } + +async function fetchAndRenderSections(libraryId: string): Promise { + const response = await fetch( + `/api/dashboard/sections?library_id=${libraryId}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + "Content-Type": "application/json", + }, + }, + ); + if (!response.ok) throw new Error("Failed to load sections"); + const data = await response.json(); + renderDashboardCollections(data.sections); + localStorage.setItem("selectedLibrary", libraryId); +} + async function saveDashboardSettings(): Promise { const collectionList = document.getElementById( "collection-list", @@ -138,33 +149,18 @@ async function saveDashboardSettings(): Promise { if (response.ok) { showToast("Dashboard settings saved", "success"); closeDashboardSettings(); - // Fetch updated sections and re-render (like switchLibrary does) if (!libraryId) { showToast("Unable to refresh dashboard - no library selected", "error"); return; } - const sectionResponse = await fetch( - `/api/dashboard/sections?library_id=${libraryId}`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("token")}`, - "Content-Type": "application/json", - }, - }, - ); - if (sectionResponse.ok) { - const data = await sectionResponse.json(); - renderDashboardCollections(data.sections); - localStorage.setItem("selectedLibrary", libraryId); - } else { - showToast("Failed to refresh sections", "error"); - } + await fetchAndRenderSections(libraryId); } } catch (error) { showToast("Failed to save settings", "error"); console.error("Save dashboard settings error:", error); } } + async function restoreSystemCollection( collectionName: string, collectionTitle: string, @@ -192,50 +188,6 @@ async function restoreSystemCollection( } } -async function switchLibrary(libraryId: string): Promise { - const container = document.getElementById( - "collections-container", - ) as HTMLElement; - const loading = document.getElementById("loading-spinner") as HTMLElement; - - if (!container || !loading) return; - - try { - // Step 1: Fade out current content (150ms) - container.classList.add("opacity-0", "transition-opacity", "duration-150"); - - // Wait for fade-out to complete - await new Promise((resolve) => setTimeout(resolve, 150)); - - // Step 2: Show loading spinner - loading.classList.remove("hidden"); - - // Step 3: Fetch new data - const response = await fetch( - `/api/dashboard/sections?library_id=${libraryId}`, - { - headers: { - Authorization: `Bearer ${localStorage.getItem("token")}`, - "Content-Type": "application/json", - }, - }, - ); - - if (!response.ok) { - throw new Error("Failed to load sections"); - } - - const data = await response.json(); - renderDashboardCollections(data.sections); - localStorage.setItem("selectedLibrary", libraryId); - } catch (error) { - showToast("Failed to load library", "error"); - console.error("Switch library error:", error); - } finally { - loading.classList.add("hidden"); - } -} - function renderSectionHTML(section: SectionData): string { return `
@@ -264,8 +216,8 @@ function renderSectionHTML(section: SectionData): string {