import { Alpine } from "./alpine"; import { showToast } from "./toast"; import { createWebSocket } from "./websocket"; let collectionId: string | null = null; function initializeCollectionWebSocket(): void { const dataEl = document.getElementById("collection-data"); if (!dataEl) return; collectionId = dataEl.dataset.id || null; if (!collectionId) return; createWebSocket({ onMessage: (message) => { 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"; showToast(actionText, "info"); const activeElement = document.activeElement; const isUserActive = activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.tagName === "SELECT" || activeElement.getAttribute("contenteditable") === "true"); if (!isUserActive) { setTimeout(() => { location.reload(); }, 1000); } } }, enableReconnect: true, reconnectDelay: 5000, }); } 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) { showToast("Rule created", "success"); loadCollectionRules(collectionId); } else { const error = await response.json(); showToast(error.error || "Failed to create rule", "error"); } } catch (error) { console.error("Failed to create rule:", error); showToast("Failed to create rule", "error"); } } 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) { showToast("Rule deleted", "success"); loadCollectionRules(collectionId); } } catch (error) { console.error("Failed to delete rule:", error); showToast("Failed to delete rule", "error"); } } 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); showToast("Failed to test rule", "error"); } } 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

`; } // 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}`; }); } 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; } } // ============================================================================ // 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.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]); } }); } // 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; if (target && target.id === "modal-container") { populateIconGrid(); Alpine.initTree(target); } }); } function showAllIcons(): void { // Populate grid if empty populateIconGrid(); // Clear search filter const searchInput = document.getElementById( "icon-search", ) as HTMLInputElement; if (searchInput) { searchInput.value = ""; } // Show all icons const iconGrid = document.getElementById("icon-grid"); if (!iconGrid) return; const buttons = iconGrid.querySelectorAll(".icon-btn"); buttons.forEach((btn) => { (btn as HTMLElement).style.display = ""; }); } // 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 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"; }); } // Export functions globally export { closeCollectionModal, createRule, deleteRule, filterIcons, initColorSelection, initializeCollectionWebSocket, loadCollectionRules, loadCollections, navigateToCollection, populateIconGrid, selectColor, selectIcon, setupHTMXAuth, setupHTMXModalInit, showAllIcons, testRule, }; Alpine.data("collections", () => ({ // Collection Methods closeCollectionModal, createRule, deleteRule, filterIcons, initColorSelection, initializeCollectionWebSocket, loadCollectionRules, loadCollections, navigateToCollection, populateIconGrid, selectColor, selectIcon, setupHTMXModalInit, showAllIcons, testRule, }));