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.
288 lines
11 KiB
Templ
288 lines
11 KiB
Templ
package templates
|
|
|
|
import "fmt"
|
|
|
|
// DocsLayout renders the standalone documentation site: a docs-owned topbar
|
|
// (search, theme picker, Open App), a docs-only sidebar with category →
|
|
// subcategory navigation, and the markdown content.
|
|
//
|
|
// It deliberately does NOT embed the app Header: docs are a public surface
|
|
// (setup and troubleshooting happen pre-login), and the app chrome assumed a
|
|
// signed-in user — it rendered a sign-in form that silently degraded into a
|
|
// native GET submit (credentials into the URL) because this page never loaded
|
|
// htmx. Signed-out visitors get a plain "Sign in" LINK here; the form lives
|
|
// on /login where it belongs.
|
|
templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
@ThemeFlashGuard()
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{ doc.Title } - Bookhoard Documentation</title>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="docs" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
|
|
@DocsTopbar(user)
|
|
<!-- Mobile drawer backdrop -->
|
|
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" @click="closeDocsSidebar($event)"></div>
|
|
<div class="flex">
|
|
@DocsSidebar(nav, currentPath)
|
|
<main class="flex-1 min-w-0">
|
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-10">
|
|
if len(doc.Breadcrumb) > 0 {
|
|
<nav class="breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-6" aria-label="Breadcrumb" style="color: var(--text-secondary);">
|
|
for i, crumb := range doc.Breadcrumb {
|
|
if i > 0 {
|
|
@Icon("chevron-right", "h-4 w-4")
|
|
}
|
|
if crumb.URL != "" {
|
|
<a href={ crumb.URL } class="no-underline transition-colors hover:underline" style="color: var(--accent);">{ crumb.Title }</a>
|
|
} else {
|
|
<span>{ crumb.Title }</span>
|
|
}
|
|
}
|
|
</nav>
|
|
}
|
|
<h1 class="text-3xl sm:text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1>
|
|
if len(doc.TOC) > 0 {
|
|
<details class="toc card p-4 mb-8">
|
|
<summary class="cursor-pointer list-none [&::-webkit-details-marker]:hidden flex items-center gap-2 font-semibold transition-colors hover:bg-surface-hover -m-4 p-4 rounded-2xl" style="color: var(--text-primary);">
|
|
<span>On this page</span>
|
|
@Icon("chevron-down", "h-4 w-4 ml-auto")
|
|
</summary>
|
|
<div class="mt-3">
|
|
for _, item := range doc.TOC {
|
|
<a
|
|
href={ "#" + item.Anchor }
|
|
class="toc-item block py-1 text-sm no-underline transition-colors hover:text-brand"
|
|
style={ "margin-left: " + fmt.Sprintf("%d", item.Level-2) + "rem; color: var(--text-secondary);" }
|
|
>
|
|
{ item.Title }
|
|
</a>
|
|
}
|
|
</div>
|
|
</details>
|
|
}
|
|
<div class="prose max-w-none">
|
|
@UnsafeHTML(doc.Content).ToComponent()
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<!-- module bundle; ?v=<stamp> busts stale intermediate builds -->
|
|
<script type="module" src="/static/main.js?v=8"></script>
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// DocsTopbar is the docs site's own slim header. It never contains a login
|
|
// form — anonymous visitors get a link to /login (the only place credentials
|
|
// are posted from).
|
|
templ DocsTopbar(user User) {
|
|
<header class="sticky top-0 z-30 h-14 shrink-0 border-b flex items-center gap-2 sm:gap-3 px-4 sm:px-6" style="background: var(--bg-primary); border-color: var(--border);">
|
|
<button
|
|
type="button"
|
|
class="lg:hidden icon-btn shrink-0"
|
|
@click="toggleSidebar()"
|
|
aria-label="Toggle documentation menu"
|
|
>
|
|
@Icon("menu", "h-5 w-5")
|
|
</button>
|
|
<a href="/docs" class="flex items-center gap-2 shrink-0 no-underline">
|
|
<span class="grid place-items-center" style="color: var(--accent);">
|
|
@Icon("book-open", "h-6 w-6")
|
|
</span>
|
|
<span class="hidden sm:inline font-bold text-lg tracking-tight" style="color: var(--text-primary);">Bookhoard</span>
|
|
<span class="text-xs font-semibold px-2 py-0.5 rounded-full" style="background: var(--accent-muted); color: var(--accent);">Docs</span>
|
|
</a>
|
|
<div class="flex-1"></div>
|
|
@DocsSearchBox()
|
|
<div class="hidden md:block h-6 w-px shrink-0" style="background: var(--border);"></div>
|
|
@DocsThemePicker()
|
|
<div class="hidden md:flex items-center gap-2 shrink-0">
|
|
if user.ID != "" {
|
|
<a
|
|
href="/profile"
|
|
class="icon-btn"
|
|
title={ "Signed in as " + user.Username }
|
|
aria-label="Your profile"
|
|
>
|
|
@Icon("user", "h-5 w-5")
|
|
</a>
|
|
} else {
|
|
<a href="/login" class="text-sm font-medium hover:underline" style="color: var(--text-secondary);">Sign in</a>
|
|
}
|
|
<a href="/" class="btn btn-primary py-1.5 px-3 text-sm no-underline">Open App</a>
|
|
</div>
|
|
</header>
|
|
}
|
|
|
|
// DocsSearchBox hosts the header search input. Results are a server-rendered
|
|
// partial (GET /docs/search/partial) swapped into #docs-search-results by
|
|
// web/src/docs.ts — the server renders what results look like.
|
|
templ DocsSearchBox() {
|
|
<div
|
|
class="relative flex-1 max-w-72"
|
|
x-data="{ open: false }"
|
|
@click.outside="open = false"
|
|
@keydown.escape="open = false"
|
|
>
|
|
<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"
|
|
class="search-input input pl-9 py-1.5 text-sm"
|
|
placeholder="Search docs…"
|
|
id="docs-search"
|
|
autocomplete="off"
|
|
/>
|
|
<div
|
|
id="docs-search-results"
|
|
class="hidden absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-2xl border shadow-pop z-40"
|
|
style="background: var(--bg-secondary); border-color: var(--border);"
|
|
></div>
|
|
</div>
|
|
}
|
|
|
|
// DocsThemePicker reuses the app's theme Alpine component: initializeTheme
|
|
// applies the saved (and, when signed in, server) theme and syncs the select;
|
|
// changeTheme persists the choice. Anchors in the content smooth-scroll via
|
|
// the same init.
|
|
templ DocsThemePicker() {
|
|
<div x-data="theme" x-init="initializeTheme()" class="shrink-0">
|
|
<select
|
|
id="theme-select"
|
|
@change="changeTheme($event.target.value)"
|
|
class="input py-1.5 pr-8 text-sm cursor-pointer w-auto"
|
|
aria-label="Theme"
|
|
>
|
|
<optgroup label="Dark">
|
|
for _, opt := range ThemeOptionsFor(ModeDark) {
|
|
<option value={ opt.Name }>{ opt.Label }</option>
|
|
}
|
|
</optgroup>
|
|
<optgroup label="Light">
|
|
for _, opt := range ThemeOptionsFor(ModeLight) {
|
|
<option value={ opt.Name }>{ opt.Label }</option>
|
|
}
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
}
|
|
|
|
// DocsSidebar renders the category → subcategory navigation. Sections and
|
|
// groups toggle collapsed/expanded client-side (pure open/closed state);
|
|
// the server decides what is expanded on first paint via data-collapsed.
|
|
templ DocsSidebar(nav Navigation, currentPath string) {
|
|
<aside
|
|
id="docs-sidebar"
|
|
class="sidebar fixed lg:sticky top-14 bottom-0 lg:bottom-auto left-0 z-40 lg:z-10 w-72 shrink-0 lg:h-[calc(100vh-3.5rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line"
|
|
>
|
|
<nav class="py-2" aria-label="Documentation">
|
|
for _, section := range nav.Sections {
|
|
<div class="nav-section border-b border-line last:border-b-0 pb-2">
|
|
<button
|
|
type="button"
|
|
class="nav-section-title w-full flex items-center gap-2.5 px-4 py-2.5 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover"
|
|
style="color: var(--text-secondary);"
|
|
@click="toggleCollapsible($el)"
|
|
>
|
|
<span class="shrink-0" style="color: var(--accent);">
|
|
@Icon(section.Icon, "h-4 w-4")
|
|
</span>
|
|
<span class="flex-1 text-left">{ section.Title }</span>
|
|
<span class="section-arrow shrink-0 transition-transform" style={ chevronStyle(section.Collapsed) }>
|
|
@Icon("chevron-down", "h-4 w-4")
|
|
</span>
|
|
</button>
|
|
<div class="nav-items pb-1" data-collapsed={ collapsibleState(section.Collapsed) }>
|
|
for _, item := range section.Items {
|
|
<a href={ item.URL } class={ docsNavClass(currentPath, item.URL, "pl-7") }>
|
|
<span>{ item.Title }</span>
|
|
</a>
|
|
}
|
|
for _, group := range section.Groups {
|
|
<div class="mt-0.5">
|
|
<button
|
|
type="button"
|
|
class="nav-group-title w-full flex items-center gap-1.5 pl-7 pr-4 py-1.5 cursor-pointer select-none text-xs font-semibold uppercase tracking-wide transition-colors hover:bg-surface-hover"
|
|
style="color: var(--text-secondary);"
|
|
@click="toggleCollapsible($el)"
|
|
>
|
|
<span class="flex-1 text-left truncate">{ group.Title }</span>
|
|
<span class="section-arrow shrink-0 transition-transform" style={ chevronStyle(group.Collapsed) }>
|
|
@Icon("chevron-down", "h-3.5 w-3.5")
|
|
</span>
|
|
</button>
|
|
<div class="nav-group-items" data-collapsed={ collapsibleState(group.Collapsed) }>
|
|
for _, item := range group.Items {
|
|
<a href={ item.URL } class={ docsNavClass(currentPath, item.URL, "pl-9") }>
|
|
<span class="truncate">{ item.Title }</span>
|
|
</a>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</nav>
|
|
</aside>
|
|
}
|
|
|
|
// DocsSearchResults renders the search results partial swapped into the
|
|
// header dropdown. Server-rendered on purpose (rendering ownership): the
|
|
// client never builds result markup from JSON.
|
|
templ DocsSearchResults(results []SearchResultItem) {
|
|
if len(results) == 0 {
|
|
<div class="px-4 py-3 text-sm" style="color: var(--text-secondary);">No matching pages.</div>
|
|
} else {
|
|
<ul class="py-1 list-none m-0 p-0">
|
|
for _, r := range results {
|
|
<li>
|
|
<a href={ r.URL } class="block px-4 py-2 no-underline transition-colors hover:bg-surface-hover" @click="closeSearchResults($event)">
|
|
<span class="block text-sm font-medium truncate" style="color: var(--text-primary);">{ r.Title }</span>
|
|
if r.Category != "" {
|
|
<span class="block text-xs" style="color: var(--text-secondary);">{ r.Category }</span>
|
|
}
|
|
</a>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
}
|
|
|
|
// DocsNotFound renders the standalone docs 404 page: same theme tokens, no
|
|
// app chrome, no CDN dependencies.
|
|
templ DocsNotFound(title string, message string) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
@ThemeFlashGuard()
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{ title } - Bookhoard Documentation</title>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body class="min-h-screen font-sans antialiased">
|
|
<div class="min-h-screen flex items-center justify-center p-4">
|
|
<div class="card p-8 max-w-md w-full text-center">
|
|
<span class="inline-grid place-items-center mb-4" style="color: var(--accent);">
|
|
@Icon("book-open", "h-10 w-10")
|
|
</span>
|
|
<h1 class="text-2xl font-bold mb-3" style="color: var(--text-primary);">{ title }</h1>
|
|
<p class="text-sm mb-6" style="color: var(--text-secondary);">{ message }</p>
|
|
<div class="flex items-center justify-center gap-3">
|
|
<a href="/docs" class="btn btn-primary py-2 px-4 text-sm no-underline">Documentation</a>
|
|
<a href="/" class="btn py-2 px-4 text-sm no-underline">Bookhoard</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
}
|