Files
bookhoard/templates/header.templ
T
John O'Keefe 2235b00c59 feat(podcasts): web UI — /podcasts page, feed detail, download lifecycle (task 1.9)
Rendering ownership holds throughout: every piece of server data is
templ-rendered; htmx swaps partials; the only TS is the Add-Feed modal
toggle + error surfacing (pure client-local state, bundled via
web/src/podcasts.ts with automatic bundle-hash stamping).

Sidebar: /podcasts nav entry (D13, between Series and Collections),
gated by a ShowPodcastsNav flag resolved centrally in
getTemplateUserWithTheme (one indexed COUNT — same class of
per-request lookup as the live-user guard). New 'podcast' icon.

/podcasts: header + Add Feed modal (htmx post → HX-Redirect to the
feed page; inline error surfacing on failure), subscription-scoped
search + facet bar (host/tag/language — server-side GET filters, no
JS), grid cards with download counts / new-episodes / complete /
explicit badges. Empty states for no-library (admin creates it first)
and no-subscriptions.

Feed detail: hero (artwork, host/publisher, tags, fetch state), action
row — Download All, Refresh, ORDER SELECTOR (D5 override), serial
one-click oldest-first suggestion (never silent), auto-download policy
select (D6), Unsubscribe (files retained), admin Remove Feed — gap
banner (D8 count), and the EPISODE LIST with the full lifecycle inline
(D8/D19): pending greyed + Download button (every non-downloaded
episode has one), queued/downloading pulse, failed RED + reason text +
Retry (same endpoint). The list is a self-refreshing partial: re-fetch
on the podcast:changed HX-Trigger and polls every 4s ONLY while the
server says downloads are active (it renders the polling variant).

API endpoints grew HX awareness: 204 + HX-Trigger podcast:changed (or
HX-Redirect for add/unsubscribe/remove) — the sanctioned Go-handler
response channels; JSON callers keep JSON. AddFeed result exposes the
fetch-error text for the redirect.

Dates render in the requesting user's timezone (D17) via
utils.FormatTimeInTimezone; episodes play through the existing
/readers/{id} audio reader (episodes ARE audiobook items — no new
player code day one). CSP tripwire (templates/csp_source_test.go)
passes: no inline scripts, no on*= handlers, no hx-on/js: vals.
2026-10-29 16:17:55 -04:00

356 lines
14 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/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"
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>
}