async function loadCollections(): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch("/api/collections", { headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { const data = await response.json(); renderCollections(data.collections || []); } } catch (error) { console.error("Failed to load collections:", error); } } function renderCollections(collections: CollectionData[]): void { const container = document.getElementById("collections-list"); if (!container) return; if (collections.length === 0) { container.innerHTML = '

No collections yet

'; return; } container.innerHTML = collections .map( (collection) => `
${collection.icon || "📁"}

${collection.name}

${collection.description ? `

${collection.description}

` : ""}
`, ) .join(""); } async function loadCollectionRules(collectionId: string): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch(`/api/collections/${collectionId}/rules`, { headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { const rules: CollectionRule[] = await response.json(); renderRules(rules); } } catch (error) { console.error("Failed to load rules:", error); } } function renderRules(rules: CollectionRule[]): void { const container = document.getElementById("rules-list"); if (!container) return; if (rules.length === 0) { container.innerHTML = '

No rules defined

'; return; } container.innerHTML = rules .map( (rule) => `

${rule.field} ${rule.operator} "${rule.value}"

Priority: ${rule.priority} | ${rule.enabled ? "Enabled" : "Disabled"}

`, ) .join(""); } async function createRule( collectionId: string, rule: Partial, ): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch(`/api/collections/${collectionId}/rules`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(rule), }); if (response.ok) { if ((window as any).showToast?.success) { (window as any).showToast.success("Rule created"); } loadCollectionRules(collectionId); } else { const error = await response.json(); if ((window as any).showToast?.error) { (window as any).showToast.error(error.error || "Failed to create rule"); } } } catch (error) { console.error("Failed to create rule:", error); if ((window as any).showToast?.error) { (window as any).showToast.error("Failed to create rule"); } } } async function deleteRule(collectionId: string, ruleId: string): Promise { const token = localStorage.getItem("token"); if (!token) return; if (!confirm("Are you sure you want to delete this rule?")) return; try { const response = await fetch( `/api/collections/${collectionId}/rules/${ruleId}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }, ); if (response.ok) { if ((window as any).showToast?.success) { (window as any).showToast.success("Rule deleted"); } loadCollectionRules(collectionId); } } catch (error) { console.error("Failed to delete rule:", error); if ((window as any).showToast?.error) { (window as any).showToast.error("Failed to delete rule"); } } } async function testRule( collectionId: string, rule: Partial, ): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch( `/api/collections/${collectionId}/rules/test`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(rule), }, ); if (response.ok) { const results = await response.json(); renderTestResults(results); } } catch (error) { console.error("Failed to test rule:", error); if ((window as any).showToast?.error) { (window as any).showToast.error("Failed to test rule"); } } } function renderTestResults(results: unknown[]): void { const container = document.getElementById("test-results"); if (!container) return; if (!results || (Array.isArray(results) && results.length === 0)) { container.innerHTML = '

No matching books found

'; return; } container.innerHTML = `

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

`; } (window as any).loadCollections = loadCollections; (window as any).loadCollectionRules = loadCollectionRules; (window as any).createRule = createRule; (window as any).deleteRule = deleteRule; (window as any).testRule = testRule; // Add authorization header to all HTMX requests function setupHTMXAuth(): void { document.body.addEventListener("htmx:configRequest", function (evt: Event) { const token = localStorage.getItem("token"); (evt as any).detail.headers["Authorization"] = `Bearer ${token}`; }); } // Auto-initialize HTMX auth when DOM is ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", setupHTMXAuth); } else { setupHTMXAuth(); } 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 } } // Only navigate if clicking the card body const href = element.getAttribute("data-href"); if (href) { window.location.href = href; } } (window as any).navigateToCollection = navigateToCollection; // ============================================================================ // Collection Modal UI Helpers // ============================================================================ const borderClasses: Record = { blue: "border-blue-500", red: "border-red-500", yellow: "border-yellow-500", green: "border-green-500", purple: "border-purple-500", }; // Color selection for create/edit modal function selectColor(color: string): void { const colorInput = document.getElementById( "collection-color", ) as HTMLInputElement; 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"; }); const selectedBtn = document.querySelector( `.color-option[onclick="selectColor('${color}')"]`, ) as HTMLElement; if (selectedBtn) { selectedBtn.style.outline = "3px solid var(--text-primary)"; selectedBtn.style.outlineOffset = "3px"; } } // Close modal (removes from DOM) function closeCollectionModal(): void { const modal = document.querySelector(".fixed.inset-0"); if (modal && modal.parentElement) { modal.parentElement.remove(); } } // Initialize color selection on page load function initColorSelection(): void { const colorInput = document.getElementById( "collection-color", ) as HTMLInputElement; if (colorInput && colorInput.value) { 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]); } }); } // Auto-initialize when DOM is ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initColorSelection); } else { initColorSelection(); } // Export functions for global access (window as any).selectColor = selectColor; (window as any).closeCollectionModal = closeCollectionModal; (window as any).initColorSelection = initColorSelection; // Initialize icon grid when modal is loaded via HTMX function setupHTMXModalInit(): void { document.body.addEventListener("htmx:afterSwap", function (evt: Event) { const target = (evt as any).detail.target; if (target && target.id === "modal-container") { initIconSelection(); } }); } // Auto-initialize when DOM is ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", setupHTMXModalInit); } else { setupHTMXModalInit(); } // 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"], "📰": ["news", "newspaper", "press", "article"], "🗂️": ["card", "index", "organize", "file"], "📁": ["file", "folder", "directory"], "📂": ["folder", "open", "directory"], "📓": ["notebook", "book", "write"], "📔": ["notebook", "book"], "📕": ["book", "read", "red"], "📗": ["book", "read", "green"], "📘": ["book", "read", "blue"], "📙": ["book", "read", "orange"], // Favorites & Activities "⭐": ["star", "favorite", "like", "rating"], "❤️": ["heart", "love", "favorite", "like"], "🔥": ["fire", "hot", "popular", "trending", "flame"], "💡": ["idea", "bulb", "light", "think", "smart"], "🎯": ["target", "goal", "aim", "focus"], "🏆": ["trophy", "winner", "award", "achievement"], "🎨": ["art", "paint", "color", "creative"], "✅": ["check", "done", "complete", "success"], "❌": ["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"], "🏠": ["house", "home", "building"], "🏢": ["building", "office", "work", "company"], "🚗": ["car", "auto", "vehicle", "drive"], "✈️": ["plane", "airplane", "fly", "travel"], "🎮": ["game", "play", "video", "gaming"], }; // Helper: Get just the emoji list const allIcons = Object.keys(iconData); 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"; button.className = "icon-btn text-2xl p-1 hover:bg-opacity-80 rounded"; button.textContent = emoji; button.setAttribute("onclick", `selectIcon('${emoji}')`); button.setAttribute("data-keywords", keywords.join(",")); iconGrid.appendChild(button); }); } function selectIcon(icon: string): void { const iconInput = document.getElementById( "collection-icon", ) as HTMLInputElement; const searchInput = document.getElementById( "icon-search", ) as HTMLInputElement; 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 = ""; }); const selectedBtn = document.querySelector( `.icon-btn[onclick="selectIcon('${icon}')"]`, ) as HTMLElement; if (selectedBtn) { selectedBtn.style.outline = "2px solid var(--text-primary)"; 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"); const lowerSearchTerm = searchTerm.toLowerCase(); buttons.forEach((btn) => { const emoji = (btn as HTMLElement).textContent || ""; const keywords = iconData[emoji] || []; // Search in keywords OR emoji itself const matches = searchTerm === "" || emoji.includes(searchTerm) || keywords.some((keyword) => keyword.toLowerCase().includes(lowerSearchTerm), ); (btn as HTMLElement).style.display = matches ? "" : "none"; }); } function showAllIcons(): void { const iconGrid = document.getElementById("icon-grid"); if (!iconGrid) return; const buttons = iconGrid.querySelectorAll(".icon-btn"); buttons.forEach((btn) => { (btn as HTMLElement).style.display = ""; }); } function initIconSelection(): void { // First, populate the grid with all icons populateIconGrid(); // Then, set the current selection const iconInput = document.getElementById( "collection-icon", ) as HTMLInputElement; const searchInput = document.getElementById( "icon-search", ) as HTMLInputElement; if (iconInput && iconInput.value && searchInput) { searchInput.value = iconInput.value; selectIcon(iconInput.value); } } // Export for global access (window as any).selectIcon = selectIcon; (window as any).filterIcons = filterIcons; (window as any).showAllIcons = showAllIcons; (window as any).populateIconGrid = populateIconGrid; (window as any).initIconSelection = initIconSelection; // ============================================================================ // Collection Detail Page - TypeScript with WebSocket Support // ============================================================================ interface SearchBookResult { media_item_id: string; title: string; author: string | null; cover_image_path: string | null; library_id: string; library_name: string; } interface CollectionUpdateMessage { type: string; data: { collection_id: string; action: string; count?: number; book_id?: string; }; } let collectionId = ""; let libraryId = ""; let booksToAdd = new Set(); let booksToRemove = new Set(); let ws: WebSocket | null = null; // Initialize from data attributes (called on page load) function initCollectionDetail(): void { const dataEl = document.getElementById("collection-data"); if (dataEl) { collectionId = dataEl.dataset.id || ""; libraryId = dataEl.dataset.libraryId || ""; // Show/hide library filter toggle based on whether library_id is present const filterContainer = document.getElementById("library-filter-container"); if (filterContainer) { if (libraryId) { filterContainer.classList.remove("hidden"); // Default: checked (filter by library) const checkbox = document.getElementById( "filter-by-library", ) as HTMLInputElement; if (checkbox) checkbox.checked = true; } else { filterContainer.classList.add("hidden"); } } } // Initialize WebSocket connection connectWebSocket(); } // Get current library filter setting function getLibraryFilterParam(): string { if (!libraryId) return ""; const checkbox = document.getElementById( "filter-by-library", ) as HTMLInputElement; if (checkbox && checkbox.checked) { return `&library_id=${libraryId}`; } return ""; } // WebSocket connection for real-time collection updates function connectWebSocket(): void { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const token = localStorage.getItem("token"); if (!token) return; const wsUrl = `${protocol}//${window.location.host}/ws/sync?token=${token}`; ws = new WebSocket(wsUrl); ws.onopen = (): void => { console.log("WebSocket connected"); }; ws.onmessage = (event: MessageEvent): void => { try { const message = JSON.parse(event.data) as CollectionUpdateMessage; if ( message.type === "collection_updated" && message.data.collection_id === collectionId ) { const actionText = message.data.action === "books_added" ? `Added ${message.data.count || 0} book(s)` : message.data.action === "book_removed" ? "Removed a book" : message.data.action === "books_bulk_removed" ? `Removed ${message.data.count || 0} book(s)` : "Collection updated"; (window as any).showToast?.(actionText, "info"); // Mitigation: Skip auto-reload if user is actively typing or interacting const activeElement = document.activeElement; const isUserActive = activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.tagName === "SELECT" || activeElement.getAttribute("contenteditable") === "true"); if (!isUserActive) { // Auto-reload after 1 second to see updates (only if user not actively typing) setTimeout(() => { location.reload(); }, 1000); } else { // User is active - just show toast, don't reload // They'll see updates when they navigate away or manually refresh console.log("User actively typing - skipping auto-reload"); } } } catch (error) { console.error("Failed to parse WebSocket message:", error); } }; ws.onclose = (): void => { console.log("WebSocket disconnected, reconnecting in 5s..."); setTimeout(connectWebSocket, 5000); }; ws.onerror = (error: Event): void => { console.error("WebSocket error:", error); }; } function backToCollections(): void { window.location.href = "/collections"; } // Modal functions (called from onclick attributes) function showAddBooksModal(): void { const modal = document.getElementById("add-books-modal"); if (modal) modal.classList.remove("hidden"); booksToAdd.clear(); const results = document.getElementById("book-results"); if (results) { results.innerHTML = '

Enter at least 2 characters to search.

'; } } function hideAddBooksModal(): void { const modal = document.getElementById("add-books-modal"); if (modal) modal.classList.add("hidden"); const searchInput = document.getElementById( "book-search", ) as HTMLInputElement; if (searchInput) searchInput.value = ""; const results = document.getElementById("book-results"); if (results) results.innerHTML = ""; booksToAdd.clear(); } // Search books - includes library filter async function searchBooksForCollections(): Promise { const searchInput = document.getElementById( "book-search", ) as HTMLInputElement; const container = document.getElementById("book-results"); if (!searchInput || !container) return; const searchTerm = searchInput.value; if (searchTerm.length < 2) { container.innerHTML = '

Enter at least 2 characters to search.

'; return; } container.innerHTML = '

Searching...

'; const libraryFilter = getLibraryFilterParam(); const token = localStorage.getItem("token"); if (!token) { container.innerHTML = '

Authentication required

'; return; } try { const response = await fetch( `/api/media-items/search?q=${encodeURIComponent(searchTerm)}${libraryFilter}`, { headers: { Authorization: `Bearer ${token}` }, }, ); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const result = (await response.json()) as SearchBookResult[]; if (result.length > 0) { let html = '
'; result.slice(0, 50).forEach((book) => { const isSelected = booksToAdd.has(book.media_item_id); const checkedAttr = isSelected ? "checked" : ""; const authorHtml = book.author ? `
${book.author}
` : ""; const libraryBadge = book.library_id === libraryId ? 'This Library' : ""; html += `
Cover
${book.title}
${authorHtml}
${libraryBadge}
`; }); html += "
"; container.innerHTML = html; } else { container.innerHTML = '

No books found

'; } } catch (error) { console.error("Search error:", error); container.innerHTML = '

Failed to search books

'; } } // Toggle book selection function toggleBookSelection(bookId: string): void { if (booksToAdd.has(bookId)) { booksToAdd.delete(bookId); } else { booksToAdd.add(bookId); } searchBooksForCollections(); } // Add selected books to collection async function addbooksToAdd(): Promise { if (booksToAdd.size === 0) { (window as any).showToast?.("Please select at least one book", "error"); return; } const bookIds = Array.from(booksToAdd); const token = localStorage.getItem("token"); if (!token) { (window as any).showToast?.("Authentication required", "error"); return; } try { const response = await fetch(`/api/collections/${collectionId}/books`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ book_ids: bookIds }), }); if (response.ok) { (window as any).showToast?.( `Added ${bookIds.length} book(s) to collection`, "success", ); hideAddBooksModal(); // Note: WebSocket will trigger page reload automatically } else { (window as any).showToast?.("Failed to add books", "error"); } } catch (error) { console.error("Add books error:", error); (window as any).showToast?.("Failed to add books", "error"); } } // Remove single book from collection async function removeBook(bookId: string): Promise { if (!confirm("Remove this book from the collection?")) return; const token = localStorage.getItem("token"); if (!token) { (window as any).showToast?.("Authentication required", "error"); return; } try { const response = await fetch( `/api/collections/${collectionId}/books/${bookId}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }, ); if (response.ok) { (window as any).showToast?.("Book removed from collection", "success"); // Note: WebSocket will trigger page reload automatically } else { (window as any).showToast?.("Failed to remove book", "error"); } } catch (error) { console.error("Remove book error:", error); (window as any).showToast?.("Failed to remove book", "error"); } } // Bulk remove functions function toggleBookForRemoval(bookId: string): void { if (booksToRemove.has(bookId)) { booksToRemove.delete(bookId); } else { booksToRemove.add(bookId); } updateSelectedCount(); } function updateSelectedCount(): void { const count = booksToRemove.size; const countSpan = document.getElementById("selected-count"); const removeBtn = document.getElementById( "bulk-remove-btn", ) as HTMLButtonElement; if (count > 0) { if (countSpan) { countSpan.textContent = `${count} selected`; countSpan.classList.remove("hidden"); } if (removeBtn) removeBtn.disabled = false; } else { if (countSpan) countSpan.classList.add("hidden"); if (removeBtn) removeBtn.disabled = true; } } async function removebooksToAdd(): Promise { if (booksToRemove.size === 0) { (window as any).showToast?.("No books selected", "error"); return; } if (!confirm(`Remove ${booksToRemove.size} book(s) from the collection?`)) return; const bookIds = Array.from(booksToRemove); const token = localStorage.getItem("token"); if (!token) { (window as any).showToast?.("Authentication required", "error"); return; } try { const response = await fetch( `/api/collections/${collectionId}/books/bulk-remove`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ book_ids: bookIds }), }, ); if (response.ok) { const result = (await response.json()) as { removed: number }; if (result.removed > 0) { (window as any).showToast?.( `Removed ${result.removed} book(s) from collection`, "success", ); // Note: WebSocket will trigger page reload automatically } else { (window as any).showToast?.("Failed to remove books", "error"); } } else { (window as any).showToast?.("Failed to remove books", "error"); } } catch (error) { console.error("Bulk remove error:", error); (window as any).showToast?.("Failed to remove books", "error"); } } // Client-side search filter for displayed books function filterCollectionBooks(): void { const searchTerm = ( document.getElementById("collection-search") as HTMLInputElement )?.value.toLowerCase() || ""; const booksContainer = document.getElementById("books-container"); if (!booksContainer) return; const bookCards = booksContainer.children; for (let i = 0; i < bookCards.length; i++) { const card = bookCards[i] as HTMLElement; if (card.id === "empty-state") continue; const titleEl = card.querySelector(".font-semibold"); const authorEl = card.querySelector(".text-sm"); const title = titleEl?.textContent?.toLowerCase() || ""; const author = authorEl?.textContent?.toLowerCase() || ""; const matches = title.includes(searchTerm) || author.includes(searchTerm); card.style.display = matches || searchTerm === "" ? "" : "none"; } } // Export functions globally (window as any).initCollectionDetail = initCollectionDetail; (window as any).showAddBooksModal = showAddBooksModal; (window as any).hideAddBooksModal = hideAddBooksModal; (window as any).searchBooksForCollections = searchBooksForCollections; (window as any).toggleBookSelection = toggleBookSelection; (window as any).addbooksToAdd = addbooksToAdd; (window as any).removeBook = removeBook; (window as any).toggleBookForRemoval = toggleBookForRemoval; (window as any).updateSelectedCount = updateSelectedCount; (window as any).removebooksToAdd = removebooksToAdd; (window as any).filterCollectionBooks = filterCollectionBooks; (window as any).backToCollections = backToCollections; // Auto-initialize if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initCollectionDetail); } else { initCollectionDetail(); }