Files
bookhoard/templates/header.templ
T
John O'Keefe d5dbaa48ea fix(ui): user-test round 4 — five browser-reported control defects
Every one of these was silent at the code level and loud in a real
browser; each fix carries its own tripwire (render pin, unit pin, or
CSP-safe rewrite) so the class cannot quietly return.

1. Theme picker dead since the CSP migration (eb57b720): the Appearance
   panel's theme buttons emitted `@click="selectTheme(opt.Name)"` as
   LITERAL text (templ gotcha #1 — quoted attribute values are never
   interpolated; `opt` is a Go loop var), so Alpine threw "Undefined
   variable: opt" on every click and no PUT /api/auth/theme ever fired.
   The wood-paneling sibling always used the correct expression form,
   which is why the panel half-worked. Fixed to the interpolated form;
   TestThemePickerHandlersAreInterpolated pins that real theme names
   reach the HTML and the raw identifier never does.

2. Filter-builder op dropdown "does nothing": the field ingest
   hardcoded `ops: []` into every field's meta while only mirroring the
   ops into the global label registry — opsFor() returned [] for EVERY
   field, so the op menu rendered zero items: an invisible menu, no
   console error (user tried author/publisher/title/subtitle, all
   equally dead). FieldMeta.ops is now the wire's op objects, opsFor()
   is a standalone exported helper, and opsForField unit tests pin the
   round-trip. Verified live: the field menu opens/closes while the op
   menu stayed shut, both with identical wiring — the difference was
   the data, not the wiring.

3. AND/OR connective chip dead: the chip carried an `if` STATEMENT in
   the Alpine attribute (`@click="if (row.chipClickable) ..."`) which
   the CSP build's evaluator rejects as a statement — the whole
   expression silently never ran. Moved the clickability check into a
   toggleConnective(row) component method; the attribute is now a plain
   method call.

4. Pencil vs card navigation race: navigateToCollection guarded with
   `event.target instanceof HTMLElement`, which is FALSE for the
   pencil glyph's SVGElement — a click landing on the glyph sailed past
   the guard and navigated while the htmx edit-modal request still
   fired (the reported "modal flashes, then the builder page"). The
   guard now uses target.closest("button") (works on SVGElement) and
   takes $event explicitly instead of the deprecated window.event.
   .icon-btn is already a 36px square, so no hit-area change needed.

5. Add Items modal under the sidebar + taller than the window: the
   picker overlay sat at z-50 UNDER the app-sidebar's z-60 (its left
   edge rendered beneath the nav), and the card had no height bound —
   header + tabs + filters + grid + footer overflow short windows with
   the footer unreachable (fixed overlay, no page scroll). The picker
   and create-modal overlays move to z-[65], both confirm dialogs to
   z-[70], and the picker card is now a flex column capped at
   max-h-[85vh] whose grids flex-fill (they grow on tall windows,
   shrink on short ones). The ladder is documented at .app-sidebar in
   input.css: sidebar 60 / overlays 65 / confirm 70 / toasts 9999.

Found by clicking every control the way a user does (panel clicks +
computed-style reads + screenshots) after the previous sweep trusted
DOM-text snapshots, which show hidden menus' text and cannot see
occlusion, stacking, or geometry at all.
2026-10-31 13:14:05 -04:00

382 lines
15 KiB
Templ

