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 {