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.
343 lines
13 KiB
Templ
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>
|
|
}
|