Files
bookhoard/web/src/conflicts.ts
T
john-okeefe 48eaa2d286 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.
2026-03-12 15:43:56 -04:00

229 lines
6.3 KiB
TypeScript

import { Alpine } from "./alpine";
import { showToast } from "./toast";
async function refreshConflicts(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/conflicts", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data: ConflictListResponse = await response.json();
renderConflicts(data.conflicts);
updateConflictStats(data);
}
} catch (error) {
console.error("Failed to refresh conflicts:", error);
}
}
async function resolveConflict(
conflictId: string,
winner: string,
manualData?: Record<string, unknown>,
): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(`/api/conflicts/${conflictId}/resolve`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ winner, manual_data: manualData }),
});
if (response.ok) {
showToast("Conflict resolved", "success");
refreshConflicts();
} else {
const error = await response.json();
showToast(error.error || "Failed to resolve conflict", "error");
}
} catch (error) {
console.error("Failed to resolve conflict:", error);
showToast("Failed to resolve conflict", "error");
}
}
async function bulkResolve(
strategy: "most_recent" | "highest_progress",
conflictIds: string[],
): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/conflicts/bulk-resolve", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ conflict_ids: conflictIds, strategy }),
});
if (response.ok) {
const data: BulkResolveResponse = await response.json();
showToast(`Resolved ${data.success} conflicts`, "success");
refreshConflicts();
}
} catch (error) {
console.error("Failed to bulk resolve:", error);
showToast("Failed to bulk resolve conflicts", "error");
}
}
async function bulkDismiss(conflictIds: string[]): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/conflicts/bulk-dismiss", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ conflict_ids: conflictIds }),
});
if (response.ok) {
showToast("Conflicts dismissed", "success");
refreshConflicts();
}
} catch (error) {
console.error("Failed to dismiss conflicts:", error);
showToast("Failed to dismiss conflicts", "error");
}
}
async function dismissAllResolved(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/conflicts/dismiss-resolved", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Resolved conflicts dismissed", "success");
refreshConflicts();
}
} catch (error) {
console.error("Failed to dismiss resolved:", error);
showToast("Failed to dismiss resolved conflicts", "error");
}
}
function renderConflicts(conflicts: ConflictDetailResponse[]): void {
const container = document.getElementById("conflicts-list");
if (!container) return;
if (conflicts.length === 0) {
container.innerHTML =
'<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
return;
}
container.innerHTML = conflicts
.map(
(conflict) => `
<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)">${conflict.media_item_title}</h3>
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
</div>
${
conflict.resolution_status === "unresolved"
? `
<div class="flex space-x-2">
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
</div>
`
: ""
}
</div>
</div>
`,
)
.join("");
}
function updateConflictStats(data: ConflictListResponse): void {
const totalEl = document.getElementById("conflicts-total");
const unresolvedEl = document.getElementById("conflicts-unresolved");
if (totalEl) totalEl.textContent = String(data.total);
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
}
function showResolveModal(conflictId: string): void {
const modal = document.getElementById("resolve-modal");
const conflictIdInput = document.getElementById(
"resolve-conflict-id",
) as HTMLInputElement;
if (modal && conflictIdInput) {
conflictIdInput.value = conflictId;
modal.classList.remove("hidden");
}
}
function hideResolveModal(): void {
const modal = document.getElementById("resolve-modal");
if (modal) {
modal.classList.add("hidden");
}
}
function handleResolveSubmit(event: Event): void {
event.preventDefault();
const form = event.target as HTMLFormElement;
const conflictId = (
form.querySelector("#resolve-conflict-id") as HTMLInputElement
)?.value;
const winner = (
form.querySelector('input[name="winner"]:checked') as HTMLInputElement
)?.value;
if (!conflictId || !winner) {
showToast("Please select a winner", "error");
return;
}
resolveConflict(conflictId, winner);
hideResolveModal();
}
export {
bulkDismiss,
bulkResolve,
dismissAllResolved,
handleResolveSubmit,
hideResolveModal,
refreshConflicts,
resolveConflict,
showResolveModal,
};
Alpine.data("conflicts", () => ({
bulkDismiss,
bulkResolve,
dismissAllResolved,
handleResolveSubmit,
hideResolveModal,
refreshConflicts,
resolveConflict,
showResolveModal,
}));