From 48eaa2d2869f145542308cb350038049746e5a1f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 12 Mar 2026 15:43:56 -0400 Subject: [PATCH] fix(alpine): wrap all Alpine.data() callbacks in arrow functions for proper component initialization - Wrap all Alpine.data() object literals in arrow functions (() => ({})) - This fixes "n.bind is not a function" errors when Alpine initializes components - Alpine.data() requires a factory function, not a plain object - Ensures each component instance gets its own closure and proper this binding Fixed 24 TypeScript files: - admin.ts, analytics.ts, api.ts, api-explorer-docs.ts - bookshelf.ts, collection-rules.ts, collections.ts, conflicts.ts - device-management.ts, docs.ts, header.ts, index.ts - library.ts, linking.ts, login.ts, password_validation.ts - profile-modal.ts, profile.ts, queue.ts, register.ts - search.ts, theme.ts, toast-error.ts, toast.ts, unlinked_books.ts Before: Alpine.data("name", { method1, method2 }) After: Alpine.data("name", () => ({ method1, method2 })) This is a critical fix for Alpine.js v3+ where components must be registered as factory functions to ensure proper reactivity and prevent binding errors during initialization. --- web/src/analytics.ts | 4 +- web/src/api.ts | 4 +- web/src/bookshelf.ts | 8 +- web/src/collection-rules.ts | 7 +- web/src/conflicts.ts | 4 +- web/src/device-management.ts | 9 ++- web/src/docs.ts | 8 +- web/src/header.ts | 4 +- web/src/index.ts | 4 +- web/src/library.ts | 4 +- web/src/linking.ts | 4 +- web/src/login.ts | 4 +- web/src/password_validation.ts | 4 +- web/src/profile-modal.ts | 4 +- web/src/profile.ts | 4 +- web/src/queue.ts | 4 +- web/src/register.ts | 4 +- web/src/search.ts | 4 +- web/src/theme.ts | 4 +- web/src/toast-error.ts | 4 +- web/src/toast.ts | 4 +- web/src/unlinked_books.ts | 134 ++++++++++++++++++++++++--------- 22 files changed, 152 insertions(+), 82 deletions(-) diff --git a/web/src/analytics.ts b/web/src/analytics.ts index db91915..fc5b6c8 100644 --- a/web/src/analytics.ts +++ b/web/src/analytics.ts @@ -126,6 +126,6 @@ document.addEventListener("DOMContentLoaded", loadAnalytics); export { loadAnalytics }; -Alpine.store("analytics", { +Alpine.data("analytics", () => ({ loadAnalytics, -}); +})); diff --git a/web/src/api.ts b/web/src/api.ts index 3965ed7..c17b6a9 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -99,7 +99,7 @@ export { handleError, }; -Alpine.store("api", { +Alpine.data("api", () => ({ get: apiGet, post: apiPost, put: apiPut, @@ -108,4 +108,4 @@ Alpine.store("api", { handleResponse: handleResponse, handleVoidResponse: handleVoidResponse, handleError: handleError, -}); +})); diff --git a/web/src/bookshelf.ts b/web/src/bookshelf.ts index e16eb30..ab7996f 100644 --- a/web/src/bookshelf.ts +++ b/web/src/bookshelf.ts @@ -7,7 +7,9 @@ let mediaItems: unknown[] = []; function initBookshelf(): void { const savedLibrary = localStorage.getItem("selectedLibrary"); if (savedLibrary) { - const select = document.getElementById("library-select") as HTMLSelectElement; + const select = document.getElementById( + "library-select", + ) as HTMLSelectElement; if (select && select.value) { currentLibraryId = savedLibrary; loadBookshelf(savedLibrary); @@ -196,7 +198,7 @@ export { viewBook, }; -Alpine.store("bookshelf", { +Alpine.data("bookshelf", () => ({ changePage, initBookshelf, loadBookshelf, @@ -204,4 +206,4 @@ Alpine.store("bookshelf", { selectLibrary, setupEventDelegation, viewBook, -}); +})); diff --git a/web/src/collection-rules.ts b/web/src/collection-rules.ts index 92c1ae1..fcb3fad 100644 --- a/web/src/collection-rules.ts +++ b/web/src/collection-rules.ts @@ -251,7 +251,8 @@ async function testRule(): Promise { '

Found ' + result.matches.length + " matching books:

"; - html += '
'; + html += + '
'; result.matches.slice(0, 20).forEach((book: TestRuleMatch) => { html += @@ -408,7 +409,7 @@ export { toggleRule, }; -Alpine.store("collectionRules", { +Alpine.data("collectionRules", () => ({ backToCollection, clearForm, deleteRule, @@ -422,4 +423,4 @@ Alpine.store("collectionRules", { setupEventDelegation, testRule, toggleRule, -}); +})); diff --git a/web/src/conflicts.ts b/web/src/conflicts.ts index fa0ab3c..5a9a524 100644 --- a/web/src/conflicts.ts +++ b/web/src/conflicts.ts @@ -216,7 +216,7 @@ export { showResolveModal, }; -Alpine.store("conflicts", { +Alpine.data("conflicts", () => ({ bulkDismiss, bulkResolve, dismissAllResolved, @@ -225,4 +225,4 @@ Alpine.store("conflicts", { refreshConflicts, resolveConflict, showResolveModal, -}); +})); diff --git a/web/src/device-management.ts b/web/src/device-management.ts index 65cca89..e59c70b 100644 --- a/web/src/device-management.ts +++ b/web/src/device-management.ts @@ -534,7 +534,10 @@ async function handleSaveDeviceSettings(event: Event): Promise { } } -async function copyToClipboard(text: string, description: string): Promise { +async function copyToClipboard( + text: string, + description: string, +): Promise { try { await navigator.clipboard.writeText(text); showToast(`${description} copied!`, "success"); @@ -693,7 +696,7 @@ export { showShelfMappings, }; -Alpine.store("devices", { +Alpine.data("devices", () => ({ approveDevice, clearSyncQueue, copyToClipboard, @@ -716,4 +719,4 @@ Alpine.store("devices", { showAddMappingModal, showDeviceSettings, showShelfMappings, -}); +})); diff --git a/web/src/docs.ts b/web/src/docs.ts index 5fbccd6..f8a0624 100644 --- a/web/src/docs.ts +++ b/web/src/docs.ts @@ -93,14 +93,14 @@ document.addEventListener("DOMContentLoaded", () => { initializeDocsSearch(); }); -Alpine.store("docs", { +Alpine.data("docs", () => ({ toggleSidebar, initializeSearch: initializeDocsSearch, -}); +})); export { toggleSidebar, initializeDocsSearch }; -Alpine.store("docs", { +Alpine.data("docs", () => ({ toggleSidebar, initializeSearch: initializeDocsSearch, -}); +})); diff --git a/web/src/header.ts b/web/src/header.ts index 1eb56c3..ad8b795 100644 --- a/web/src/header.ts +++ b/web/src/header.ts @@ -10,6 +10,6 @@ const logout = (): void => { export { logout }; -Alpine.store("header", { +Alpine.data("header", () => ({ logout, -}); +})); diff --git a/web/src/index.ts b/web/src/index.ts index 968593a..5c657f9 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -43,8 +43,8 @@ async function checkAuthRedirect(): Promise { export { changeTheme, checkAuthRedirect, initIndexTheme }; -Alpine.store("index", { +Alpine.data("index", () => ({ changeTheme, checkAuthRedirect, initIndexTheme, -}); +})); diff --git a/web/src/library.ts b/web/src/library.ts index 323b4b5..644f8ea 100644 --- a/web/src/library.ts +++ b/web/src/library.ts @@ -680,7 +680,7 @@ export { confirmDeleteLibrary, }; -Alpine.store("library", { +Alpine.data("library", () => ({ deleteLibrary, showLibraryFolders, addLibraryFolder, @@ -696,4 +696,4 @@ Alpine.store("library", { showDeleteModal, hideDeleteModal, confirmDeleteLibrary, -}); +})); diff --git a/web/src/linking.ts b/web/src/linking.ts index 373648e..20a503a 100644 --- a/web/src/linking.ts +++ b/web/src/linking.ts @@ -201,11 +201,11 @@ export { showSuggestionsModal, }; -Alpine.store("linking", { +Alpine.data("linking", () => ({ autoLinkBooks, getSuggestions, hideMatchModal, linkBook, loadUnlinkedBooks, showSuggestionsModal, -}); +})); diff --git a/web/src/login.ts b/web/src/login.ts index 23ef921..16a0e1f 100644 --- a/web/src/login.ts +++ b/web/src/login.ts @@ -22,7 +22,7 @@ function changeTheme(): void { export { changeTheme, initLoginTheme }; -Alpine.store("login", { +Alpine.data("login", () => ({ changeTheme, initLoginTheme, -}); +})); diff --git a/web/src/password_validation.ts b/web/src/password_validation.ts index 84430a9..bdbeed3 100644 --- a/web/src/password_validation.ts +++ b/web/src/password_validation.ts @@ -189,6 +189,6 @@ function initPasswordValidation(): void { // Export for use in template export { initPasswordValidation }; -Alpine.store("validation", { +Alpine.data("validation", () => ({ initPasswordValidation, -}); +})); diff --git a/web/src/profile-modal.ts b/web/src/profile-modal.ts index 11eda02..4fdf3ee 100644 --- a/web/src/profile-modal.ts +++ b/web/src/profile-modal.ts @@ -26,7 +26,7 @@ function setupProfileModal(): void { export { closeProfileModal, setupProfileModal }; -Alpine.store("profileModal", { +Alpine.data("profileModal", () => ({ closeProfileModal, setupProfileModal, -}); +})); diff --git a/web/src/profile.ts b/web/src/profile.ts index eba4478..301f324 100644 --- a/web/src/profile.ts +++ b/web/src/profile.ts @@ -41,6 +41,6 @@ async function confirmDeleteAccount(): Promise { export { confirmDeleteAccount }; -Alpine.store("profile", { +Alpine.data("profile", () => ({ confirmDeleteAccount, -}); +})); diff --git a/web/src/queue.ts b/web/src/queue.ts index 72372a2..13687d9 100644 --- a/web/src/queue.ts +++ b/web/src/queue.ts @@ -191,7 +191,7 @@ export { filterQueue, }; -Alpine.store("queue", { +Alpine.data("queue", () => ({ clearAllItems, clearFailedItems, deleteQueueItem, @@ -201,4 +201,4 @@ Alpine.store("queue", { showQueueItemModal, hideQueueItemModal, filterQueue, -}); +})); diff --git a/web/src/register.ts b/web/src/register.ts index 8931912..5e18ad1 100644 --- a/web/src/register.ts +++ b/web/src/register.ts @@ -11,6 +11,6 @@ function initRegisterTheme(): void { export { initRegisterTheme }; -Alpine.store("register", { +Alpine.data("register", () => ({ initRegisterTheme, -}); +})); diff --git a/web/src/search.ts b/web/src/search.ts index dcc798b..67b16fd 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -307,6 +307,6 @@ document.addEventListener("DOMContentLoaded", initializeSearch); export { selectLibraryAndBook }; -Alpine.store("search", { +Alpine.data("search", () => ({ selectLibraryAndBook, -}); +})); diff --git a/web/src/theme.ts b/web/src/theme.ts index cac9094..1802221 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -217,10 +217,10 @@ if (typeof document !== "undefined") { } } -Alpine.store("theme", { +Alpine.data("theme", () => ({ changeTheme, changeWoodPaneling, -}); +})); export { applyTheme, diff --git a/web/src/toast-error.ts b/web/src/toast-error.ts index a6fbe4f..7ff7e85 100644 --- a/web/src/toast-error.ts +++ b/web/src/toast-error.ts @@ -30,6 +30,6 @@ function showErrorToast(message: string): void { export { showErrorToast }; -Alpine.store("toastError", { +Alpine.data("toastError", () => ({ showErrorToast, -}); +})); diff --git a/web/src/toast.ts b/web/src/toast.ts index 16bb146..435f01f 100644 --- a/web/src/toast.ts +++ b/web/src/toast.ts @@ -226,14 +226,14 @@ if (typeof document !== "undefined") { } // Export toast API for manual use -Alpine.store("showToast", { +Alpine.data("showToast", () => ({ error: (message: string, duration?: number) => showToast(message, "error", duration), success: (message: string, duration?: number) => showToast(message, "success", duration), info: (message: string, duration?: number) => showToast(message, "info", duration), -}); +})); export { showToast }; export type { ToastType }; diff --git a/web/src/unlinked_books.ts b/web/src/unlinked_books.ts index 12da822..aecd481 100644 --- a/web/src/unlinked_books.ts +++ b/web/src/unlinked_books.ts @@ -4,14 +4,19 @@ import { getToken } from "./storage"; let selectedMediaItem: string | null = null; -function searchMatches(progressId: string, sha256: string, title: string): void { +function searchMatches( + progressId: string, + sha256: string, + title: string, +): void { const container = document.getElementById(`matches-${progressId}`); const matchesList = document.getElementById(`matches-list-${progressId}`); if (!container || !matchesList) return; container.classList.remove("hidden"); - matchesList.innerHTML = '

Searching...

'; + matchesList.innerHTML = + '

Searching...

'; const token = getToken(); const url = sha256 @@ -51,19 +56,27 @@ function searchMatches(progressId: string, sha256: string, title: string): void ) .join(""); } else { - matchesList.innerHTML = '

No matches found. Try manual linking.

'; + matchesList.innerHTML = + '

No matches found. Try manual linking.

'; } }) .catch((error) => { console.error("Failed to search", error); - matchesList.innerHTML = '

Failed to search

'; + matchesList.innerHTML = + '

Failed to search

'; }); } -function autoLinkBook(progressId: string, mediaItemId: string, confidence: number): void { +function autoLinkBook( + progressId: string, + mediaItemId: string, + confidence: number, +): void { if ( !confirm( - "Link this book? The confidence score is " + Math.round(confidence * 100) + "%", + "Link this book? The confidence score is " + + Math.round(confidence * 100) + + "%", ) ) { return; @@ -104,20 +117,28 @@ function autoLinkBook(progressId: string, mediaItemId: string, confidence: numbe function showManualLinkModal(progressId: string, bookTitle: string): void { const modal = document.getElementById("manual-link-modal"); - const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement; - const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement; + const progressIdInput = document.getElementById( + "link-progress-id", + ) as HTMLInputElement; + const bookTitleInput = document.getElementById( + "link-book-title", + ) as HTMLInputElement; const searchResults = document.getElementById("link-search-results"); if (modal) modal.classList.remove("hidden"); if (progressIdInput) progressIdInput.value = progressId; if (bookTitleInput) bookTitleInput.value = bookTitle; - if (searchResults) searchResults.innerHTML = '

Search for books to link

'; + if (searchResults) + searchResults.innerHTML = + '

Search for books to link

'; selectedMediaItem = null; } function hideManualLinkModal(): void { const modal = document.getElementById("manual-link-modal"); - const searchInput = document.getElementById("link-search-input") as HTMLInputElement; + const searchInput = document.getElementById( + "link-search-input", + ) as HTMLInputElement; if (modal) modal.classList.add("hidden"); if (searchInput) searchInput.value = ""; @@ -125,7 +146,9 @@ function hideManualLinkModal(): void { } function searchBooksForLink(): void { - const searchInput = document.getElementById("link-search-input") as HTMLInputElement; + const searchInput = document.getElementById( + "link-search-input", + ) as HTMLInputElement; const resultsContainer = document.getElementById("link-search-results"); if (!searchInput || !resultsContainer) return; @@ -133,11 +156,13 @@ function searchBooksForLink(): void { const searchTerm = searchInput.value; if (searchTerm.length < 2) { - resultsContainer.innerHTML = '

Enter at least 2 characters

'; + resultsContainer.innerHTML = + '

Enter at least 2 characters

'; return; } - resultsContainer.innerHTML = '

Searching...

'; + resultsContainer.innerHTML = + '

Searching...

'; const token = getToken(); fetch(`/api/books/match?title=${encodeURIComponent(searchTerm)}`, { @@ -168,16 +193,22 @@ function searchBooksForLink(): void { ) .join(""); } else { - resultsContainer.innerHTML = '

No matches found

'; + resultsContainer.innerHTML = + '

No matches found

'; } }) .catch((error) => { console.error("Failed to search", error); - resultsContainer.innerHTML = '

Failed to search

'; + resultsContainer.innerHTML = + '

Failed to search

'; }); } -function selectBookForLink(mediaItemId: string, title: string, _coverPath: string): void { +function selectBookForLink( + mediaItemId: string, + title: string, + _coverPath: string, +): void { selectedMediaItem = mediaItemId; const resultsContainer = document.getElementById("link-search-results"); if (!resultsContainer) return; @@ -197,10 +228,18 @@ function confirmManualLink(): void { return; } - const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement; - const confidenceInput = document.getElementById("link-confidence") as HTMLInputElement; - const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement; - const sha256Input = document.getElementById("link-book-sha256") as HTMLInputElement; + const progressIdInput = document.getElementById( + "link-progress-id", + ) as HTMLInputElement; + const confidenceInput = document.getElementById( + "link-confidence", + ) as HTMLInputElement; + const bookTitleInput = document.getElementById( + "link-book-title", + ) as HTMLInputElement; + const sha256Input = document.getElementById( + "link-book-sha256", + ) as HTMLInputElement; if (!progressIdInput) return; @@ -240,7 +279,9 @@ function confirmManualLink(): void { } function toggleAllUnlinked(): void { - const selectAll = document.getElementById("select-all-unlinked") as HTMLInputElement; + const selectAll = document.getElementById( + "select-all-unlinked", + ) as HTMLInputElement; if (!selectAll) return; document.querySelectorAll(".unlinked-checkbox").forEach((cb) => { @@ -250,7 +291,9 @@ function toggleAllUnlinked(): void { } function getSelectedUnlinked(): { progressId: string; title: string }[] { - return Array.from(document.querySelectorAll(".unlinked-checkbox:checked")).map((cb) => ({ + return Array.from( + document.querySelectorAll(".unlinked-checkbox:checked"), + ).map((cb) => ({ progressId: cb.getAttribute("data-progress-id") || "", title: cb.getAttribute("data-title") || "", })); @@ -271,7 +314,11 @@ async function bulkAutoLink(): Promise { return; } - if (!confirm(`Auto-link ${selected.length} books with high confidence matches (≥80%)?`)) { + if ( + !confirm( + `Auto-link ${selected.length} books with high confidence matches (≥80%)?`, + ) + ) { return; } @@ -290,7 +337,10 @@ async function bulkAutoLink(): Promise { }); const result = await response.json(); - showToast(`Auto-linked ${result.auto_linked} books successfully`, "success"); + showToast( + `Auto-linked ${result.auto_linked} books successfully`, + "success", + ); setTimeout(() => window.location.reload(), 1500); } catch (error) { console.error("Auto-link failed", error); @@ -308,11 +358,14 @@ async function bulkGetSuggestions(): Promise { const token = getToken(); for (const book of selected) { try { - const response = await fetch(`/sync/unlinked-books/${book.progressId}/suggestions`, { - headers: { - Authorization: `Bearer ${token}`, + const response = await fetch( + `/sync/unlinked-books/${book.progressId}/suggestions`, + { + headers: { + Authorization: `Bearer ${token}`, + }, }, - }); + ); const result = await response.json(); displaySuggestions(book.progressId, result.suggestions, result.action); @@ -322,7 +375,11 @@ async function bulkGetSuggestions(): Promise { } } -function displaySuggestions(progressId: string, suggestions: any[], _action: string): void { +function displaySuggestions( + progressId: string, + suggestions: any[], + _action: string, +): void { const container = document.getElementById(`matches-${progressId}`); if (!container) return; @@ -333,13 +390,15 @@ function displaySuggestions(progressId: string, suggestions: any[], _action: str listContainer.innerHTML = ""; if (suggestions.length === 0) { - listContainer.innerHTML = '

No matches found

'; + listContainer.innerHTML = + '

No matches found

'; return; } suggestions.forEach((match) => { const div = document.createElement("div"); - div.className = "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors"; + div.className = + "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors"; div.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`; div.innerHTML = `
@@ -370,8 +429,13 @@ function showBulkManualLink(): void { return; } - showToast(`Bulk manual link for ${selected.length} books - select target book in library`, "info"); - window.location.href = "/library?mode=link&unlinked=" + selected.map((s) => s.progressId).join(","); + showToast( + `Bulk manual link for ${selected.length} books - select target book in library`, + "info", + ); + window.location.href = + "/library?mode=link&unlinked=" + + selected.map((s) => s.progressId).join(","); } function setupEventDelegation(): void { @@ -427,7 +491,7 @@ export { toggleAllUnlinked, }; -Alpine.store("unlinkedBooks", { +Alpine.data("unlinkedBooks", () => ({ bulkAutoLink, bulkGetSuggestions, confirmManualLink, @@ -440,4 +504,4 @@ Alpine.store("unlinkedBooks", { showBulkManualLink, showManualLinkModal, toggleAllUnlinked, -}); +}));