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
+23
-1
@@ -224,17 +224,39 @@ templ Header(user User, currentPath string) {
|
||||
>
|
||||
@Icon("menu", "h-5 w-5")
|
||||
</button>
|
||||
<div class="relative flex-1 max-w-xl">
|
||||
<div class="relative flex-1 max-w-xl" x-data="headerSearch" @click.outside="close()">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="header-search"
|
||||
name="q"
|
||||
placeholder="Search your library…"
|
||||
class="input pl-10"
|
||||
autocomplete="off"
|
||||
x-ref="input"
|
||||
role="combobox"
|
||||
:aria-expanded="open ? 'true' : 'false'"
|
||||
aria-label="Search your library"
|
||||
@input="onInput()"
|
||||
@focus="onFocus()"
|
||||
@keydown.arrow-down.prevent="moveSelection(1)"
|
||||
@keydown.arrow-up.prevent="moveSelection(-1)"
|
||||
@keydown.enter.prevent="openSelected()"
|
||||
@keydown.escape="close()"
|
||||
hx-get="/api/media-items/search?layout=dropdown"
|
||||
hx-trigger="search delay:300ms"
|
||||
hx-target="#search-results"
|
||||
hx-swap="innerHTML"
|
||||
/>
|
||||
<div
|
||||
id="search-results"
|
||||
x-show="open"
|
||||
x-cloak
|
||||
class="absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in new issue
Block a user