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:
1 parent
d1f9ad3b25
commit
61e5f47ec0
18 files changed
+1140
-616
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user