feat(matching): match results as a scoped templ partial + enriched matches

- BookMatch now carries title/author/cover (the fields the old client
  renderer always assumed but the API never sent — cards would have
  shown 'undefined'); filled during the family-lens pass in QueryBooks
- new partial route GET /unlinked-books/matches renders the scoped
  match cascade server-side; searchMatches swaps it via htmx.ajax
  instead of rebuilding cards from JSON
- NOTE: the unlinked-books surface is currently dark end-to-end — the
  templ page has no route, the live linking.ts renderer targets a
  container no template has, and its Link button calls a
  window.showMatchModal that does not exist. This commit ships the
  scoped backend + partial; wiring the UI is a product decision.
This commit is contained in:
John O'Keefe committed 2026-10-18 16:00:04 -04:00
1 parent a31da89df5
commit 327fc2c0f9
5 files changed
+383 -160

No files matched your search

+15 -44
View File
@@ -18,52 +18,23 @@ function searchMatches(
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
const token = getToken();
const url = sha256
? `/api/books/match?sha256=${sha256}`
: `/api/books/match?title=${encodeURIComponent(title)}`;
// Rendering ownership: results are server-rendered markup (scoped to
// the caller's visible libraries); swap in the templ partial instead of
// rebuilding cards from JSON.
const params = new URLSearchParams({ progress_id: progressId });
if (sha256) params.set("sha256", sha256);
if (title) params.set("title", title);
fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => response.json())
.then((result) => {
if (result.matches && result.matches.length > 0) {
matchesList.innerHTML = result.matches
.map(
(match: any) => `
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-primary); border-color: var(--border);"
data-action="auto-link" data-progress-id="${progressId}" data-media-item-id="${match.media_item_id}" data-confidence="${match.confidence}">
<div class="flex gap-4">
<img src="${match.cover_image_path || "/static/placeholder-book.svg"}"
alt="Cover" class="w-16 h-24 object-cover rounded">
<div>
<h5 class="font-semibold" style="color: var(--text-primary)">${match.title}</h5>
<p class="text-sm" style="color: var(--text-secondary)">by ${match.author || "Unknown"}</p>
<div class="mt-2 flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary);">
${Math.round(match.confidence * 100)}% confidence
</span>
<span class="text-xs" style="color: var(--text-secondary)">${match.match_method}</span>
</div>
</div>
</div>
</div>
`,
)
.join("");
} else {
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>';
}
window.htmx
.ajax("GET", `/unlinked-books/matches?${params.toString()}`, {
target: `#matches-list-${progressId}`,
swap: "innerHTML",
})
.catch((error) => {
console.error("Failed to search", error);
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--error)">Failed to search</p>';
.then((ok) => {
if (!ok) {
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--status-danger)">Failed to search</p>';
}
});
}