The design parked in the handoff (multi-tier stats off reading_history; bookorbit-style in data, not looks) is in. The real prerequisite work was data: reading_history existed but NOTHING wrote to it — the whole stats feature was sitting on an empty table. - CAPTURE (no client changes, no repo-boundary crossings): every successful progress write through SaveProgress (KOReader + app + web + Kobo + offline queue — the one chokepoint) now records one reading_history session row: span = the interval since the PREVIOUS write instant, pages = positive page delta when both sides know the page index, marker rows for first contact. Spans over 2h are kept as zero-second markers (overnight suspend→resume, offline flushes must not inflate totals) — the honest 'sync cadence, not a stopwatch' caveat stays. device_id only when a REAL device row matches (the web/app progress path historically stuffs the USER id into DeviceID — probe instead of trusting it; FK violation would silently drop every capture). Source preserved in device_metadata. - API: /api/stats/summary?range=7|30|90|year (totals, zero-filled daily arrays, streaks, most-active day/weekday, top books, library breakdown — one call), /api/stats/this-week (widget: rolling 7d vs previous + streak), /api/stats/books/:id (lifetime + recent sessions). All bucketed in the user's timezone; finished ≡ any push ≥0.995; visible-library lens enforced at source. Legacy /api/analytics/* kept untouched. Range windows anchor end-of-day to the START of today (blasting the clock time into tomorrow resolved 'today' a calendar day late — found by the streak test). - /stats page (sidebar Stats between Progress and Devices, previously /analytics was an orphan): range selector, five stat cards, two Chart.js v4 charts (daily minutes bar, pages/day line — chunked via dynamic import so main.js stays untouched), per-library breakdown, most-read list, empty-state explainer. /analytics now 301s to /stats. The orphaned analytics.templ + analytics.ts removed (their containers and the page JSON never matched; the page was astub). - Dashboard 'This Week' widget (server-rendered, suppressed on service failure) and the book detail 'Your Reading Stats' card (lifetime totals of the viewer's own sessions) complete the tiers: per-user default, per-library breakdown inline, per-book on the page, per-system/admin aggregate parked as before. Tests: statistics_test.go (capture semantics at the chokepoint, tz/day buckets, zero-fill, streak math, finished detection, range validation, auth gate, widget, per-book lifetime). Full suite PASS.
286 lines
11 KiB
Templ
286 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="/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="{ 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/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">
|
|
<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>
|
|
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>
|
|
}
|