feat(search): viewer progress + completed badges; dropdown to templ

Search results now show where you are in a book: a completed check at
the 0.999 threshold (same cut as the filter status arms), a partial
progress bar along the cover while mid-read, nothing when you never
opened it. Rendered in BookCard (any grid whose adapter loads viewer
progress) and in the header-search dropdown rows.

The progress rides a LEFT JOIN reading_progress on the SAME user_id
param SearchMediaItemsUnified already binds for library visibility —
one requester param, no new wire inputs, row-neutral join (the table
is UNIQUE per media_item+user). SearchMediaItemsUnifiedRow and BookInfo
gain the keys additively (viewer_percentage null when unknown,
library_name empty on feeds that don't load it); contract_pins.md
carries the changelog entry and the pin tests widen in lockstep.

The header dropdown itself migrates off the innerHTML string renderer
in search.ts (a rendering-ownership violation: a templ twin existed via
the HX-Request branch). The input now hx-gets layout=dropdown and swaps
the new templ SearchResults partial — titles/authors highlighted
server-side (escape-first, safe by construction), rows with the same
progress overlays — while a slim Alpine component owns only
client-local state (min-length gating, open/close, keyboard selection,
htmx lifecycle). web/src/rendering-allowlist.txt drops the search.ts
baseline to zero accordingly.

Integration test pins the per-user join (fresh user sees 1.0/0.42/null
on the fixture; the shared admin sees null everywhere — read-only) and
the dropdown partial's overlays/count/row count.
This commit is contained in:
John O'Keefe committed 2026-10-27 15:11:49 -04:00
1 parent d1f9ad3b25
commit 61e5f47ec0
18 files changed
+1140 -616

No files matched your search

+8 -2
View File
@@ -90,9 +90,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
// Render using BooksGrid template (or BookPickerGrid for collection picker)
var buf bytes.Buffer
if c.QueryParam("show_checkbox") == "true" {
switch {
case c.QueryParam("show_checkbox") == "true":
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
} else {
case c.QueryParam("layout") == "dropdown":
// Header-search dropdown: compact templ rows (rendering
// ownership) carrying the viewer's progress/completed overlay.
// Replaces the old innerHTML string renderer in search.ts.
err = templates.SearchResults(bookInfoList, c.QueryParam("q")).Render(c.Request().Context(), &buf)
default:
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
}
if err != nil {