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.
This commit is contained in:
2026-03-12 15:43:56 -04:00
parent 93710a1e96
commit 48eaa2d286
22 changed files with 152 additions and 82 deletions
+2 -2
View File
@@ -126,6 +126,6 @@ document.addEventListener("DOMContentLoaded", loadAnalytics);
export { loadAnalytics }; export { loadAnalytics };
Alpine.store("analytics", { Alpine.data("analytics", () => ({
loadAnalytics, loadAnalytics,
}); }));
+2 -2
View File
@@ -99,7 +99,7 @@ export {
handleError, handleError,
}; };
Alpine.store("api", { Alpine.data("api", () => ({
get: apiGet, get: apiGet,
post: apiPost, post: apiPost,
put: apiPut, put: apiPut,
@@ -108,4 +108,4 @@ Alpine.store("api", {
handleResponse: handleResponse, handleResponse: handleResponse,
handleVoidResponse: handleVoidResponse, handleVoidResponse: handleVoidResponse,
handleError: handleError, handleError: handleError,
}); }));
+5 -3
View File
@@ -7,7 +7,9 @@ let mediaItems: unknown[] = [];
function initBookshelf(): void { function initBookshelf(): void {
const savedLibrary = localStorage.getItem("selectedLibrary"); const savedLibrary = localStorage.getItem("selectedLibrary");
if (savedLibrary) { if (savedLibrary) {
const select = document.getElementById("library-select") as HTMLSelectElement; const select = document.getElementById(
"library-select",
) as HTMLSelectElement;
if (select && select.value) { if (select && select.value) {
currentLibraryId = savedLibrary; currentLibraryId = savedLibrary;
loadBookshelf(savedLibrary); loadBookshelf(savedLibrary);
@@ -196,7 +198,7 @@ export {
viewBook, viewBook,
}; };
Alpine.store("bookshelf", { Alpine.data("bookshelf", () => ({
changePage, changePage,
initBookshelf, initBookshelf,
loadBookshelf, loadBookshelf,
@@ -204,4 +206,4 @@ Alpine.store("bookshelf", {
selectLibrary, selectLibrary,
setupEventDelegation, setupEventDelegation,
viewBook, viewBook,
}); }));
+4 -3
View File
@@ -251,7 +251,8 @@ async function testRule(): Promise<void> {
'<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' + '<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' +
result.matches.length + result.matches.length +
" matching books:</p>"; " matching books:</p>";
html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">'; html +=
'<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
result.matches.slice(0, 20).forEach((book: TestRuleMatch) => { result.matches.slice(0, 20).forEach((book: TestRuleMatch) => {
html += html +=
@@ -408,7 +409,7 @@ export {
toggleRule, toggleRule,
}; };
Alpine.store("collectionRules", { Alpine.data("collectionRules", () => ({
backToCollection, backToCollection,
clearForm, clearForm,
deleteRule, deleteRule,
@@ -422,4 +423,4 @@ Alpine.store("collectionRules", {
setupEventDelegation, setupEventDelegation,
testRule, testRule,
toggleRule, toggleRule,
}); }));
+2 -2
View File
@@ -216,7 +216,7 @@ export {
showResolveModal, showResolveModal,
}; };
Alpine.store("conflicts", { Alpine.data("conflicts", () => ({
bulkDismiss, bulkDismiss,
bulkResolve, bulkResolve,
dismissAllResolved, dismissAllResolved,
@@ -225,4 +225,4 @@ Alpine.store("conflicts", {
refreshConflicts, refreshConflicts,
resolveConflict, resolveConflict,
showResolveModal, showResolveModal,
}); }));
+6 -3
View File
@@ -534,7 +534,10 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
} }
} }
async function copyToClipboard(text: string, description: string): Promise<void> { async function copyToClipboard(
text: string,
description: string,
): Promise<void> {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
showToast(`${description} copied!`, "success"); showToast(`${description} copied!`, "success");
@@ -693,7 +696,7 @@ export {
showShelfMappings, showShelfMappings,
}; };
Alpine.store("devices", { Alpine.data("devices", () => ({
approveDevice, approveDevice,
clearSyncQueue, clearSyncQueue,
copyToClipboard, copyToClipboard,
@@ -716,4 +719,4 @@ Alpine.store("devices", {
showAddMappingModal, showAddMappingModal,
showDeviceSettings, showDeviceSettings,
showShelfMappings, showShelfMappings,
}); }));
+4 -4
View File
@@ -93,14 +93,14 @@ document.addEventListener("DOMContentLoaded", () => {
initializeDocsSearch(); initializeDocsSearch();
}); });
Alpine.store("docs", { Alpine.data("docs", () => ({
toggleSidebar, toggleSidebar,
initializeSearch: initializeDocsSearch, initializeSearch: initializeDocsSearch,
}); }));
export { toggleSidebar, initializeDocsSearch }; export { toggleSidebar, initializeDocsSearch };
Alpine.store("docs", { Alpine.data("docs", () => ({
toggleSidebar, toggleSidebar,
initializeSearch: initializeDocsSearch, initializeSearch: initializeDocsSearch,
}); }));
+2 -2
View File
@@ -10,6 +10,6 @@ const logout = (): void => {
export { logout }; export { logout };
Alpine.store("header", { Alpine.data("header", () => ({
logout, logout,
}); }));
+2 -2
View File
@@ -43,8 +43,8 @@ async function checkAuthRedirect(): Promise<void> {
export { changeTheme, checkAuthRedirect, initIndexTheme }; export { changeTheme, checkAuthRedirect, initIndexTheme };
Alpine.store("index", { Alpine.data("index", () => ({
changeTheme, changeTheme,
checkAuthRedirect, checkAuthRedirect,
initIndexTheme, initIndexTheme,
}); }));
+2 -2
View File
@@ -680,7 +680,7 @@ export {
confirmDeleteLibrary, confirmDeleteLibrary,
}; };
Alpine.store("library", { Alpine.data("library", () => ({
deleteLibrary, deleteLibrary,
showLibraryFolders, showLibraryFolders,
addLibraryFolder, addLibraryFolder,
@@ -696,4 +696,4 @@ Alpine.store("library", {
showDeleteModal, showDeleteModal,
hideDeleteModal, hideDeleteModal,
confirmDeleteLibrary, confirmDeleteLibrary,
}); }));
+2 -2
View File
@@ -201,11 +201,11 @@ export {
showSuggestionsModal, showSuggestionsModal,
}; };
Alpine.store("linking", { Alpine.data("linking", () => ({
autoLinkBooks, autoLinkBooks,
getSuggestions, getSuggestions,
hideMatchModal, hideMatchModal,
linkBook, linkBook,
loadUnlinkedBooks, loadUnlinkedBooks,
showSuggestionsModal, showSuggestionsModal,
}); }));
+2 -2
View File
@@ -22,7 +22,7 @@ function changeTheme(): void {
export { changeTheme, initLoginTheme }; export { changeTheme, initLoginTheme };
Alpine.store("login", { Alpine.data("login", () => ({
changeTheme, changeTheme,
initLoginTheme, initLoginTheme,
}); }));
+2 -2
View File
@@ -189,6 +189,6 @@ function initPasswordValidation(): void {
// Export for use in template // Export for use in template
export { initPasswordValidation }; export { initPasswordValidation };
Alpine.store("validation", { Alpine.data("validation", () => ({
initPasswordValidation, initPasswordValidation,
}); }));
+2 -2
View File
@@ -26,7 +26,7 @@ function setupProfileModal(): void {
export { closeProfileModal, setupProfileModal }; export { closeProfileModal, setupProfileModal };
Alpine.store("profileModal", { Alpine.data("profileModal", () => ({
closeProfileModal, closeProfileModal,
setupProfileModal, setupProfileModal,
}); }));
+2 -2
View File
@@ -41,6 +41,6 @@ async function confirmDeleteAccount(): Promise<void> {
export { confirmDeleteAccount }; export { confirmDeleteAccount };
Alpine.store("profile", { Alpine.data("profile", () => ({
confirmDeleteAccount, confirmDeleteAccount,
}); }));
+2 -2
View File
@@ -191,7 +191,7 @@ export {
filterQueue, filterQueue,
}; };
Alpine.store("queue", { Alpine.data("queue", () => ({
clearAllItems, clearAllItems,
clearFailedItems, clearFailedItems,
deleteQueueItem, deleteQueueItem,
@@ -201,4 +201,4 @@ Alpine.store("queue", {
showQueueItemModal, showQueueItemModal,
hideQueueItemModal, hideQueueItemModal,
filterQueue, filterQueue,
}); }));
+2 -2
View File
@@ -11,6 +11,6 @@ function initRegisterTheme(): void {
export { initRegisterTheme }; export { initRegisterTheme };
Alpine.store("register", { Alpine.data("register", () => ({
initRegisterTheme, initRegisterTheme,
}); }));
+2 -2
View File
@@ -307,6 +307,6 @@ document.addEventListener("DOMContentLoaded", initializeSearch);
export { selectLibraryAndBook }; export { selectLibraryAndBook };
Alpine.store("search", { Alpine.data("search", () => ({
selectLibraryAndBook, selectLibraryAndBook,
}); }));
+2 -2
View File
@@ -217,10 +217,10 @@ if (typeof document !== "undefined") {
} }
} }
Alpine.store("theme", { Alpine.data("theme", () => ({
changeTheme, changeTheme,
changeWoodPaneling, changeWoodPaneling,
}); }));
export { export {
applyTheme, applyTheme,
+2 -2
View File
@@ -30,6 +30,6 @@ function showErrorToast(message: string): void {
export { showErrorToast }; export { showErrorToast };
Alpine.store("toastError", { Alpine.data("toastError", () => ({
showErrorToast, showErrorToast,
}); }));
+2 -2
View File
@@ -226,14 +226,14 @@ if (typeof document !== "undefined") {
} }
// Export toast API for manual use // Export toast API for manual use
Alpine.store("showToast", { Alpine.data("showToast", () => ({
error: (message: string, duration?: number) => error: (message: string, duration?: number) =>
showToast(message, "error", duration), showToast(message, "error", duration),
success: (message: string, duration?: number) => success: (message: string, duration?: number) =>
showToast(message, "success", duration), showToast(message, "success", duration),
info: (message: string, duration?: number) => info: (message: string, duration?: number) =>
showToast(message, "info", duration), showToast(message, "info", duration),
}); }));
export { showToast }; export { showToast };
export type { ToastType }; export type { ToastType };
+99 -35
View File
@@ -4,14 +4,19 @@ import { getToken } from "./storage";
let selectedMediaItem: string | null = null; 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 container = document.getElementById(`matches-${progressId}`);
const matchesList = document.getElementById(`matches-list-${progressId}`); const matchesList = document.getElementById(`matches-list-${progressId}`);
if (!container || !matchesList) return; if (!container || !matchesList) return;
container.classList.remove("hidden"); container.classList.remove("hidden");
matchesList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>'; matchesList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
const token = getToken(); const token = getToken();
const url = sha256 const url = sha256
@@ -51,19 +56,27 @@ function searchMatches(progressId: string, sha256: string, title: string): void
) )
.join(""); .join("");
} else { } else {
matchesList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>'; matchesList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>';
} }
}) })
.catch((error) => { .catch((error) => {
console.error("Failed to search", error); console.error("Failed to search", error);
matchesList.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search</p>'; matchesList.innerHTML =
'<p class="text-sm" style="color: var(--error)">Failed to search</p>';
}); });
} }
function autoLinkBook(progressId: string, mediaItemId: string, confidence: number): void { function autoLinkBook(
progressId: string,
mediaItemId: string,
confidence: number,
): void {
if ( if (
!confirm( !confirm(
"Link this book? The confidence score is " + Math.round(confidence * 100) + "%", "Link this book? The confidence score is " +
Math.round(confidence * 100) +
"%",
) )
) { ) {
return; return;
@@ -104,20 +117,28 @@ function autoLinkBook(progressId: string, mediaItemId: string, confidence: numbe
function showManualLinkModal(progressId: string, bookTitle: string): void { function showManualLinkModal(progressId: string, bookTitle: string): void {
const modal = document.getElementById("manual-link-modal"); const modal = document.getElementById("manual-link-modal");
const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement; const progressIdInput = document.getElementById(
const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement; "link-progress-id",
) as HTMLInputElement;
const bookTitleInput = document.getElementById(
"link-book-title",
) as HTMLInputElement;
const searchResults = document.getElementById("link-search-results"); const searchResults = document.getElementById("link-search-results");
if (modal) modal.classList.remove("hidden"); if (modal) modal.classList.remove("hidden");
if (progressIdInput) progressIdInput.value = progressId; if (progressIdInput) progressIdInput.value = progressId;
if (bookTitleInput) bookTitleInput.value = bookTitle; if (bookTitleInput) bookTitleInput.value = bookTitle;
if (searchResults) searchResults.innerHTML = '<p style="color: var(--text-secondary)">Search for books to link</p>'; if (searchResults)
searchResults.innerHTML =
'<p style="color: var(--text-secondary)">Search for books to link</p>';
selectedMediaItem = null; selectedMediaItem = null;
} }
function hideManualLinkModal(): void { function hideManualLinkModal(): void {
const modal = document.getElementById("manual-link-modal"); 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 (modal) modal.classList.add("hidden");
if (searchInput) searchInput.value = ""; if (searchInput) searchInput.value = "";
@@ -125,7 +146,9 @@ function hideManualLinkModal(): void {
} }
function searchBooksForLink(): 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"); const resultsContainer = document.getElementById("link-search-results");
if (!searchInput || !resultsContainer) return; if (!searchInput || !resultsContainer) return;
@@ -133,11 +156,13 @@ function searchBooksForLink(): void {
const searchTerm = searchInput.value; const searchTerm = searchInput.value;
if (searchTerm.length < 2) { if (searchTerm.length < 2) {
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters</p>'; resultsContainer.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters</p>';
return; return;
} }
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>'; resultsContainer.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
const token = getToken(); const token = getToken();
fetch(`/api/books/match?title=${encodeURIComponent(searchTerm)}`, { fetch(`/api/books/match?title=${encodeURIComponent(searchTerm)}`, {
@@ -168,16 +193,22 @@ function searchBooksForLink(): void {
) )
.join(""); .join("");
} else { } else {
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">No matches found</p>'; resultsContainer.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">No matches found</p>';
} }
}) })
.catch((error) => { .catch((error) => {
console.error("Failed to search", error); console.error("Failed to search", error);
resultsContainer.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to search</p>'; resultsContainer.innerHTML =
'<p class="text-sm" style="color: var(--error)">Failed to search</p>';
}); });
} }
function selectBookForLink(mediaItemId: string, title: string, _coverPath: string): void { function selectBookForLink(
mediaItemId: string,
title: string,
_coverPath: string,
): void {
selectedMediaItem = mediaItemId; selectedMediaItem = mediaItemId;
const resultsContainer = document.getElementById("link-search-results"); const resultsContainer = document.getElementById("link-search-results");
if (!resultsContainer) return; if (!resultsContainer) return;
@@ -197,10 +228,18 @@ function confirmManualLink(): void {
return; return;
} }
const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement; const progressIdInput = document.getElementById(
const confidenceInput = document.getElementById("link-confidence") as HTMLInputElement; "link-progress-id",
const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement; ) as HTMLInputElement;
const sha256Input = document.getElementById("link-book-sha256") 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; if (!progressIdInput) return;
@@ -240,7 +279,9 @@ function confirmManualLink(): void {
} }
function toggleAllUnlinked(): 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; if (!selectAll) return;
document.querySelectorAll(".unlinked-checkbox").forEach((cb) => { document.querySelectorAll(".unlinked-checkbox").forEach((cb) => {
@@ -250,7 +291,9 @@ function toggleAllUnlinked(): void {
} }
function getSelectedUnlinked(): { progressId: string; title: string }[] { 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") || "", progressId: cb.getAttribute("data-progress-id") || "",
title: cb.getAttribute("data-title") || "", title: cb.getAttribute("data-title") || "",
})); }));
@@ -271,7 +314,11 @@ async function bulkAutoLink(): Promise<void> {
return; 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; return;
} }
@@ -290,7 +337,10 @@ async function bulkAutoLink(): Promise<void> {
}); });
const result = await response.json(); 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); setTimeout(() => window.location.reload(), 1500);
} catch (error) { } catch (error) {
console.error("Auto-link failed", error); console.error("Auto-link failed", error);
@@ -308,11 +358,14 @@ async function bulkGetSuggestions(): Promise<void> {
const token = getToken(); const token = getToken();
for (const book of selected) { for (const book of selected) {
try { try {
const response = await fetch(`/sync/unlinked-books/${book.progressId}/suggestions`, { const response = await fetch(
headers: { `/sync/unlinked-books/${book.progressId}/suggestions`,
Authorization: `Bearer ${token}`, {
headers: {
Authorization: `Bearer ${token}`,
},
}, },
}); );
const result = await response.json(); const result = await response.json();
displaySuggestions(book.progressId, result.suggestions, result.action); displaySuggestions(book.progressId, result.suggestions, result.action);
@@ -322,7 +375,11 @@ async function bulkGetSuggestions(): Promise<void> {
} }
} }
function displaySuggestions(progressId: string, suggestions: any[], _action: string): void { function displaySuggestions(
progressId: string,
suggestions: any[],
_action: string,
): void {
const container = document.getElementById(`matches-${progressId}`); const container = document.getElementById(`matches-${progressId}`);
if (!container) return; if (!container) return;
@@ -333,13 +390,15 @@ function displaySuggestions(progressId: string, suggestions: any[], _action: str
listContainer.innerHTML = ""; listContainer.innerHTML = "";
if (suggestions.length === 0) { if (suggestions.length === 0) {
listContainer.innerHTML = '<p style="color: var(--text-secondary)">No matches found</p>'; listContainer.innerHTML =
'<p style="color: var(--text-secondary)">No matches found</p>';
return; return;
} }
suggestions.forEach((match) => { suggestions.forEach((match) => {
const div = document.createElement("div"); 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.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
div.innerHTML = ` div.innerHTML = `
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@@ -370,8 +429,13 @@ function showBulkManualLink(): void {
return; return;
} }
showToast(`Bulk manual link for ${selected.length} books - select target book in library`, "info"); showToast(
window.location.href = "/library?mode=link&unlinked=" + selected.map((s) => s.progressId).join(","); `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 { function setupEventDelegation(): void {
@@ -427,7 +491,7 @@ export {
toggleAllUnlinked, toggleAllUnlinked,
}; };
Alpine.store("unlinkedBooks", { Alpine.data("unlinkedBooks", () => ({
bulkAutoLink, bulkAutoLink,
bulkGetSuggestions, bulkGetSuggestions,
confirmManualLink, confirmManualLink,
@@ -440,4 +504,4 @@ Alpine.store("unlinkedBooks", {
showBulkManualLink, showBulkManualLink,
showManualLinkModal, showManualLinkModal,
toggleAllUnlinked, toggleAllUnlinked,
}); }));