package templates
templ Header(user User, currentPath string) {
<div
x-data="header"
>
<!-- htmx loads with the header (classic script, before the module
bundle): the header's live search is htmx-driven on EVERY
page, so the include lives HERE — not per-page. Pages that
render @Header must not include htmx.min.js themselves
(double processing). -->
<script src="/static/htmx.min.js"></script>
<!-- Mobile backdrop -->
<div
class="app-sidebar-backdrop lg:hidden"
:class="{ 'is-open': mobileMenuOpen }"
@click="mobileMenuOpen = false"
x-cloak
></div>
<!-- Sidebar -->
<aside class="app-sidebar" :class="{ 'is-open': mobileMenuOpen }">
<!-- Logo -->
<a
href="/dashboard"
class="flex items-center gap-2.5 px-5 h-16 shrink-0 border-b"
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
>
<span class="grid place-items-center h-6 w-6 shrink-0" style="color: var(--accent);">
@Icon("book-open", "h-6 w-6")
</span>
<span class="font-bold text-lg tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
<!-- Scanning indicator: lives INSIDE the fixed-height logo row so
it can never push the nav entries down. Spinner sits last so
the growing percent extends leftward, not into the spinner. -->
<div
x-show="scanning"
x-cloak
x-transition
class="ml-auto flex items-center gap-1.5 shrink-0"
aria-live="polite"
>
<span
x-show="scanProgress > 0"
class="text-xs font-medium"
style="color: var(--text-secondary);"
x-text="scanProgress + '%'"
></span>
<svg
class="h-4 w-4 animate-spin"
style="color: var(--accent);"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</a>
<!-- Primary navigation -->
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<a href="/dashboard" class={ activeClass(currentPath, "/dashboard") }>
@Icon("library", "h-5 w-5 shrink-0")
<span>Library</span>
</a>
<a href="/bookshelf" class={ activeClass(currentPath, "/bookshelf") }>
@Icon("book", "h-5 w-5 shrink-0")
<span>All Books</span>
</a>
<a href="/series" class={ activeClass(currentPath, "/series") }>
@Icon("layers", "h-5 w-5 shrink-0")
<span>Series</span>
</a>
if user.ShowPodcastsNav {
<a href="/podcasts" class={ activeClass(currentPath, "/podcasts") }>
@Icon("podcast", "h-5 w-5 shrink-0")
<span>Podcasts</span>
</a>
}
<a href="/collections" class={ activeClass(currentPath, "/collections") }>
@Icon("folder", "h-5 w-5 shrink-0")
<span>Collections</span>
</a>
<a href="/progress" class={ activeClass(currentPath, "/progress") }>
@Icon("clock", "h-5 w-5 shrink-0")
<span>Progress</span>
</a>
<a href="/stats" class={ activeClass(currentPath, "/stats") }>
@Icon("chart", "h-5 w-5 shrink-0")
<span>Stats</span>
</a>
<a href="/devices" class={ activeClass(currentPath, "/devices") }>
@Icon("device", "h-5 w-5 shrink-0")
<span>Devices</span>
</a>
</nav>
<!-- Bottom: theme picker + user -->
<div
x-data={ "sidebarShell('" + string(ThemeModeOf(user.Theme)) + "')" }
class="shrink-0 px-3 py-3 space-y-2 border-t"
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
>
if user.ID != "" {
@SidebarUserMenu(user)
} else {
@SidebarSignIn(currentPath)
}
<!-- Theme picker -->
<div class="sidebar-panel">
<button
type="button"
@click="toggleAppearancePanel()"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover"
>
@Icon("palette", "h-5 w-5 shrink-0")
<span>Appearance</span>
<span class="ml-auto h-4 w-4 transition-transform" :class="{ 'rotate-180': openPanel === 'appearance' }">
@Icon("chevron-down", "h-4 w-4")
</span>
</button>
<div x-show="openPanel === 'appearance'" x-cloak x-transition class="mt-1 pl-1">
<!-- Dark / Light filter -->
<div class="grid grid-cols-2 gap-1 px-2 pt-1">
<button
type="button"
@click="setThemeMode('dark')"
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
:class={ "themeMode === 'dark' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
>
@Icon("moon", "h-3.5 w-3.5 shrink-0")
<span>Dark</span>
<span class="tabular-nums opacity-50">{ len(ThemeOptionsFor(ModeDark)) }</span>
</button>
<button
type="button"
@click="setThemeMode('light')"
class="flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
:class={ "themeMode === 'light' ? 'mode-toggle-active' : 'text-content-muted hover:text-content'" }
>
@Icon("sun", "h-3.5 w-3.5 shrink-0")
<span>Light</span>
<span class="tabular-nums opacity-50">{ len(ThemeOptionsFor(ModeLight)) }</span>
</button>
</div>
<!-- Scrollable theme list (filtered by the mode above) -->
<div class="mt-1 max-h-72 overflow-y-auto pr-1 space-y-0.5">
for _, opt := range ThemeOptions {
<button
type="button"
@click={ "selectTheme('" + opt.Name + "')" }
class="theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
x-show={ "themeMode === '" + string(opt.Mode) + "'" }
data-theme={ opt.Name }
data-mode={ string(opt.Mode) }
style="color: var(--text-secondary);"
>
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-color: " + opt.Color }></span>
<span class="flex-1 text-left">{ opt.Label }</span>
<span class={ "theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme) }>
@Icon("check", "h-4 w-4")
</span>
</button>
}
</div>
<div class="my-1.5 mx-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"></div>
<p class="px-3 pb-1 text-xs font-medium uppercase tracking-wide" style="color: var(--text-secondary);">Bookshelf</p>
for _, w := range WoodOptions {
<button
type="button"
@click={ "changeWoodPaneling('" + w.Name + "')" }
class="wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
data-wood={ w.Name }
style="color: var(--text-secondary);"
>
if w.Name == "none" {
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style="background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);"></span>
} else {
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;" }></span>
}
<span class="flex-1 text-left">{ w.Label }</span>
</button>
}
</div>
</div>
<!-- Admin section (visible to admins only) -->
if user.Role == "admin" {
<div class="sidebar-panel">
<button
type="button"
@click="openPanel = openPanel === 'admin' ? null : 'admin'"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover"
>
@Icon("shield", "h-5 w-5 shrink-0")
<span>Administration</span>
<svg
class="h-4 w-4 ml-auto transition-transform"
:class="{ 'rotate-180': openPanel === 'admin' }"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div x-show="openPanel === 'admin'" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
<a href="/admin" class={ activeClass(currentPath, "/admin") }>
@Icon("grid", "h-5 w-5 shrink-0")
<span>Dashboard</span>
</a>
<a href="/admin/library" class={ activeClass(currentPath, "/admin/library") }>
@Icon("library", "h-5 w-5 shrink-0")
<span>Libraries</span>
</a>
<a href="/admin/podcasts" class={ activeClass(currentPath, "/admin/podcasts") }>
@Icon("podcast", "h-5 w-5 shrink-0")
<span>Podcasts</span>
</a>
<a href="/admin/hash-conflicts" class={ activeClass(currentPath, "/admin/hash-conflicts") }>
@Icon("copy", "h-5 w-5 shrink-0")
<span>Hash Conflicts</span>
</a>
<a href="/admin/users" class={ activeClass(currentPath, "/admin/users") }>
@Icon("users", "h-5 w-5 shrink-0")
<span>Users</span>
</a>
<a href="/admin/settings" class={ activeClass(currentPath, "/admin/settings") }>
@Icon("gear", "h-5 w-5 shrink-0")
<span>Settings</span>
</a>
</div>
</div>
}
</div>
</aside>
<!-- Global confirm modal (issue #5): ONE styled dialog answers every
hx-confirm sitewide via the htmx:confirm interceptor in
web/src/confirm.ts, and exposes the confirmDialog() promise for
legacy JS call sites. Inert until shown. MUST render on every
page (it previously lived inside SidebarSignIn — signed-in
pages had no #confirm-modal, so confirmDialog degraded to the
native window.confirm; fixed 2026-10-30, user report). -->
<div
id="confirm-modal"
class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div class="card w-full max-w-sm" style="box-shadow: var(--shadow-pop);">
<div class="p-5 border-b" style="border-color: var(--border);">
<p id="confirm-modal-message" class="text-sm font-medium" style="color: var(--text-primary)"></p>
</div>
<div class="p-4 flex justify-end gap-2">
<button id="confirm-modal-cancel" class="btn btn-secondary">Cancel</button>
<button id="confirm-modal-accept" class="btn btn-primary">Confirm</button>
</div>
</div>
</div>
<!-- Topbar -->
<header class="app-topbar">
<div class="flex items-center gap-3 px-4 sm:px-6 h-full">
<button
type="button"
class="app-mobile-only icon-btn"
@click="mobileMenuOpen = true"
aria-label="Open menu"
>
@Icon("menu", "h-5 w-5")
</button>
<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"
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>
<!-- module bundle; ?v=<content hash> busts stale intermediate
builds across app restarts (browser caches are keyed by URL);
the hash is computed at startup from the served file -->
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
</div>
}
// SidebarUserMenu is the bottom-of-sidebar account control for signed-in users.
templ SidebarUserMenu(user User) {
<div class="sidebar-panel">
<button
type="button"
@click="openPanel = openPanel === 'user' ? null : 'user'"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover"
style="color: var(--text-primary);"
>
<span class="grid place-items-center h-7 w-7 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("user", "h-4 w-4")
</span>
<span class="flex-1 text-left truncate">{ user.Username }</span>
<span class="h-4 w-4 shrink-0 transition-transform" :class="{ 'rotate-180': openPanel === 'user' }">
@Icon("chevron-down", "h-4 w-4")
</span>
</button>
<div x-show="openPanel === 'user'" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
<a href="/profile" class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
@Icon("user", "h-4 w-4")
<span>Profile</span>
</a>
<button
type="button"
@click="logout()"
class="w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
style="color: var(--text-secondary);"
>
@Icon("logout", "h-4 w-4")
<span>Logout</span>
</button>
</div>
</div>
}
// SidebarSignIn is an inline sign-in for signed-out visitors, preserving the
// original header's inline login (htmx POST) so users can auth from any page.
templ SidebarSignIn(currentPath string) {
<div class="sidebar-panel">
<button
type="button"
@click="openPanel = openPanel === 'signin' ? null : 'signin'"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover"
style="color: var(--text-primary);"
>
<span class="grid place-items-center h-7 w-7 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("user", "h-4 w-4")
</span>
<span class="flex-1 text-left">Sign in</span>
<span class="h-4 w-4 shrink-0 transition-transform" :class="{ 'rotate-180': openPanel === 'signin' }">
@Icon("chevron-down", "h-4 w-4")
</span>
</button>
<div x-show="openPanel === 'signin'" x-cloak x-transition class="mt-1 px-1">
<form
hx-post="/api/auth/login"
hx-target="#login-result"
hx-swap="innerHTML"
class="space-y-2"
>
<input type="hidden" name="redirect" value={ currentPath }/>
<input type="text" name="login" class="input py-1.5 text-sm" placeholder="Email or username" required/>
<input type="password" name="password" class="input py-1.5 text-sm" placeholder="Password" required/>
<button type="submit" class="btn btn-primary w-full py-1.5 text-sm">Sign In</button>
</form>
<div id="login-result"></div>
if SignupVisible() {
<a href="/register" class="block text-center text-xs mt-2 hover:underline" style="color: var(--text-secondary);">
Create an account
</a>
}
</div>
</div>
}