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

+127
View File
@@ -0,0 +1,127 @@
package templates
import (
"fmt"
"html"
"regexp"
"bookhoard/internal/handlers"
)
// CompletedProgressThreshold is the viewer_percentage at which a card
// shows the completed check instead of a partial-progress bar — the
// SAME 0.999 cut the filter_query.go status arms and the series-page
// status filter use.
const CompletedProgressThreshold = 0.999
// progressFillCSS renders the partial-progress bar width (gotcha #8:
// quote-free style values only — templ double-escapes quotes into
// literal text the CSS parser never sees).
func progressFillCSS(pct float64) string {
return fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", pct*100)
}
// highlightQueryHTML escapes text FIRST and only then wraps
// case-insensitive query matches in <mark> — the returned HTML is
// safe by construction (escaped payload, marker tags we wrote), which
// is what lets the search partial emit it via templ.Raw.
func highlightQueryHTML(text, query string) string {
if text == "" || query == "" {
return html.EscapeString(text)
}
pattern := regexp.MustCompile(regexp.QuoteMeta(html.EscapeString(query)))
markOpen := `<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">`
return pattern.ReplaceAllString(html.EscapeString(text), markOpen+"$0</mark>")
}
// searchCoverURL falls back to the placeholder the way BookCard does —
// no inline onerror (CSP: hash-mode refuses native handler attributes).
func searchCoverURL(item handlers.BookInfo) string {
if item.CoverImagePath != "" {
return item.CoverImagePath
}
return "/static/placeholder-book.svg"
}
// SearchResults is the header-search dropdown partial: compact result
// rows swapped into #search-results by the htmx request the header
// input fires (layout=dropdown on /api/media-items/search). Content is
// server-rendered (rendering-ownership doctrine); web/src/search.ts
// only owns open/close/keyboard state.
templ SearchResults(items []handlers.BookInfo, query string) {
<div class="p-3 border-b" style="border-color: var(--border)">
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
{ resultCountLabel(len(items), query) }
</p>
</div>
if len(items) == 0 {
<div class="p-4 text-center">
<div class="text-4xl mb-2">🔍</div>
<p class="text-sm" style="color: var(--text-primary)">No results found</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
</div>
} else {
<div class="max-h-96 overflow-y-auto">
for _, item := range items {
@SearchResultRow(item, query)
}
</div>
<div class="p-2 border-t text-center" style="border-color: var(--border)">
<p class="text-xs" style="color: var(--text-secondary)">
Press <kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">↑↓</kbd> to navigate,
<kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">Enter</kbd> to select
</p>
</div>
}
}
templ SearchResultRow(item handlers.BookInfo, query string) {
<div class="search-result-item p-3 border-b transition-colors cursor-pointer" style="border-color: var(--border)">
<a href={ "/media/" + item.MediaItemID } class="block">
<div class="flex items-start space-x-3">
<div class="relative w-10 h-14 flex-shrink-0 rounded overflow-hidden bg-gradient-to-br from-gray-700 to-gray-900">
<img src={ searchCoverURL(item) } alt={ item.Title } class="w-full h-full object-cover" loading="lazy"/>
@SearchProgressOverlay(item)
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">@templ.Raw(highlightQueryHTML(item.Title, query))</h4>
if item.Author != "" {
<p class="text-xs truncate" style="color: var(--text-secondary)">@templ.Raw(highlightQueryHTML(item.Author, query))</p>
}
if item.LibraryName != "" {
<p class="text-xs mt-1" style="color: var(--text-secondary)">{ item.LibraryName }</p>
}
</div>
</div>
</a>
</div>
}
// SearchProgressOverlay is the cover overlay shared by the dropdown
// rows and BookCard: a completed check at/above the 0.999 threshold,
// a partial fill bar along the cover bottom while mid-read, nothing
// when the viewer never opened the book.
templ SearchProgressOverlay(item handlers.BookInfo) {
if item.ViewerPercentage != nil {
if *item.ViewerPercentage >= CompletedProgressThreshold {
<span
class="absolute top-0.5 left-0.5 z-10 flex items-center justify-center h-4 w-4 rounded-full shadow-sm"
style="background-color: var(--accent); color: var(--bg-primary);"
title="Completed"
>
@Icon("check", "h-3 w-3")
</span>
} else if *item.ViewerPercentage > 0 {
<div class="absolute bottom-0 left-0 right-0 z-10 h-1" style="background-color: color-mix(in srgb, var(--bg-primary) 60%, transparent);">
<div class="h-full" style={ progressFillCSS(*item.ViewerPercentage) }></div>
</div>
}
}
}
func resultCountLabel(n int, query string) string {
if n == 1 {
return fmt.Sprintf("1 result for “%s”", query)
}
return fmt.Sprintf("%d results for “%s”", n, query)
}