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

+39
View File
@@ -742,6 +742,45 @@ func registerFrontendRoutes(cfg *Config) {
// itself here (htmx) whenever the library switcher dispatches
// week-refresh, carrying the current #library-select value. Same
// resolution rules as the dashboard page (family-lens safe).
// Unlinked-book match results partial: the match cascade for one device
// file, rendered server-side and scoped to the requester's visible
// libraries (absent-from-results) — rendering-ownership: templ renders,
// JS only swaps. NOTE: nothing routes to the unlinked-books page yet;
// this endpoint is ready for whichever UI wires it up.
frontendProtected.GET("/unlinked-books/matches", func(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
userUUID, uuidErr := uuid.Parse(user.ID.String())
if uuidErr != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if setErr != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to resolve visible libraries</div>")
}
visibleIDs := make([]string, 0, len(set))
for id := range set {
visibleIDs = append(visibleIDs, id)
}
req := &services.BookQueryRequest{
SHA256: c.QueryParam("sha256"),
Title: c.QueryParam("title"),
VisibleLibraryIDs: visibleIDs,
}
resp, matchErr := services.NewBookMatchingService(cfg.Queries).QueryBooks(c.Request().Context(), req)
if matchErr != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to search</div>")
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedMatchCards(c.QueryParam("progress_id"), resp.Matches).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {