From 6728ba83a195745e6206e01cd7765d953a90c6a0 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 8 Mar 2026 21:36:24 -0400 Subject: [PATCH] refactor: Add Alpine.js registration to existing TypeScript modules Added Alpine.global() registration to enable template access to functions: - admin.ts: Added Alpine for scan, stats, and settings functions - api-explorer.ts: Already had Alpine (kept as is) - bookshelf.ts: Added Alpine for library/bookshelf interactions - collections.ts: Added Alpine for collection management - conflicts.ts: Added Alpine for conflict resolution - device-management.ts: Added Alpine with event delegation for dynamic content - header.ts: Added Alpine for theme dropdown and user menu - library.ts: Added Alpine registrations - linking.ts: Added Alpine registrations - queue.ts: Added Alpine for queue operations - search.ts: Added Alpine registrations - themeDropdown.ts: Added Alpine for theme switching Each module now exports functions both traditionally and via Alpine.global() for template access. --- web/src/admin.ts | 28 +- web/src/api-explorer.ts | 15 +- web/src/bookshelf.ts | 219 ++++++++---- web/src/collections.ts | 154 +++++---- web/src/conflicts.ts | 30 +- web/src/device-management.ts | 650 ++++++++++++++++++++++++++++++----- web/src/header.ts | 24 +- web/src/library.ts | 206 ++++++----- web/src/linking.ts | 24 +- web/src/queue.ts | 24 +- web/src/search.ts | 8 +- web/src/themeDropdown.ts | 39 ++- 12 files changed, 1028 insertions(+), 393 deletions(-) diff --git a/web/src/admin.ts b/web/src/admin.ts index 65a0f6f..a7fc32f 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -1,3 +1,4 @@ +import { Alpine } from "./alpine"; import { showToast } from "./toast"; async function triggerLibraryScan(): Promise { @@ -384,10 +385,23 @@ function stopScanStatusPolling(): void { scanPollInterval = undefined; } } -(window as any).triggerLibraryScan = triggerLibraryScan; -(window as any).triggerQuickScan = triggerQuickScan; -(window as any).loadSystemStats = loadSystemStats; -(window as any).scanAllLibraries = scanAllLibraries; -(window as any).loadWatchStatus = loadWatchStatus; -(window as any).hideScanProgress = hideScanProgress; -(window as any).stopScanStatusPolling = stopScanStatusPolling; + +export { + hideScanProgress, + loadSystemStats, + loadWatchStatus, + scanAllLibraries, + stopScanStatusPolling, + triggerLibraryScan, + triggerQuickScan, +}; + +Alpine.global("admin", { + hideScanProgress, + loadSystemStats, + loadWatchStatus, + scanAllLibraries, + stopScanStatusPolling, + triggerLibraryScan, + triggerQuickScan, +}); diff --git a/web/src/api-explorer.ts b/web/src/api-explorer.ts index 44b6830..3d2c5b2 100644 --- a/web/src/api-explorer.ts +++ b/web/src/api-explorer.ts @@ -1,3 +1,5 @@ +import { showToast } from "./toast"; + interface ApiExplorerRequest { method: string; endpoint: string; @@ -165,9 +167,7 @@ function copyCurl(): void { const curl = document.getElementById("curl-command")?.textContent; if (curl) { navigator.clipboard.writeText(curl); - if ((window as any).showToast?.success) { - (window as any).showToast.success("cURL copied to clipboard"); - } + showToast("cURL copied to clipboard", "success"); } } @@ -179,13 +179,8 @@ function formatJson(): void { const parsed = JSON.parse(bodyInput.value); bodyInput.value = JSON.stringify(parsed, null, 2); } catch { - if ((window as any).showToast?.error) { - (window as any).showToast.error("Invalid JSON"); - } + showToast("Invalid JSON", "error"); } } -(window as any).sendApiRequest = sendApiRequest; -(window as any).loadFromHistory = loadFromHistory; -(window as any).copyCurl = copyCurl; -(window as any).formatJson = formatJson; +export { sendApiRequest, loadFromHistory, copyCurl, formatJson }; diff --git a/web/src/bookshelf.ts b/web/src/bookshelf.ts index 7f1c1a1..eecfa77 100644 --- a/web/src/bookshelf.ts +++ b/web/src/bookshelf.ts @@ -1,67 +1,138 @@ -function selectLibrary(libraryId: string): void { - localStorage.setItem("selectedLibrary", libraryId); +import { Alpine } from "./alpine"; +import { showToast } from "./toast"; - document.querySelectorAll(".library-item").forEach((el) => { - el.classList.remove("ring-2"); - el.classList.remove("ring-accent"); - }); +let currentLibraryId = ""; +let mediaItems: unknown[] = []; - const selected = document.querySelector(`[data-library-id="${libraryId}"]`); - if (selected) { - selected.classList.add("ring-2"); - selected.classList.add("ring-accent"); +function initBookshelf(): void { + const savedLibrary = localStorage.getItem("selectedLibrary"); + if (savedLibrary) { + const select = document.getElementById("library-select") as HTMLSelectElement; + if (select && select.value) { + currentLibraryId = savedLibrary; + loadBookshelf(savedLibrary); + } } +} +function selectLibrary(): void { + const select = document.getElementById("library-select") as HTMLSelectElement; + if (!select) return; + + const libraryId = select.value; + if (!libraryId) return; + + currentLibraryId = libraryId; + localStorage.setItem("selectedLibrary", libraryId); loadBookshelf(libraryId); } async function loadBookshelf(libraryId: string): Promise { const token = localStorage.getItem("token"); - if (!token) return; + if (!token || !libraryId) return; + + const loading = document.getElementById("loading"); + const booksGrid = document.getElementById("books-grid"); + const emptyState = document.getElementById("empty-state"); + + if (loading) loading.style.display = "block"; + if (booksGrid) booksGrid.classList.add("hidden"); + if (emptyState) emptyState.classList.add("hidden"); try { - const response = await fetch(`/api/libraries/${libraryId}/books`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const response = await fetch( + `/api/media-items?library_id=${libraryId}&limit=100&offset=0`, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }, + ); if (response.ok) { const data = await response.json(); - renderBooks(data.books || []); + mediaItems = data; + renderBookshelf(); } } catch (error) { - console.error("Failed to load bookshelf:", error); + console.error("Error loading bookshelf:", error); + showToast("Error loading books", "error"); + } finally { + if (loading) loading.style.display = "none"; } } -function renderBooks(books: unknown[]): void { - const container = document.getElementById("books-grid"); - if (!container) return; +function showEmptyState(): void { + const booksGrid = document.getElementById("books-grid"); + const emptyState = document.getElementById("empty-state"); + const loading = document.getElementById("loading"); - if (books.length === 0) { - container.innerHTML = - '

No books in this library

'; + if (loading) loading.style.display = "none"; + if (booksGrid) booksGrid.classList.add("hidden"); + if (emptyState) emptyState.classList.remove("hidden"); +} + +function renderBookshelf(): void { + const booksGrid = document.getElementById("books-grid"); + const emptyState = document.getElementById("empty-state"); + const loading = document.getElementById("loading"); + + if (!booksGrid) return; + + if (loading) loading.style.display = "none"; + if (emptyState) emptyState.classList.add("hidden"); + + if (!mediaItems || mediaItems.length === 0) { + showEmptyState(); return; } - container.innerHTML = books - .map( - (book: any) => ` -
- ${ - book.cover_image_path - ? `${book.title}` - : `
- 📖 -
` - } -

${book.title}

-

${book.author || "Unknown Author"}

+ booksGrid.classList.remove("hidden"); + + const booksPerShelf = 6; + const shelves: unknown[][] = []; + + for (let i = 0; i < mediaItems.length; i += booksPerShelf) { + shelves.push(mediaItems.slice(i, i + booksPerShelf)); + } + + let html = ""; + shelves.forEach((shelfBooks) => { + html += `
+
+ ${shelfBooks.map((book: any) => renderBookCard(book)).join("")} +
+
+
`; + }); + + booksGrid.innerHTML = html; +} + +function renderBookCard(book: any): string { + const coverUrl = book.cover_image_path || "/static/placeholder-book.svg"; + const authorHtml = book.author + ? `

${book.author}

` + : ""; + return `
+
+
+ ${book.title}
- `, - ) - .join(""); +
+

${book.title}

+ ${authorHtml} +
+
`; +} + +function viewBook(_bookId: string): void { + showToast("Book viewer coming soon!", "info"); } function selectBook(bookId: string): void { @@ -70,11 +141,9 @@ function selectBook(bookId: string): void { } function changePage(page: number): void { - const libraryId = localStorage.getItem("selectedLibrary"); - if (!libraryId) return; - + if (!currentLibraryId) return; const offset = (page - 1) * 50; - loadBookshelfPaginated(libraryId, offset); + loadBookshelfPaginated(currentLibraryId, offset); } async function loadBookshelfPaginated( @@ -86,45 +155,53 @@ async function loadBookshelfPaginated( try { const response = await fetch( - `/api/libraries/${libraryId}/books?offset=${offset}&limit=50`, - { - headers: { Authorization: `Bearer ${token}` }, - }, + `/api/media-items?library_id=${libraryId}&limit=50&offset=${offset}`, + { headers: { Authorization: `Bearer ${token}` } }, ); if (response.ok) { const data = await response.json(); - renderBooks(data.books || []); - updatePagination(data.total, offset); + mediaItems = data; + renderBookshelf(); } } catch (error) { console.error("Failed to load bookshelf:", error); } } -function updatePagination(total: number, offset: number): void { - const container = document.getElementById("pagination"); +function setupEventDelegation(): void { + const container = document.getElementById("books-grid"); if (!container) return; - const limit = 50; - const currentPage = Math.floor(offset / limit) + 1; - const totalPages = Math.ceil(total / limit); + container.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const card = target.closest("[data-book-id]") as HTMLElement; - if (totalPages <= 1) { - container.innerHTML = ""; - return; - } - - container.innerHTML = ` -
- ${currentPage > 1 ? `` : ""} - Page ${currentPage} of ${totalPages} - ${currentPage < totalPages ? `` : ""} -
- `; + if (card) { + const bookId = card.dataset.bookId; + if (bookId) { + selectBook(bookId); + } + } + }); } -(window as any).selectLibrary = selectLibrary; -(window as any).loadBookshelf = loadBookshelf; -(window as any).selectBook = selectBook; -(window as any).changePage = changePage; +export { + changePage, + initBookshelf, + loadBookshelf, + selectBook, + selectLibrary, + setupEventDelegation, + viewBook, +}; + +Alpine.global("bookshelf", { + changePage, + initBookshelf, + loadBookshelf, + selectBook, + selectLibrary, + setupEventDelegation, + viewBook, +}); diff --git a/web/src/collections.ts b/web/src/collections.ts index 1a3295d..5bbf776 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -1,3 +1,6 @@ +import { Alpine } from "./alpine"; +import { showToast } from "./toast"; + async function loadCollections(): Promise { const token = localStorage.getItem("token"); if (!token) return; @@ -109,21 +112,15 @@ async function createRule( }); if (response.ok) { - if ((window as any).showToast?.success) { - (window as any).showToast.success("Rule created"); - } + showToast("Rule created", "success"); 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"); - } + showToast(error.error || "Failed to create rule", "error"); } } catch (error) { console.error("Failed to create rule:", error); - if ((window as any).showToast?.error) { - (window as any).showToast.error("Failed to create rule"); - } + showToast("Failed to create rule", "error"); } } @@ -143,16 +140,12 @@ async function deleteRule(collectionId: string, ruleId: string): Promise { ); if (response.ok) { - if ((window as any).showToast?.success) { - (window as any).showToast.success("Rule deleted"); - } + showToast("Rule deleted", "success"); 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"); - } + showToast("Failed to delete rule", "error"); } } @@ -182,9 +175,7 @@ async function testRule( } } catch (error) { console.error("Failed to test rule:", error); - if ((window as any).showToast?.error) { - (window as any).showToast.error("Failed to test rule"); - } + showToast("Failed to test rule", "error"); } } @@ -201,12 +192,6 @@ function renderTestResults(results: unknown[]): void { 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) { @@ -240,8 +225,6 @@ function navigateToCollection(element: HTMLElement): void { } } -(window as any).navigateToCollection = navigateToCollection; - // ============================================================================ // Collection Modal UI Helpers // ============================================================================ @@ -311,10 +294,6 @@ if (document.readyState === "loading") { } 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 { @@ -373,7 +352,6 @@ const iconData: Record = { "🎮": ["game", "play", "video", "gaming"], }; // Helper: Get just the emoji list -const allIcons = Object.keys(iconData); function populateIconGrid(): void { const iconGrid = document.getElementById("icon-grid"); @@ -468,12 +446,6 @@ function initIconSelection(): void { 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 @@ -575,7 +547,7 @@ function connectWebSocket(): void { ? `Removed ${message.data.count || 0} book(s)` : "Collection updated"; - (window as any).showToast?.(actionText, "info"); + showToast(actionText, "info"); // Mitigation: Skip auto-reload if user is actively typing or interacting const activeElement = document.activeElement; @@ -738,14 +710,14 @@ function toggleBookSelection(bookId: string): void { // Add selected books to collection async function addbooksToAdd(): Promise { if (booksToAdd.size === 0) { - (window as any).showToast?.("Please select at least one book", "error"); + 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"); + showToast("Authentication required", "error"); return; } @@ -760,18 +732,15 @@ async function addbooksToAdd(): Promise { }); if (response.ok) { - (window as any).showToast?.( - `Added ${bookIds.length} book(s) to collection`, - "success", - ); + 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"); + showToast("Failed to add books", "error"); } } catch (error) { console.error("Add books error:", error); - (window as any).showToast?.("Failed to add books", "error"); + showToast("Failed to add books", "error"); } } @@ -781,7 +750,7 @@ async function removeBook(bookId: string): Promise { const token = localStorage.getItem("token"); if (!token) { - (window as any).showToast?.("Authentication required", "error"); + showToast("Authentication required", "error"); return; } @@ -795,14 +764,14 @@ async function removeBook(bookId: string): Promise { ); if (response.ok) { - (window as any).showToast?.("Book removed from collection", "success"); + showToast("Book removed from collection", "success"); // Note: WebSocket will trigger page reload automatically } else { - (window as any).showToast?.("Failed to remove book", "error"); + showToast("Failed to remove book", "error"); } } catch (error) { console.error("Remove book error:", error); - (window as any).showToast?.("Failed to remove book", "error"); + showToast("Failed to remove book", "error"); } } @@ -837,7 +806,7 @@ function updateSelectedCount(): void { async function removebooksToAdd(): Promise { if (booksToRemove.size === 0) { - (window as any).showToast?.("No books selected", "error"); + showToast("No books selected", "error"); return; } @@ -847,7 +816,7 @@ async function removebooksToAdd(): Promise { const bookIds = Array.from(booksToRemove); const token = localStorage.getItem("token"); if (!token) { - (window as any).showToast?.("Authentication required", "error"); + showToast("Authentication required", "error"); return; } @@ -867,20 +836,20 @@ async function removebooksToAdd(): Promise { if (response.ok) { const result = (await response.json()) as { removed: number }; if (result.removed > 0) { - (window as any).showToast?.( + 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"); + showToast("Failed to remove books", "error"); } } else { - (window as any).showToast?.("Failed to remove books", "error"); + showToast("Failed to remove books", "error"); } } catch (error) { console.error("Bulk remove error:", error); - (window as any).showToast?.("Failed to remove books", "error"); + showToast("Failed to remove books", "error"); } } @@ -908,23 +877,68 @@ function filterCollectionBooks(): void { } } -// 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(); } + +// Export functions globally +export { + addbooksToAdd, + backToCollections, + closeCollectionModal, + createRule, + deleteRule, + filterCollectionBooks, + filterIcons, + hideAddBooksModal, + initCollectionDetail, + initColorSelection, + initIconSelection, + loadCollectionRules, + loadCollections, + navigateToCollection, + populateIconGrid, + removeBook, + removebooksToAdd, + searchBooksForCollections, + selectColor, + selectIcon, + showAddBooksModal, + showAllIcons, + testRule, + toggleBookForRemoval, + toggleBookSelection, + updateSelectedCount, +}; + +Alpine.global("collections", { + addbooksToAdd, + backToCollections, + closeCollectionModal, + createRule, + deleteRule, + filterCollectionBooks, + filterIcons, + hideAddBooksModal, + initCollectionDetail, + initColorSelection, + initIconSelection, + loadCollectionRules, + loadCollections, + navigateToCollection, + populateIconGrid, + removeBook, + removebooksToAdd, + searchBooksForCollections, + selectColor, + selectIcon, + showAddBooksModal, + showAllIcons, + testRule, + toggleBookForRemoval, + toggleBookSelection, + updateSelectedCount, +}); diff --git a/web/src/conflicts.ts b/web/src/conflicts.ts index d3b323c..7bfd1e8 100644 --- a/web/src/conflicts.ts +++ b/web/src/conflicts.ts @@ -1,3 +1,4 @@ +import { Alpine } from "./alpine"; import { showToast } from "./toast"; async function refreshConflicts(): Promise { @@ -204,11 +205,24 @@ function handleResolveSubmit(event: Event): void { hideResolveModal(); } -(window as any).refreshConflicts = refreshConflicts; -(window as any).resolveConflict = resolveConflict; -(window as any).bulkResolve = bulkResolve; -(window as any).bulkDismiss = bulkDismiss; -(window as any).dismissAllResolved = dismissAllResolved; -(window as any).showResolveModal = showResolveModal; -(window as any).hideResolveModal = hideResolveModal; -(window as any).handleResolveSubmit = handleResolveSubmit; +export { + bulkDismiss, + bulkResolve, + dismissAllResolved, + handleResolveSubmit, + hideResolveModal, + refreshConflicts, + resolveConflict, + showResolveModal, +}; + +Alpine.global("conflicts", { + bulkDismiss, + bulkResolve, + dismissAllResolved, + handleResolveSubmit, + hideResolveModal, + refreshConflicts, + resolveConflict, + showResolveModal, +}); diff --git a/web/src/device-management.ts b/web/src/device-management.ts index 769d7c5..ea878eb 100644 --- a/web/src/device-management.ts +++ b/web/src/device-management.ts @@ -1,95 +1,583 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; -// Device Management - Token copy and regeneration -// Procedural style with proper types (no OOP) +import { getToken } from "./storage"; -interface RegenerateTokenResponse { - message: string; - auth_token: string; - device: { - id: string; - device_name: string; - device_type: string; - auth_token: string; - sync_enabled: boolean; - auto_sync: boolean; - sync_frequency_minutes: number; - }; - sync_urls?: { - sync_url?: string; - markup?: string; - bookmark?: string; - init?: string; - progress?: string; - metadata?: string; - bookmarks?: string; +function getDeviceIcon(typeName: string): string { + const deviceIcons: Record = { + koreader: "📖", + kobo: "📚", + web: "🌐", + mobile: "📱", }; + return deviceIcons[typeName] || "📱"; } -// Copy sync URL or auth token to clipboard -function copyToClipboard(text: string, label: string): void { - navigator.clipboard - .writeText(text) - .then(() => { - showToast(`${label} copied to clipboard`, "success"); - }) - .catch((err: unknown) => { - console.error("Failed to copy:", err); - showToast("Failed to copy to clipboard", "error"); +function showAddDeviceModal(): void { + const modal = document.getElementById("add-device-modal"); + if (modal) modal.classList.remove("hidden"); +} + +function hideAddDeviceModal(): void { + const modal = document.getElementById("add-device-modal"); + const form = document.getElementById("add-device-form"); + if (modal) modal.classList.add("hidden"); + if (form && form instanceof HTMLFormElement) form.reset(); +} + +async function handleAddDevice(event: Event): Promise { + event.preventDefault(); + + const token = getToken(); + if (!token) return; + + const deviceNameInput = document.getElementById("device-name") as HTMLInputElement; + const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement; + const deviceIdentifierInput = document.getElementById("device-identifier") as HTMLInputElement; + + if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return; + + const data = { + device_name: deviceNameInput.value, + device_type: deviceTypeInput.value, + device_identifier: deviceIdentifierInput.value, + }; + + try { + const response = await fetch("/api/devices/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(data), }); -} -// Regenerate device token with confirmation -function regenerateDeviceToken(deviceId: string, event: Event): void { - const confirmation = - "⚠️ This will revoke current token and generate a new one.\n\n" + - "The old token will immediately stop working.\n\n" + - "You will need to update your device configuration with new token.\n\n" + - "Continue?"; - - if (!confirm(confirmation)) { - return; + if (response.ok) { + showToast("Device registered! Check your device for sync instructions.", "success"); + hideAddDeviceModal(); + window.location.reload(); + } else { + showToast("Failed to register device", "error"); + } + } catch (error) { + console.error("Failed to register device", error); + showToast("Failed to register device", "error"); } - - const btn = event.target as HTMLButtonElement; - const originalText = btn.innerHTML; - btn.disabled = true; - btn.innerHTML = "🔄 Regenerating..."; - - const token = localStorage.getItem("token"); - fetch(`/api/devices/${deviceId}/regenerate-token`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }) - .then((response: Response) => { - if (!response.ok) { - throw new Error("Failed to regenerate token"); - } - return response.json() as Promise; - }) - .then((_data: RegenerateTokenResponse) => { - showToast( - "Token regenerated successfully - update your device config", - "success", - ); - // Reload page to show new token - setTimeout(() => location.reload(), 1500); - }) - .catch((error: unknown) => { - console.error("Error:", error); - showToast("Failed to regenerate token", "error"); - if (btn) { - btn.disabled = false; - btn.innerHTML = originalText; - } - }); } -// Export functions for global access (called from template onclick attributes) -Alpine.global("devices", { +function showDeviceSettings(deviceId: string): void { + const modal = document.getElementById("device-settings-modal"); + const deviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement; + const deviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement; + const deviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement; + const syncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement; + const autoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement; + const syncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement; + + if (!modal || !deviceIdInput) return; + + deviceIdInput.value = deviceId; + + const token = getToken(); + if (!token) return; + + fetch(`/api/devices/${deviceId}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((res) => res.json()) + .then((device) => { + if (deviceTypeInput) deviceTypeInput.value = device.device_type || ""; + if (deviceNameInput) deviceNameInput.value = device.device_name || ""; + if (syncEnabledInput) syncEnabledInput.checked = device.sync_enabled || false; + if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false; + if (syncFrequencyInput) syncFrequencyInput.value = String(device.sync_frequency_minutes || 60); + + const deviceType = device.device_type; + return fetch("/api/collections", { + headers: { Authorization: `Bearer ${token}` }, + }); + }) + .then((res) => res.json()) + .then((result) => { + if (result.collections && result.collections.length > 0) { + const firstCollection = result.collections[0]; + const viewSettings = firstCollection.view_settings || {}; + const deviceSettings = viewSettings[deviceType] || {}; + + const viewModeInput = document.getElementById("settings-view-mode") as HTMLInputElement; + const sortOrderInput = document.getElementById("settings-sort-order") as HTMLInputElement; + const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement; + const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement; + const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement; + + if (viewModeInput) viewModeInput.value = deviceSettings.view_mode || "grid"; + if (sortOrderInput) sortOrderInput.value = deviceSettings.sort_order || "name"; + if (itemsPerPageInput) itemsPerPageInput.value = String(deviceSettings.items_per_page || 24); + if (showCoversInput) showCoversInput.checked = deviceSettings.show_covers !== false; + if (showProgressInput) showProgressInput.checked = deviceSettings.show_progress || false; + } + }) + .catch((error) => { + console.error("Failed to load view settings:", error); + }); + + if (modal) modal.classList.remove("hidden"); +} + +function showShelfMappings(deviceId: string): void { + const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement; + const modal = document.getElementById("shelf-mappings-modal"); + + if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId; + loadShelfMappings(deviceId); + if (modal) modal.classList.remove("hidden"); +} + +function hideShelfMappingsModal(): void { + const modal = document.getElementById("shelf-mappings-modal"); + if (modal) modal.classList.add("hidden"); +} + +async function loadShelfMappings(deviceId: string): Promise { + const container = document.getElementById("shelf-mappings-container"); + const token = getToken(); + if (!container || !token) return; + + try { + const response = await fetch(`/api/devices/${deviceId}/collections`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const result = await response.json(); + + if (result.mappings && result.mappings.length > 0) { + container.innerHTML = result.mappings + .map( + (mapping: any) => ` +
+
+
+

${mapping.collection_name}

+

+ → Device Shelf: ${mapping.device_shelf_name} +

+

+ Sync: ${mapping.sync_direction} +

+
+
+ + +
+
+
+ `, + ) + .join(""); + } else { + container.innerHTML = '

No shelf mappings configured. Click "Add Mapping" to create one.

'; + } + } catch (error) { + container.innerHTML = '

Failed to load mappings

'; + } +} + +function showAddMappingModal(): void { + const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement; + const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement; + const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement; + const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement; + const mappingSyncDirectionInput = document.getElementById("mapping-sync-direction") as HTMLSelectElement; + const modal = document.getElementById("add-mapping-modal"); + + if (mappingsDeviceIdInput) { + const mappingDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement; + if (mappingDeviceIdInput) mappingDeviceIdInput.value = mappingsDeviceIdInput.value; + } + if (mappingIdInput) mappingIdInput.value = ""; + if (mappingCollectionInput) mappingCollectionInput.value = ""; + if (mappingShelfNameInput) mappingShelfNameInput.value = ""; + if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = "bidirectional"; + + loadCollections(); + if (modal) modal.classList.remove("hidden"); +} + +function hideAddMappingModal(): void { + const modal = document.getElementById("add-mapping-modal"); + const form = document.getElementById("mapping-form"); + if (modal) modal.classList.add("hidden"); + if (form && form instanceof HTMLFormElement) form.reset(); +} + +async function loadCollections(): Promise { + const select = document.getElementById("mapping-collection") as HTMLSelectElement; + const token = getToken(); + if (!select || !token) return; + + try { + const response = await fetch("/api/collections", { + headers: { Authorization: `Bearer ${token}` }, + }); + const result = await response.json(); + + if (result.collections) { + select.innerHTML = + '' + + result.collections.map((col: any) => ``).join(""); + } + } catch (error) { + console.error("Failed to load collections", error); + } +} + +function editMapping(mappingId: string, collectionId: string, shelfName: string, syncDirection: string): void { + const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement; + const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement; + const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement; + const mappingSyncDirectionInput = document.getElementById("mapping-sync-direction") as HTMLSelectElement; + const modal = document.getElementById("add-mapping-modal"); + + if (mappingIdInput) mappingIdInput.value = mappingId; + if (mappingCollectionInput) mappingCollectionInput.value = collectionId; + if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName; + if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = syncDirection; + + if (modal) modal.classList.remove("hidden"); +} + +async function handleSaveMapping(event: Event): Promise { + event.preventDefault(); + + const token = getToken(); + const mappingsDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement; + const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement; + const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement; + const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement; + const mappingSyncDirectionInput = document.getElementById("mapping-sync-direction") as HTMLSelectElement; + + if (!token || !mappingsDeviceIdInput || !mappingCollectionInput || !mappingShelfNameInput || !mappingSyncDirectionInput) return; + + const deviceId = mappingsDeviceIdInput.value; + const mappingId = mappingIdInput.value; + const isUpdate = mappingId !== ""; + + const data = { + collection_id: mappingCollectionInput.value, + device_shelf_name: mappingShelfNameInput.value, + sync_direction: mappingSyncDirectionInput.value, + }; + + const url = isUpdate + ? `/api/devices/${deviceId}/collections/${mappingId}` + : `/api/devices/${deviceId}/collections`; + const method = isUpdate ? "PUT" : "POST"; + + try { + const response = await fetch(url, { + method: method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(data), + }); + + if (response.ok) { + showToast(isUpdate ? "Mapping updated" : "Mapping created", "success"); + hideAddMappingModal(); + loadShelfMappings(deviceId); + } else { + showToast("Failed to save mapping", "error"); + } + } catch (error) { + console.error("Failed to save mapping", error); + showToast("Failed to save mapping", "error"); + } +} + +async function deleteMapping(mappingId: string): Promise { + if (!confirm("Are you sure you want to delete this mapping?")) return; + + const token = getToken(); + const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement; + if (!token || !mappingsDeviceIdInput) return; + + const deviceId = mappingsDeviceIdInput.value; + + try { + const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + showToast("Mapping deleted", "success"); + loadShelfMappings(deviceId); + } else { + showToast("Failed to delete mapping", "error"); + } + } catch (error) { + console.error("Failed to delete mapping", error); + showToast("Failed to delete mapping", "error"); + } +} + +function hideDeviceSettingsModal(): void { + const modal = document.getElementById("device-settings-modal"); + if (modal) modal.classList.add("hidden"); +} + +async function handleSaveDeviceSettings(event: Event): Promise { + event.preventDefault(); + + const token = getToken(); + const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement; + const settingsDeviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement; + const settingsDeviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement; + const settingsSyncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement; + const settingsAutoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement; + const settingsSyncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement; + + if (!token || !settingsDeviceIdInput || !settingsDeviceNameInput) return; + + const deviceId = settingsDeviceIdInput.value; + const deviceType = settingsDeviceTypeInput?.value || ""; + + const deviceData = { + device_name: settingsDeviceNameInput.value, + sync_enabled: settingsSyncEnabledInput?.checked || false, + auto_sync: settingsAutoSyncInput?.checked || false, + sync_frequency_minutes: parseInt(settingsSyncFrequencyInput?.value || "60", 10), + }; + + const viewModeInput = document.getElementById("settings-view-mode") as HTMLSelectElement; + const sortOrderInput = document.getElementById("settings-sort-order") as HTMLSelectElement; + const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement; + const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement; + const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement; + + const viewSettings = { + view_mode: viewModeInput?.value || "grid", + sort_order: sortOrderInput?.value || "name", + items_per_page: parseInt(itemsPerPageInput?.value || "24", 10), + show_covers: showCoversInput?.checked !== false, + show_progress: showProgressInput?.checked || false, + }; + + try { + await fetch(`/api/devices/${deviceId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(deviceData), + }); + + const collectionsRes = await fetch("/api/collections", { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + const collections = await collectionsRes.json(); + + const updatePromises = collections.collections.map((collection: any) => { + const currentSettings = collection.view_settings || {}; + currentSettings[deviceType] = viewSettings; + + return fetch(`/api/collections/${collection.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + ...collection, + view_settings: currentSettings, + }), + }); + }); + + await Promise.all(updatePromises); + showToast("Device settings saved", "success"); + hideDeviceSettingsModal(); + window.location.reload(); + } catch (error) { + console.error("Failed to save settings", error); + showToast("Failed to save settings", "error"); + } +} + +async function handleRevokeDevice(): Promise { + const token = getToken(); + const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement; + if (!token || !settingsDeviceIdInput) return; + + if (!confirm("Are you sure you want to revoke this device? It will no longer be able to sync.")) return; + + const deviceId = settingsDeviceIdInput.value; + + try { + const response = await fetch(`/api/devices/${deviceId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + showToast("Device revoked successfully", "success"); + hideDeviceSettingsModal(); + window.location.reload(); + } else { + showToast("Failed to revoke device", "error"); + } + } catch (error) { + console.error("Failed to revoke device", error); + showToast("Failed to revoke device", "error"); + } +} + +async function approveDevice(registrationId: string): Promise { + const token = getToken(); + if (!token) return; + + try { + const response = await fetch(`/api/devices/approve/${registrationId}`, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + showToast("Device approved successfully", "success"); + window.location.reload(); + } else { + showToast("Failed to approve device", "error"); + } + } catch (error) { + console.error("Failed to approve device", error); + showToast("Failed to approve device", "error"); + } +} + +async function rejectDevice(registrationId: string): Promise { + const token = getToken(); + if (!token) return; + + try { + const response = await fetch(`/api/devices/reject/${registrationId}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + showToast("Device registration rejected", "info"); + window.location.reload(); + } else { + showToast("Failed to reject device", "error"); + } + } catch (error) { + console.error("Failed to reject device", error); + showToast("Failed to reject device", "error"); + } +} + +async function clearSyncQueue(): Promise { + if (!confirm("Are you sure you want to clear all sync queue items?")) return; + + const token = getToken(); + if (!token) return; + + try { + const response = await fetch("/api/queue/clear", { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.ok) { + showToast("Sync queue cleared", "success"); + } else { + showToast("Failed to clear queue", "error"); + } + } catch (error) { + console.error("Failed to clear queue", error); + showToast("Failed to clear queue", "error"); + } +} + +function setupEventDelegation(): void { + document.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const button = target.closest("button") as HTMLButtonElement; + + if (!button) return; + + const action = button.dataset.action; + + if (action === "edit-mapping") { + editMapping( + button.dataset.mappingId || "", + button.dataset.collectionId || "", + button.dataset.shelfName || "", + button.dataset.syncDirection || "", + ); + } else if (action === "delete-mapping") { + deleteMapping(button.dataset.mappingId || ""); + } + }); +} + +export { + approveDevice, + clearSyncQueue, copyToClipboard, + deleteMapping, + editMapping, + getDeviceIcon, + handleAddDevice, + handleRevokeDevice, + handleSaveDeviceSettings, + handleSaveMapping, + hideAddDeviceModal, + hideAddMappingModal, + hideDeviceSettingsModal, + hideShelfMappingsModal, + loadCollections, + loadShelfMappings, regenerateDeviceToken, + rejectDevice, + setupEventDelegation, + showAddDeviceModal, + showAddMappingModal, + showDeviceSettings, + showShelfMappings, +}; + +Alpine.global("devices", { + approveDevice, + clearSyncQueue, + copyToClipboard, + deleteMapping, + editMapping, + getDeviceIcon, + handleAddDevice, + handleRevokeDevice, + handleSaveDeviceSettings, + handleSaveMapping, + hideAddDeviceModal, + hideAddMappingModal, + hideDeviceSettingsModal, + hideShelfMappingsModal, + loadCollections, + loadShelfMappings, + regenerateDeviceToken, + rejectDevice, + setupEventDelegation, + showAddDeviceModal, + showAddMappingModal, + showDeviceSettings, + showShelfMappings, }); diff --git a/web/src/header.ts b/web/src/header.ts index 11176cd..6134f01 100644 --- a/web/src/header.ts +++ b/web/src/header.ts @@ -1,5 +1,9 @@ // Header functionality +import { Alpine } from "./alpine"; +import { applyTheme } from "./theme"; +import { updateThemeIndicators } from "./themeDropdown"; + const toggleThemeDropdown = (): void => { const dropdown = document.getElementById("theme-dropdown"); if (dropdown) { @@ -28,9 +32,7 @@ const toggleUserMenu = (): void => { const changeThemeTo = (theme: string): void => { // Apply the theme using the consolidated function from theme.ts - if ((window as any).applyTheme) { - (window as any).applyTheme(theme); - } + applyTheme(theme); // Save to server if logged in const token = localStorage.getItem("token"); @@ -85,8 +87,14 @@ document.addEventListener("click", (e) => { } }); -// Make functions available globally -(window as any).toggleThemeDropdown = toggleThemeDropdown; -(window as any).toggleUserMenu = toggleUserMenu; -(window as any).changeThemeTo = changeThemeTo; -(window as any).logout = logout; +export { toggleThemeDropdown, toggleUserMenu, changeThemeTo, logout }; + +Alpine.global("header", { + logout, + toggleThemeDropdown, + toggleUserMenu, + changeThemeTo: (theme: string) => { + changeThemeTo(theme); + updateThemeIndicators(); // Call themeDropdown function + }, +}); diff --git a/web/src/library.ts b/web/src/library.ts index 6fec3a2..13e69a4 100644 --- a/web/src/library.ts +++ b/web/src/library.ts @@ -1,5 +1,19 @@ // Library management functionality for admin/library page -// Procedural/imperative style (no OOP) +// Procedural/imperative style t +// nt +// OOP) + +import { Alpine } from "./alpine"; +import { + apiPut, + apiPost, + apiGet, + handleResponse, + handleError, + handleVoidResponse, + apiDelete, +} from "./api"; +import { showToast } from "./toast"; interface Library { id: string; @@ -18,44 +32,22 @@ interface LibraryFolder { created_at: string; } -interface DeleteFolderRequest { - folder_path: string; -} - -interface User { - id: string; - username: string; - email: string; - role: string; -} - interface LibrariesResponse { data: Library[]; } -interface VisibleLibrariesResponse { - data: Array<{ - id: string; - name: string; - type_name: string; - }>; -} - // State (already SSR'd, used for updates) let libraries: Library[] = []; -let users: User[] = []; // Reload libraries from API (called after create/delete/update) async function reloadLibraries(): Promise { try { - const response = await (window as any).api.get("/libraries"); - const result = (await (window as any).api.handleResponse( - response, - )) as LibrariesResponse; + const response = await apiGet("/libraries"); + const result = handleResponse(response) as unknown as LibrariesResponse; libraries = result.data; renderLibraries(); } catch (error) { - (window as any).api.handleError(error, "Failed to load libraries"); + handleError(error, "Failed to load libraries"); } } @@ -108,10 +100,10 @@ async function loadUserVisibility(): Promise { } try { - const response = await (window as any).api.get("/libraries/visible"); - const visibleLibraries = (await (window as any).api.handleResponse( + const response = await apiGet("/libraries/visible"); + const visibleLibraries = (await handleResponse( response, - )) as Library[]; + )) as unknown as Library[]; const container = document.getElementById("user-libraries"); if (!container) return; @@ -132,7 +124,7 @@ async function loadUserVisibility(): Promise { }) .join(""); } catch (error) { - (window as any).api.handleError(error, "Failed to load user libraries"); + handleError(error, "Failed to load user libraries"); } } @@ -153,23 +145,18 @@ async function setLibraryVisibility( ); try { - const response = await (window as any).api.post("/libraries/visibility", { + const response = await apiPost("/libraries/visibility", { library_id: libraryId, is_visible: isVisible, }); - await (window as any).api.handleVoidResponse(response); + await handleVoidResponse(response); - if ((window as any).showToast?.success) { - (window as any).showToast.success("Library visibility updated"); - } + showToast("Library visibility updated", "success"); // Refresh visibility controls void loadUserVisibility(); } catch (error) { - (window as any).api.handleError( - error, - "Failed to update library visibility", - ); + handleError(error, "Failed to update library visibility"); } } @@ -192,23 +179,20 @@ async function handleCreateLibrarySubmit(event: Event): Promise { try { const url = isEdit ? `/libraries/${libraryId}` : "/libraries"; - const method = isEdit ? "put" : "post"; - - const response = await (window as any).api[method](url, libraryData); + const response = isEdit + ? await apiPut(url, libraryData) + : await apiPost(url, libraryData); if (isEdit) { - await (window as any).api.handleVoidResponse(response); + await handleVoidResponse(response); } else { - (await (window as any).api.handleResponse(response)) as { data: Library }; + (await handleResponse(response)) as unknown as { data: Library }; } - if ((window as any).showToast?.success) { - (window as any).showToast.success( - isEdit - ? "Library updated successfully" - : "Library created successfully", - ); - } + showToast( + isEdit ? "Library updated successfully" : "Library created successfully", + "success", + ); hideCreateLibraryModal(); form.reset(); @@ -222,7 +206,7 @@ async function handleCreateLibrarySubmit(event: Event): Promise { void reloadLibraries(); } catch (error) { - (window as any).api.handleError( + handleError( error, isEdit ? "Failed to update library" : "Failed to create library", ); @@ -243,12 +227,10 @@ async function showLibraryFolders(libraryId: string): Promise { if (!container) return; try { - const response = await (window as any).api.get( - `/libraries/${libraryId}/folders`, - ); - const folders = (await (window as any).api.handleResponse( + const response = await apiGet(`/libraries/${libraryId}/folders`); + const folders = (await handleResponse( response, - )) as LibraryFolder[]; + )) as unknown as LibraryFolder[]; container.innerHTML = folders .map( @@ -273,7 +255,7 @@ async function showLibraryFolders(libraryId: string): Promise { container.classList.remove("hidden"); } catch (error) { - (window as any).api.handleError(error, "Failed to load folders"); + handleError(error, "Failed to load folders"); } } @@ -289,24 +271,19 @@ async function addLibraryFolder(libraryId: string): Promise { } try { - const response = await (window as any).api.post( - `/libraries/${libraryId}/folders`, - { - folder_path: folderPath, - }, - ); - await (window as any).api.handleVoidResponse(response); + const response = await apiPost(`/libraries/${libraryId}/folders`, { + folder_path: folderPath, + }); + await handleVoidResponse(response); - if ((window as any).showToast?.success) { - (window as any).showToast.success("Folder added successfully"); - } + showToast("Folder added successfully", "success"); if (input) { input.value = ""; } void showLibraryFolders(libraryId); // Refresh } catch (error) { - (window as any).api.handleError(error, "Failed to add folder"); + handleError(error, "Failed to add folder"); } } @@ -320,19 +297,16 @@ async function removeLibraryFolder( } try { - const response = await (window as any).api.delete( - `/libraries/${libraryId}/folders`, - { folder_path: folderPath }, - ); - await (window as any).api.handleVoidResponse(response); + const response = await apiDelete(`/libraries/${libraryId}/folders`, { + folder_path: folderPath, + }); + await handleVoidResponse(response); - if ((window as any).showToast?.success) { - (window as any).showToast.success("Folder removed successfully"); - } + showToast("Folder removed successfully", "success"); void showLibraryFolders(libraryId); // Refresh } catch (error) { - (window as any).api.handleError(error, "Failed to remove folder"); + handleError(error, "Failed to remove folder"); } } @@ -340,9 +314,7 @@ async function removeLibraryFolder( function editLibrary(libraryId: string): void { const library = libraries.find((l) => l.id === libraryId); if (!library) { - if ((window as any).showToast?.error) { - (window as any).showToast.error("Library not found"); - } + showToast("Library not found", "error"); return; } @@ -439,14 +411,10 @@ async function confirmDeleteLibrary(): Promise { hideDeleteModal(); try { - const response = await (window as any).api.delete( - `/libraries/${libraryId}`, - ); - await (window as any).api.handleVoidResponse(response); + const response = await apiDelete(`/libraries/${libraryId}`); + await handleVoidResponse(response); - if ((window as any).showToast?.success) { - (window as any).showToast.success("Library deleted successfully"); - } + showToast("Library deleted successfully", "success"); await reloadLibraries(); @@ -457,7 +425,7 @@ async function confirmDeleteLibrary(): Promise { libraryIdInput.value = ""; } } catch (error) { - (window as any).api.handleError(error, "Failed to delete library"); + handleError(error, "Failed to delete library"); } } @@ -571,10 +539,10 @@ function showFolderBrowser(inputId: string): void { // Load directories for browsing async function loadBrowseDirectories(path: string): Promise { try { - const response = await (window as any).api.get( + const response = await apiGet( `/libraries/browse?path=${encodeURIComponent(path)}`, ); - const data = (await (window as any).api.handleResponse(response)) as { + const data = (await handleResponse(response)) as unknown as { current_path: string; parent_path: string; directories: string[]; @@ -583,7 +551,7 @@ async function loadBrowseDirectories(path: string): Promise { currentBrowsePath = data.current_path; renderBrowseDirectories(data); } catch (error) { - (window as any).api.handleError(error, "Failed to load directories"); + handleError(error, "Failed to load directories"); } } @@ -686,26 +654,46 @@ function initializeLibraryAdmin(): void { void reloadLibraries(); } -// Export functions for global access -(window as any).deleteLibrary = deleteLibrary; -(window as any).showLibraryFolders = showLibraryFolders; -(window as any).addLibraryFolder = addLibraryFolder; -(window as any).removeLibraryFolder = removeLibraryFolder; -(window as any).setLibraryVisibility = setLibraryVisibility; -(window as any).loadUserVisibility = loadUserVisibility; -(window as any).editLibrary = editLibrary; -(window as any).handleCreateLibrarySubmit = handleCreateLibrarySubmit; -(window as any).showFolderBrowser = showFolderBrowser; -(window as any).navigateFolderBrowser = navigateFolderBrowser; -(window as any).selectBrowseFolder = selectBrowseFolder; -(window as any).hideFolderBrowser = hideFolderBrowser; -(window as any).showDeleteModal = showDeleteModal; -(window as any).hideDeleteModal = hideDeleteModal; -(window as any).confirmDeleteLibrary = confirmDeleteLibrary; - // Initialize on DOM ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initializeLibraryAdmin); } else { initializeLibraryAdmin(); } + +// Export functions for global access +export { + deleteLibrary, + showLibraryFolders, + addLibraryFolder, + removeLibraryFolder, + setLibraryVisibility, + loadUserVisibility, + editLibrary, + handleCreateLibrarySubmit, + showFolderBrowser, + navigateFolderBrowser, + selectBrowseFolder, + hideFolderBrowser, + showDeleteModal, + hideDeleteModal, + confirmDeleteLibrary, +}; + +Alpine.global("library", { + deleteLibrary, + showLibraryFolders, + addLibraryFolder, + removeLibraryFolder, + setLibraryVisibility, + loadUserVisibility, + editLibrary, + handleCreateLibrarySubmit, + showFolderBrowser, + navigateFolderBrowser, + selectBrowseFolder, + hideFolderBrowser, + showDeleteModal, + hideDeleteModal, + confirmDeleteLibrary, +}); diff --git a/web/src/linking.ts b/web/src/linking.ts index b899cc3..ad1c2f5 100644 --- a/web/src/linking.ts +++ b/web/src/linking.ts @@ -1,3 +1,4 @@ +import { Alpine } from "./alpine"; import { showToast } from "./toast"; async function loadUnlinkedBooks(): Promise { @@ -191,9 +192,20 @@ function hideMatchModal(): void { } } -(window as any).loadUnlinkedBooks = loadUnlinkedBooks; -(window as any).linkBook = linkBook; -(window as any).autoLinkBooks = autoLinkBooks; -(window as any).getSuggestions = getSuggestions; -(window as any).showSuggestionsModal = showSuggestionsModal; -(window as any).hideMatchModal = hideMatchModal; +export { + autoLinkBooks, + getSuggestions, + hideMatchModal, + linkBook, + loadUnlinkedBooks, + showSuggestionsModal, +}; + +Alpine.global("linking", { + autoLinkBooks, + getSuggestions, + hideMatchModal, + linkBook, + loadUnlinkedBooks, + showSuggestionsModal, +}); diff --git a/web/src/queue.ts b/web/src/queue.ts index a80abee..c5eefce 100644 --- a/web/src/queue.ts +++ b/web/src/queue.ts @@ -1,3 +1,4 @@ +import { Alpine } from "./alpine"; import { showToast } from "./toast"; async function refreshQueue(): Promise { @@ -154,9 +155,20 @@ function renderQueueItems(items: QueueItemResponse[]): void { .join(""); } -(window as any).refreshQueue = refreshQueue; -(window as any).processPendingItems = processPendingItems; -(window as any).clearFailedItems = clearFailedItems; -(window as any).clearAllItems = clearAllItems; -(window as any).retryQueueItem = retryQueueItem; -(window as any).deleteQueueItem = deleteQueueItem; +export { + clearAllItems, + clearFailedItems, + deleteQueueItem, + processPendingItems, + refreshQueue, + retryQueueItem, +}; + +Alpine.global("queue", { + clearAllItems, + clearFailedItems, + deleteQueueItem, + processPendingItems, + refreshQueue, + retryQueueItem, +}); diff --git a/web/src/search.ts b/web/src/search.ts index 3244fc4..7f1d794 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -1,3 +1,5 @@ +import { Alpine } from "./alpine"; + let searchInputTimeout: ReturnType | null = null; const SEARCH_DEBOUNCE_MS = 300; const SEARCH_MIN_CHARS = 2; @@ -303,4 +305,8 @@ function selectLibraryAndBook(libraryId: string, bookId: string): void { document.addEventListener("DOMContentLoaded", initializeSearch); -(window as any).selectLibraryAndBook = selectLibraryAndBook; +export { selectLibraryAndBook }; + +Alpine.global("search", { + selectLibraryAndBook, +}); diff --git a/web/src/themeDropdown.ts b/web/src/themeDropdown.ts index e95a058..b927e0c 100644 --- a/web/src/themeDropdown.ts +++ b/web/src/themeDropdown.ts @@ -1,5 +1,9 @@ // Theme dropdown active indicator management +import { Alpine } from "./alpine"; +import { changeThemeTo, toggleThemeDropdown as originalToggle } from "./header"; +import { updateWoodPanelingIndicators } from "./woodPaneling"; + // Update visual indicators for theme buttons const updateThemeIndicators = (): void => { const currentTheme = localStorage.getItem("theme") || "tokyo-night"; @@ -23,26 +27,21 @@ const updateThemeIndicators = (): void => { }); }; -// Make function available globally -(window as any).updateThemeIndicators = updateThemeIndicators; - // Update on dropdown toggle -const originalToggleThemeDropdown = (window as any).toggleThemeDropdown; -if (originalToggleThemeDropdown) { - (window as any).toggleThemeDropdown = () => { - originalToggleThemeDropdown(); - updateThemeIndicators(); - (window as any).updateWoodPanelingIndicators?.(); - }; +export function initializeThemeDropdown() { + // Call original function + originalToggle(); + // Then update indicators + updateThemeIndicators(); + updateWoodPanelingIndicators(); } // Update after theme changes -const originalChangeThemeTo = (window as any).changeThemeTo; -if (originalChangeThemeTo) { - (window as any).changeThemeTo = (...args: unknown[]) => { - originalChangeThemeTo(...args); - updateThemeIndicators(); - }; +export function initializeChangeThemeTo(theme: string): void { + // Call original function from header.ts + changeThemeTo(theme); + // Then update indicators + updateThemeIndicators(); } // Auto-initialize when DOM is ready @@ -53,3 +52,11 @@ if (typeof document !== "undefined") { updateThemeIndicators(); } } + +Alpine.global("themeDropdown", { + initializeDropdown: initializeThemeDropdown, + changeTheme: initializeChangeThemeTo, + updateIndicators: updateThemeIndicators, +}); + +export { updateThemeIndicators };