§0 item 8 (Phase-4 item 1 pulled forward, user directive: maintenance
tooling ships WITH the features it maintains):
- /admin/podcasts (AdminMiddleware): every feed from EVERY user via the
RLS admin escape (first-admin identity, poller/fetcher contract) —
feed + host, library, subscriber count/names, downloaded/total, DISK
USAGE, last fetch. Sorted disk DESC ('what's eating the disk'),
totals header (feeds · episodes · bytes on disk).
- Per-feed actions: Refresh · Delete files (rows → pending, disk freed;
confirms spell out the re-materialization contract) · Remove feed
(rows + files for everyone — the same admin delete as the detail
page). All confirms ride the global confirm modal.
- New sqlc: ListAllPodcastFeedsAdmin (correlated aggregates),
ResetPodcastEpisodeFileDeleted / ResetPodcastFeedFilesDeleted —
downloaded rows reset to pending with download-derived facts cleared
(audio_metadata, chapter_count, cover, size, mime); file_path KEPT
(fetch-maintained layout — a re-download lands on the same path).
file deletion via the existing DeleteFeedFiles file-removal pattern
(episode files + .cover.jpg siblings).
- Poll upsert never touches download_state, so deleted files STAY
deleted — a feed refresh re-lists metadata but re-materializes
nothing (pinned by TestAdminDeleteEpisodeFiles).
- Per-episode admin delete-file: POST /feeds/:id/episodes/:eid/
delete-file (admin) — one episode's file gone, row pending, siblings
untouched; HX swaps the episode list. (Found while testing: the
handler first read c.Param("id") — the FEED's uuid — instead of
:eid; the 404 'episode not found' trail led straight to it.)
- Tests: admin sees other users' feeds + subscriber names; regular
users 403 on page AND actions; single + feed-level deletion reset
rows/clear facts/delete files; refresh leaves pending rows pending.
- Admin nav gains 'Podcasts' under Administration.
Wrong-gotcha note for future agents: parseUUID on c.Param('id') inside
a /feeds/:id/episodes/:eid route resolves the FEED, not the episode.
382 lines
15 KiB
Templ
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-[60] 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>
|
|
}
|