feat(web): templ partials replace the last UI twins (collections grid, conflicts list)

Both pages had a templ render (initial load) and a TypeScript twin
(innerHTML rebuild on refresh) of the same fragment. The twins are
deleted; the server-rendered markup is now the only markup.

- collections: the library switcher swaps in the new books-grid partial
  (GET /collections/:id/books-grid) instead of re-rendering cards from
  the /api/collections JSON. Book loading is extracted into
  Config.loadCollectionBooks, shared verbatim by the page and the
  partial (smart / dashboard-query / manual branches, family-lens
  scoping via resolveLibrary + UserVisibleLibraryIDs) so the two cannot
  drift.
- conflicts: resolve/dismiss refreshes swap the new list partial
  (GET /conflicts/list) with the stats bar as an out-of-band swap,
  instead of the TS twin — which rendered a degraded card (no
  checkboxes, no timestamps), pointed at a window.showResolveModal that
  does not exist, and updated stats via element ids the SSR never had.
- shims.d.ts: window.htmx typing (ajax/process) for TS callers.

Visibility note: the books-grid partial inherits its family-lens
scoping from the shared loader (identical to the detail page); a
dedicated hidden-library pin test for the partial route is still worth
adding in the next test pass.
This commit is contained in:
John O'Keefe committed 2026-10-18 12:28:25 -04:00
1 parent 1fdf306f2d
commit f1844591b4
9 files changed
+1078 -889

No files matched your search

+8 -54
View File
@@ -2,19 +2,16 @@ import { Alpine } from "./alpine";
import { showToast } from "./toast";
async function refreshConflicts(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
// 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 {
const response = await fetch("/api/conflicts", {
headers: { Authorization: `Bearer ${token}` },
await window.htmx.ajax("GET", "/conflicts/list", {
target: "#conflicts-list",
swap: "outerHTML",
});
if (response.ok) {
const data: ConflictListResponse = await response.json();
renderConflicts(data.conflicts);
updateConflictStats(data);
}
const list = document.getElementById("conflicts-list");
if (list) Alpine.initTree(list);
} catch (error) {
console.error("Failed to refresh conflicts:", error);
}
@@ -123,49 +120,6 @@ async function dismissAllResolved(): Promise<void> {
}
}
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(