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 — 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 := `` return pattern.ReplaceAllString(html.EscapeString(text), markOpen+"$0") } // 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) {

{ resultCountLabel(len(items), query) }

if len(items) == 0 {
šŸ”

No results found

Try different keywords

} else {
for _, item := range items { @SearchResultRow(item, query) }

Press ↑↓ to navigate, Enter to select

} } templ SearchResultRow(item handlers.BookInfo, query string) {
{ @SearchProgressOverlay(item)

@templ.Raw(highlightQueryHTML(item.Title, query))

if item.Author != "" {

@templ.Raw(highlightQueryHTML(item.Author, query))

} if item.LibraryName != "" {

{ item.LibraryName }

}
} // 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 { @Icon("check", "h-3 w-3") } else if *item.ViewerPercentage > 0 {
} } } 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) }