They never did: filterConflicts/toggleAllConflicts/updateBulkActions were referenced in the templ but defined nowhere, and the bulk buttons called bulkResolve()/bulkDismiss() with no arguments against functions expecting (strategy, conflictIds). - conflict cards carry data-status; the status dropdown filters them client-side (pure client state — allowed to be JS), applied on page init and re-applied after every partial refresh, with a 'no conflicts match this filter' note when everything is filtered out - select-all toggles only the visible cards; the bulk bar shows the selected count and resolve/dismiss now gather the checked ids and the chosen strategy (the bar's own buttons use resolveSelected()/ dismissSelected() wrappers) - dead onchange= globals became Alpine @change handlers on the server- rendered controls; partial swaps re-bind via Alpine.initTree (already in refreshConflicts) and re-apply filter + bulk state Visible change: the dropdown's stated default (Unresolved Only) now actually applies on load, so resolved conflicts are hidden until the filter is changed.
269 lines
7.4 KiB
TypeScript
269 lines
7.4 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
|
|
async function refreshConflicts(): Promise<void> {
|
|
// Rendering ownership: the list and stats are server-rendered markup.
|
|
// Swap in the templ partial (stats arrive as an out-of-band swap)
|
|
// instead of re-rendering cards from JSON.
|
|
try {
|
|
await window.htmx.ajax("GET", "/conflicts/list", {
|
|
target: "#conflicts-list",
|
|
swap: "outerHTML",
|
|
});
|
|
const list = document.getElementById("conflicts-list");
|
|
if (list) Alpine.initTree(list);
|
|
filterConflicts();
|
|
updateBulkActions();
|
|
} catch (error) {
|
|
console.error("Failed to refresh conflicts:", error);
|
|
}
|
|
}
|
|
|
|
// --- Client-side filter and bulk selection -------------------------------
|
|
// Pure functions over the server-rendered conflict cards (rendering
|
|
// ownership: JS decides what shows; templ rendered what they look like).
|
|
|
|
function filterConflicts(): void {
|
|
const select = document.getElementById(
|
|
"status-filter",
|
|
) as HTMLSelectElement | null;
|
|
const filter = select?.value ?? "unresolved";
|
|
let total = 0;
|
|
let visible = 0;
|
|
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
|
|
total++;
|
|
const status = item.dataset.status ?? "unresolved";
|
|
const show = filter === "all" || status === filter;
|
|
item.style.display = show ? "" : "none";
|
|
if (show) visible++;
|
|
});
|
|
const empty = document.getElementById("filter-empty");
|
|
if (empty) empty.classList.toggle("hidden", visible > 0 || total === 0);
|
|
// Cards hidden by the filter can't stay selected.
|
|
if (visible !== total) updateBulkActions();
|
|
}
|
|
|
|
function toggleAllConflicts(): void {
|
|
const selectAll = document.getElementById(
|
|
"select-all-conflicts",
|
|
) as HTMLInputElement | null;
|
|
if (!selectAll) return;
|
|
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
|
|
if (item.style.display === "none") return;
|
|
const box = item.querySelector<HTMLInputElement>(".conflict-checkbox");
|
|
if (box) box.checked = selectAll.checked;
|
|
});
|
|
updateBulkActions();
|
|
}
|
|
|
|
function getSelectedConflictIds(): string[] {
|
|
return Array.from(
|
|
document.querySelectorAll<HTMLInputElement>(".conflict-checkbox:checked"),
|
|
)
|
|
.map((box) => box.dataset.conflictId || "")
|
|
.filter((id) => id !== "");
|
|
}
|
|
|
|
function updateBulkActions(): void {
|
|
const selected = getSelectedConflictIds();
|
|
const bar = document.getElementById("bulk-actions");
|
|
if (bar) bar.classList.toggle("hidden", selected.length === 0);
|
|
const count = document.getElementById("selected-count");
|
|
if (count) count.textContent = String(selected.length);
|
|
}
|
|
|
|
function resolveSelected(): void {
|
|
const ids = getSelectedConflictIds();
|
|
if (ids.length === 0) return;
|
|
const strategyEl = document.getElementById(
|
|
"bulk-strategy",
|
|
) as HTMLSelectElement | null;
|
|
const strategy = (strategyEl?.value ?? "most_recent") as
|
|
| "most_recent"
|
|
| "highest_progress";
|
|
void bulkResolve(strategy, ids);
|
|
}
|
|
|
|
function dismissSelected(): void {
|
|
const ids = getSelectedConflictIds();
|
|
if (ids.length === 0) return;
|
|
void bulkDismiss(ids);
|
|
}
|
|
|
|
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 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,
|
|
filterConflicts,
|
|
toggleAllConflicts,
|
|
updateBulkActions,
|
|
resolveSelected,
|
|
dismissSelected,
|
|
};
|
|
|
|
Alpine.data("conflicts", () => ({
|
|
init() {
|
|
filterConflicts();
|
|
},
|
|
bulkDismiss,
|
|
bulkResolve,
|
|
dismissAllResolved,
|
|
handleResolveSubmit,
|
|
hideResolveModal,
|
|
refreshConflicts,
|
|
resolveConflict,
|
|
showResolveModal,
|
|
filterConflicts,
|
|
toggleAllConflicts,
|
|
updateBulkActions,
|
|
resolveSelected,
|
|
dismissSelected,
|
|
}));
|