Files
bookhoard/templates/header.templ
T
John O'Keefe fd9292e6f2 fix(series): Date Added respects the direction toggle; bump bundle stamp
Two follow-ups from live testing:

- "recent" is BOTH a base toolbar key and a full registry key (where it
  means desc). ResolveSeriesSort's full-key passthrough ran first, so
  sort=recent&dir=asc silently resolved back to desc — the direction
  chevron flipped but Date Added could never sort ascending. "recent"
  now composes with the direction before the passthrough, unknown bases
  fall to the default entirely (a hostile dir can't ride an unknown
  sort), and the composition table is unit-pinned plus an HTTP-level
  row in the series filter test.

- The header search "still broken" report was a stale-cache artifact:
  the fixed code shipped but main.js kept its ?v=7 stamp, so browsers
  with a cached copy never refetched (fresh-profile e2e passed while
  the user's browser didn't — heuristically cached module). Stamp bumped
  to ?v=8 in header + docs; the e2e suite re-verified against the
  rebuilt server.
2026-10-27 22:55:11 -04:00

343 lines
13 KiB
Templ

package templates
templ Header(user User, currentPath string) {
<div
x-data="header"
>
<!-- 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>
<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/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>
<!-- 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 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>
<!-- module bundle; ?v=<commit-ish stamp> busts stale intermediate
builds across app restarts (browser caches are keyed by URL) -->
<script type="module" src="/static/main.js?v=8"></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>
}