- 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.
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
|
|
async function loadUnlinkedBooks(): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch("/api/sync/unlinked-books", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
renderUnlinkedBooks(data.unlinked || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load unlinked books:", error);
|
|
}
|
|
}
|
|
|
|
function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
|
const container = document.getElementById("unlinked-books-list");
|
|
if (!container) return;
|
|
|
|
if (books.length === 0) {
|
|
container.innerHTML =
|
|
'<p class="text-center p-4" style="color: var(--text-secondary)">No unlinked books</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = books
|
|
.map(
|
|
(book) => `
|
|
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<h3 class="font-medium" style="color: var(--text-primary)">${book.title_from_device}</h3>
|
|
<p class="text-sm" style="color: var(--text-secondary)">${book.device_name} (${book.device_type})</p>
|
|
<p class="text-xs" style="color: var(--text-secondary)">${book.file_path}</p>
|
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Confidence: ${Math.round(book.confidence_score * 100)}%</p>
|
|
</div>
|
|
<div class="flex space-x-2">
|
|
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
|
|
</div>
|
|
</div>
|
|
${
|
|
book.potential_matches && book.potential_matches.length > 0
|
|
? `
|
|
<div class="mt-3 pt-3 border-t" style="border-color: var(--border)">
|
|
<p class="text-xs font-medium mb-2" style="color: var(--text-secondary)">Potential Matches:</p>
|
|
${book.potential_matches
|
|
.slice(0, 3)
|
|
.map(
|
|
(match) => `
|
|
<div class="flex justify-between items-center p-2 rounded mb-1" style="background-color: var(--bg-primary)">
|
|
<div>
|
|
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
|
|
<p class="text-xs" style="color: var(--text-secondary)">${match.author} (${Math.round(match.confidence * 100)}%)</p>
|
|
</div>
|
|
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function linkBook(
|
|
progressId: string,
|
|
mediaItemId: string,
|
|
): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch("/api/sync/link-book", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
progress_id: progressId,
|
|
media_item_id: mediaItemId,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast("Book linked successfully", "success");
|
|
loadUnlinkedBooks();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(error.error || "Failed to link book", "error");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to link book:", error);
|
|
showToast("Failed to link book", "error");
|
|
}
|
|
}
|
|
|
|
async function autoLinkBooks(): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
if (!confirm("Auto-link all books with high confidence matches?")) return;
|
|
|
|
try {
|
|
const response = await fetch("/api/sync/auto-link", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ confidence_threshold: 0.9 }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
showToast(`Auto-linked ${data.linked_count || 0} books`, "success");
|
|
loadUnlinkedBooks();
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to auto-link:", error);
|
|
showToast("Failed to auto-link books", "error");
|
|
}
|
|
}
|
|
|
|
async function getSuggestions(progressId: string): Promise<void> {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/sync/suggestions/${progressId}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (response.ok) {
|
|
const suggestions = await response.json();
|
|
showSuggestionsModal(progressId, suggestions);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to get suggestions:", error);
|
|
}
|
|
}
|
|
|
|
function showSuggestionsModal(
|
|
progressId: string,
|
|
suggestions: PotentialMatchData[],
|
|
): void {
|
|
const modal = document.getElementById("match-modal");
|
|
const content = document.getElementById("match-modal-content");
|
|
|
|
if (!modal || !content) return;
|
|
|
|
content.innerHTML = `
|
|
<div class="p-4">
|
|
<h3 class="font-medium mb-4" style="color: var(--text-primary)">Select a match</h3>
|
|
<div class="space-y-2">
|
|
${suggestions
|
|
.map(
|
|
(s) => `
|
|
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
|
|
style="background-color: var(--bg-primary); border-color: var(--border)"
|
|
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();">
|
|
<p class="font-medium" style="color: var(--text-primary)">${s.title}</p>
|
|
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
|
|
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
|
|
</div>
|
|
`;
|
|
|
|
modal.classList.remove("hidden");
|
|
}
|
|
|
|
function hideMatchModal(): void {
|
|
const modal = document.getElementById("match-modal");
|
|
if (modal) {
|
|
modal.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
export {
|
|
autoLinkBooks,
|
|
getSuggestions,
|
|
hideMatchModal,
|
|
linkBook,
|
|
loadUnlinkedBooks,
|
|
showSuggestionsModal,
|
|
};
|
|
|
|
Alpine.data("linking", () => ({
|
|
autoLinkBooks,
|
|
getSuggestions,
|
|
hideMatchModal,
|
|
linkBook,
|
|
loadUnlinkedBooks,
|
|
showSuggestionsModal,
|
|
}));
|