Release / build-and-push (push) Canceled after 42s
Sidebar appearance menu improvements:
Accordion behavior:
- Lift panel open/close state to a shared 'openPanel' variable on the
parent container so only one sidebar panel (User, Appearance, Admin,
Sign In) can be open at a time; all can be closed.
- Admin panel still auto-opens on /admin/* pages via initial state.
- Add chevron rotation to User and Appearance panels (previously only
Admin rotated); add a chevron to the Sign In panel for consistency.
Wood texture previews:
- Generate 48x48 WebP thumbnails (~200 bytes each) from the full-size
PNG textures (873 KB – 1.9 MB) so the bookshelf option circles show
the actual wood grain instead of a flat grey dot.
- Use unquoted url() in the inline style to avoid templ's double-HTML
escaping of single quotes (SanitizeStyleAttributeValues + EscapeString
turned url('...') into url('...) which is invalid CSS).
- 'None' keeps the flat neutral circle.
CSS resilience:
- Move the wood background-image: url() rules from the compiled
style.css into input.css (the Tailwind source) so they survive CSS
rebuilds instead of being silently lost.
276 lines
11 KiB
Templ
276 lines
11 KiB
Templ
package templates
|
|
|
|
templ Header(user User, currentPath string) {
|
|
<div
|
|
x-data="header"
|
|
x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()"
|
|
>
|
|
<!-- 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>
|
|
<svg
|
|
x-show="scanning"
|
|
x-cloak
|
|
x-transition
|
|
class="h-4 w-4 animate-spin ml-auto"
|
|
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>
|
|
</a>
|
|
<span
|
|
x-show="scanning && scanProgress > 0"
|
|
x-cloak
|
|
x-transition
|
|
class="block px-5 py-1.5 text-xs font-medium -mt-1"
|
|
style="color: var(--text-secondary);"
|
|
x-text="'Scanning… ' + scanProgress + '%'"
|
|
></span>
|
|
<!-- 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="/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="{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null }"
|
|
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="openPanel = openPanel === 'appearance' ? null : 'appearance'"
|
|
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 space-y-0.5 pl-1">
|
|
for _, opt := range ThemeOptions {
|
|
<button
|
|
type="button"
|
|
@click={ "changeTheme('" + opt.Name + "'); openPanel = null" }
|
|
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"
|
|
data-theme={ opt.Name }
|
|
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 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/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">
|
|
<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"
|
|
placeholder="Search your library…"
|
|
class="input pl-10"
|
|
autocomplete="off"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<script type="module" src="/static/main.js"></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>
|
|
<a href="/register" class="block text-center text-xs mt-2 hover:underline" style="color: var(--text-secondary);">
|
|
Create an account
|
|
</a>
|
|
</div>
|
|
</div>
|
|
}
|