feat(layout): desktop sidebar replaces top-nav
Phase 5g slice A. m wants projax aligned with mBrian's nav layout: fixed-
left sidebar on desktop, bottom-nav on mobile (slice B). This slice drops
the top-nav <header> and ships the desktop sidebar; the ≤767px viewport
temporarily renders nav-less until slice B lands the bottom-nav.
web/templates/layout.tmpl:
- Delete the old <header><nav>...</nav></header>. Replace with
<aside class="projax-sidebar"> carrying:
* .sidebar-top: brand (▦ + "projax")
* .sidebar-nav: 6 items (Tree → Dashboard → Calendar → Timeline →
Graph → Admin) with inline SVG icons. Active class set server-side
via `{{if eq $path "/dashboard"}}active{{end}}`.
* .sidebar-bottom: theme toggle + sign-out form + collapse toggle.
- Content wrapped in <main class="projax-main">.
- New pre-paint <script> in <head> reads
localStorage["projax.sidebar.collapsed"] and sets
data-sidebar-collapsed="true" on <html> BEFORE first paint so the
main-content margin doesn't flash 220px→56px on every navigation.
- Existing theme-toggle JS unchanged (the button is just relocated). New
body-end <script> wires the #sidebar-collapse button: toggle the
attribute, persist to localStorage, sync aria-expanded + title.
- DO NOT port mBrian's resize handle — that's the $effect-feedback bug
mBrian debugged at length. Static 220/56px is fine for v1.
web/static/style.css:
- Strip the pre-5g `header { ... }`, `header nav { ... }`,
`header .logout-form { ... }`, `header .brand { ... }`,
`header .theme-toggle { ... }` rules and the matching @media
overrides (320×, 480× targeted `header`).
- New `main.projax-main` rule: `margin-left: var(--projax-sidebar-width,
220px)` on desktop, transitions on collapse. The
`html[data-sidebar-collapsed="true"]` selector flips the var to 56px.
Mobile (≤767px) zeros the margin.
- New `.projax-sidebar` block: fixed-left, z-index 50, .nav-item /
.nav-icon / .nav-label rules, .active border-left accent (matches
mBrian's `border-left: 2px solid #8cf` pattern but uses var(--accent)
so it round-trips dark/light theme).
- @media (max-width: 767px) hides the sidebar so the phone isn't stuck
with a 220px-wide hole until slice B.
web/server.go:
- render() injects `Path: r.URL.Path` into the template data map (unless
caller pre-set it for tests) so the layout can mark the active nav
item without any per-handler boilerplate.
Tests (web/layout_test.go):
- TestLayoutSidebarOnDesktop: aside present, all six href + label pairs
rendered.
- TestLayoutActiveClass: /dashboard render has the Dashboard item with
.active and Tree without.
- TestLayoutCollapseScript: pre-paint localStorage restore + the
collapse-toggle handler both present.
- TestLayoutNoTopHeader: belt-and-braces — the pre-5g <header> and
.logout-btn classes are gone.
All existing tests stay green (TestLayoutHasAdminNavLink,
TestLayoutHasManifestAndAppleTouchIcon, TestLayoutHasViewportMeta,
TestCalendar*, TestTreeRenders, etc.). No test source edits required —
existing assertions look at page CONTENT, not chrome.
This commit is contained in:
106
web/layout_test.go
Normal file
106
web/layout_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLayoutSidebarOnDesktop confirms the Phase 5g sidebar markup is
|
||||
// rendered with all six nav items (Tree / Dashboard / Calendar /
|
||||
// Timeline / Graph / Admin). Per-item href + label asserted so a stray
|
||||
// edit can't silently lose a section.
|
||||
func TestLayoutSidebarOnDesktop(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
if !strings.Contains(body, `<aside class="projax-sidebar"`) {
|
||||
t.Fatalf("expected <aside class=\"projax-sidebar\"> in body, got: %s", truncate(body, 400))
|
||||
}
|
||||
for _, want := range []struct {
|
||||
href, label string
|
||||
}{
|
||||
{`/`, "Tree"},
|
||||
{`/dashboard`, "Dashboard"},
|
||||
{`/calendar`, "Calendar"},
|
||||
{`/timeline`, "Timeline"},
|
||||
{`/graph`, "Graph"},
|
||||
{`/admin`, "Admin"},
|
||||
} {
|
||||
if !strings.Contains(body, `href="`+want.href+`"`) {
|
||||
t.Errorf("sidebar missing href=%q", want.href)
|
||||
}
|
||||
if !strings.Contains(body, `<span class="nav-label">`+want.label+`</span>`) {
|
||||
t.Errorf("sidebar missing label %q", want.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLayoutActiveClass proves the server-side active marker fires only
|
||||
// on the cell whose href matches the request path. Render is driven by
|
||||
// the .Path field the render helper injects from r.URL.Path.
|
||||
func TestLayoutActiveClass(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
// Dashboard item should be active.
|
||||
if !strings.Contains(body, `class="nav-item active" title="Dashboard"`) {
|
||||
t.Errorf("expected Dashboard nav-item to carry .active on /dashboard, body: %s", truncate(body, 400))
|
||||
}
|
||||
// Tree item (href="/") must NOT be active on the /dashboard page.
|
||||
// The Tree anchor opens with the exact-path active match; on /dashboard
|
||||
// the substring `class="nav-item" title="Tree"` should be present and
|
||||
// not its `active` sibling.
|
||||
if !strings.Contains(body, `class="nav-item" title="Tree"`) {
|
||||
t.Errorf("expected Tree nav-item to be non-active on /dashboard")
|
||||
}
|
||||
if strings.Contains(body, `class="nav-item active" title="Tree"`) {
|
||||
t.Errorf("Tree nav-item should NOT be active on /dashboard")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLayoutCollapseScript proves the inline pre-paint script that
|
||||
// restores the sidebar collapsed state from localStorage ships unchanged.
|
||||
// Without it the main-content margin would flash from 220px → 56px on
|
||||
// every navigation when the user has the sidebar collapsed.
|
||||
func TestLayoutCollapseScript(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
// Pre-paint restore script.
|
||||
if !strings.Contains(body, `localStorage.getItem('projax.sidebar.collapsed')`) {
|
||||
t.Errorf("expected pre-paint localStorage restore script in layout")
|
||||
}
|
||||
// The collapse-toggle button + its handler are also part of the chrome.
|
||||
if !strings.Contains(body, `id="sidebar-collapse"`) {
|
||||
t.Errorf("expected #sidebar-collapse button in layout")
|
||||
}
|
||||
if !strings.Contains(body, `localStorage.setItem('projax.sidebar.collapsed'`) {
|
||||
t.Errorf("expected toggle handler that persists state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLayoutNoTopHeader proves the pre-5g <header> chrome is gone so
|
||||
// callers that asserted on old top-nav markup can't keep passing by
|
||||
// accident. Belt-and-braces guard for the migration.
|
||||
func TestLayoutNoTopHeader(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
if strings.Contains(body, `<header>`) || strings.Contains(body, `<header `) {
|
||||
t.Errorf("expected the pre-5g top <header> to be gone, but rendered body has one: %s", truncate(body, 400))
|
||||
}
|
||||
if strings.Contains(body, `class="logout-btn"`) {
|
||||
t.Errorf("expected the pre-5g .logout-btn to be replaced by the sidebar .logout-item")
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
@@ -890,6 +890,12 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
|
||||
if _, set := data["ThemeColor"]; !set {
|
||||
data["ThemeColor"] = themeColorForMeta(theme)
|
||||
}
|
||||
// Phase 5g: layout.tmpl marks the active sidebar/bottom-nav item by
|
||||
// comparing .Path to each item's href. Each request gets its own copy;
|
||||
// tests can still override with an explicit Path value.
|
||||
if _, set := data["Path"]; !set {
|
||||
data["Path"] = r.URL.Path
|
||||
}
|
||||
entry := "layout"
|
||||
switch name {
|
||||
case "login":
|
||||
|
||||
@@ -76,15 +76,21 @@
|
||||
* { box-sizing: border-box; }
|
||||
html { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--fg); background: var(--bg); }
|
||||
body { margin: 0; }
|
||||
header { background: var(--bg-alt); border-bottom: 1px solid var(--border); padding: 8px 16px; }
|
||||
header nav { display: flex; gap: 16px; align-items: center; }
|
||||
header .logout-form { margin: 0 0 0 auto; }
|
||||
header .logout-btn { background: none; border: none; color: var(--muted); cursor: pointer; padding: 4px 6px; font: inherit; }
|
||||
header .logout-btn:hover { color: var(--bad); text-decoration: underline; }
|
||||
header .brand { font-weight: 600; font-size: 1.1em; color: var(--fg); text-decoration: none; }
|
||||
header a { color: var(--accent); text-decoration: none; }
|
||||
header a:hover { text-decoration: underline; }
|
||||
main { padding: 16px 24px; max-width: 1100px; margin: 0 auto; }
|
||||
/* Phase 5g — top-nav is gone; .projax-sidebar fills the same role on
|
||||
desktop, .projax-bottom-nav (Slice B) on mobile. main has a sidebar-
|
||||
width margin on desktop and zero on mobile. */
|
||||
main.projax-main {
|
||||
padding: 16px 24px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto 0 var(--projax-sidebar-width, 220px);
|
||||
transition: margin-left 200ms ease;
|
||||
}
|
||||
html[data-sidebar-collapsed="true"] main.projax-main {
|
||||
margin-left: var(--projax-sidebar-collapsed-width, 56px);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
main.projax-main { margin-left: 0; }
|
||||
}
|
||||
h1 { font-size: 1.4em; margin: 0 0 8px; }
|
||||
h2 { font-size: 1.1em; margin: 24px 0 8px; }
|
||||
.counts { color: var(--muted); margin: 0 0 16px; }
|
||||
@@ -352,10 +358,7 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main { padding: 12px 14px; }
|
||||
header { padding: 8px 12px; }
|
||||
header nav { flex-wrap: wrap; gap: 12px; }
|
||||
header .logout-form { margin-left: 0; }
|
||||
main.projax-main { padding: 12px 14px; }
|
||||
|
||||
/* Filter chip strips become horizontal-scrollable. */
|
||||
.tagbar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
@@ -427,10 +430,7 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
|
||||
|
||||
@media (max-width: 480px) {
|
||||
html { font-size: 15px; } /* nudge up so default body text is legible without zooming */
|
||||
header { padding: 6px 10px; }
|
||||
header nav { gap: 8px; }
|
||||
header nav a { font-size: 0.95em; }
|
||||
main { padding: 8px 10px; }
|
||||
main.projax-main { padding: 8px 10px; }
|
||||
|
||||
/* Even tighter chip-row scroll behaviour on phone. */
|
||||
.tag, .mgmt-chip, .status-chip, .has-chip { padding: 8px 12px; }
|
||||
@@ -640,30 +640,10 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
|
||||
}
|
||||
.dashboard .task-row .todo-delete .x:hover { color: var(--bad); border-color: var(--bad); }
|
||||
|
||||
/* --- Theme toggle (Phase 4b) --- */
|
||||
header .theme-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
padding: 2px 8px;
|
||||
font-size: 1.05em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
header .theme-toggle:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
header .theme-toggle .theme-icon {
|
||||
display: inline-block;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
header .theme-toggle { padding: 6px 10px; font-size: 1em; min-height: 36px; }
|
||||
}
|
||||
/* --- Theme toggle (Phase 4b) — relocated into the Phase 5g sidebar.
|
||||
The .theme-icon span keeps its semantic role (the JS toggles its
|
||||
textContent ☀ ↔ ☾) but is now restyled as a sidebar nav-icon. The
|
||||
pre-5g header .theme-toggle rules are gone. */
|
||||
|
||||
/* --- Public listing (Phase 4d) --- */
|
||||
fieldset.public-listing {
|
||||
@@ -904,3 +884,121 @@ details.proj-section > summary.proj-section-summary small { font-weight: 400; }
|
||||
#calendar-filterbar { gap: 8px; }
|
||||
#calendar-filterbar .counts { margin-left: 0; }
|
||||
}
|
||||
|
||||
/* --- Phase 5g layout: desktop sidebar nav --- */
|
||||
:root {
|
||||
--projax-sidebar-width: 220px;
|
||||
--projax-sidebar-collapsed-width: 56px;
|
||||
}
|
||||
.projax-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--projax-sidebar-width);
|
||||
background: var(--bg-alt);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 50;
|
||||
transition: width 200ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
html[data-sidebar-collapsed="true"] .projax-sidebar {
|
||||
width: var(--projax-sidebar-collapsed-width);
|
||||
}
|
||||
.projax-sidebar .sidebar-top {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.projax-sidebar .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 1.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.projax-sidebar .brand-icon {
|
||||
font-size: 1.2em;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
html[data-sidebar-collapsed="true"] .projax-sidebar .brand-label {
|
||||
display: none;
|
||||
}
|
||||
.projax-sidebar .sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.projax-sidebar .nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
position: relative;
|
||||
}
|
||||
.projax-sidebar .nav-item:hover {
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
}
|
||||
.projax-sidebar .nav-item.active {
|
||||
color: var(--accent);
|
||||
background: var(--bg);
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 14px;
|
||||
}
|
||||
.projax-sidebar .nav-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.projax-sidebar .nav-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
html[data-sidebar-collapsed="true"] .projax-sidebar .nav-label {
|
||||
display: none;
|
||||
}
|
||||
.projax-sidebar .sidebar-bottom {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.projax-sidebar .sidebar-bottom .logout-form {
|
||||
margin: 0;
|
||||
}
|
||||
.projax-sidebar .theme-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
}
|
||||
.projax-sidebar .collapse-icon {
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
html[data-sidebar-collapsed="true"] .projax-sidebar .collapse-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
/* Bottom-nav (Slice B) replaces the sidebar on mobile. Hidden here so
|
||||
Slice A can ship without leaving the phone with a 220px-wide hole. */
|
||||
.projax-sidebar { display: none; }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,20 @@
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script>
|
||||
// Phase 5g — restore sidebar collapsed state BEFORE first paint so the
|
||||
// main-content margin doesn't flash from 220px→56px on every navigation.
|
||||
// Mirrors layout.tmpl's existing theme-cookie approach (server-rendered
|
||||
// first paint, no FOUC) but localStorage-backed because collapse state
|
||||
// doesn't need to round-trip the server.
|
||||
(function() {
|
||||
try {
|
||||
if (localStorage.getItem('projax.sidebar.collapsed') === 'true') {
|
||||
document.documentElement.setAttribute('data-sidebar-collapsed', 'true');
|
||||
}
|
||||
} catch (e) { /* private mode / disabled storage — fall through */ }
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Phase 3j — register the service worker post-load so the install
|
||||
// affordance fires and shell assets warm into cache. Failures are silent
|
||||
@@ -25,24 +39,79 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/" class="brand">projax</a>
|
||||
<a href="/dashboard">dashboard</a>
|
||||
<a href="/timeline">timeline</a>
|
||||
<a href="/calendar">calendar</a>
|
||||
<a href="/graph">graph</a>
|
||||
<a href="/admin">admin</a>
|
||||
<button type="button" id="theme-toggle" class="theme-toggle" title="Toggle dark / light"
|
||||
aria-label="Toggle theme" data-theme="{{.Theme}}">
|
||||
<span class="theme-icon" aria-hidden="true">{{if eq .Theme "light"}}☾{{else}}☀{{end}}</span>
|
||||
{{$path := .Path}}
|
||||
<aside class="projax-sidebar" aria-label="Primary navigation">
|
||||
<div class="sidebar-top">
|
||||
<a href="/" class="brand" title="projax">
|
||||
<span class="brand-icon" aria-hidden="true">▦</span>
|
||||
<strong class="brand-label">projax</strong>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/" class="nav-item{{if eq $path "/"}} active{{end}}" title="Tree">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
<span class="nav-label">Tree</span>
|
||||
</a>
|
||||
<a href="/dashboard" class="nav-item{{if eq $path "/dashboard"}} active{{end}}" title="Dashboard">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
|
||||
</svg>
|
||||
<span class="nav-label">Dashboard</span>
|
||||
</a>
|
||||
<a href="/calendar" class="nav-item{{if eq $path "/calendar"}} active{{end}}" title="Calendar">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span class="nav-label">Calendar</span>
|
||||
</a>
|
||||
<a href="/timeline" class="nav-item{{if eq $path "/timeline"}} active{{end}}" title="Timeline">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span class="nav-label">Timeline</span>
|
||||
</a>
|
||||
<a href="/graph" class="nav-item{{if eq $path "/graph"}} active{{end}}" title="Graph">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
</svg>
|
||||
<span class="nav-label">Graph</span>
|
||||
</a>
|
||||
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span class="nav-label">Admin</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<button type="button" id="theme-toggle" class="nav-item theme-toggle"
|
||||
title="Toggle dark / light" aria-label="Toggle theme" data-theme="{{.Theme}}">
|
||||
<span class="nav-icon theme-icon" aria-hidden="true">{{if eq .Theme "light"}}☾{{else}}☀{{end}}</span>
|
||||
<span class="nav-label">Theme</span>
|
||||
</button>
|
||||
<form method="post" action="/logout" class="logout-form">
|
||||
<button type="submit" class="logout-btn">sign out</button>
|
||||
<button type="submit" class="nav-item logout-item" title="Sign out">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
<span class="nav-label">Sign out</span>
|
||||
</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<button type="button" id="sidebar-collapse" class="nav-item collapse-btn"
|
||||
title="Collapse sidebar" aria-label="Collapse sidebar" aria-expanded="true">
|
||||
<svg class="nav-icon collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
<span class="nav-label">Collapse</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="projax-main">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
<script>
|
||||
@@ -69,5 +138,31 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Phase 5g — sidebar collapse toggle. State persists in localStorage; the
|
||||
// pre-paint script in <head> already restored data-sidebar-collapsed on
|
||||
// <html>, so this handler only writes the new state. Aria-expanded
|
||||
// tracks the open/closed state for screen readers.
|
||||
(function() {
|
||||
var btn = document.getElementById('sidebar-collapse');
|
||||
if (!btn) return;
|
||||
var root = document.documentElement;
|
||||
function sync() {
|
||||
var collapsed = root.getAttribute('data-sidebar-collapsed') === 'true';
|
||||
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
btn.setAttribute('title', collapsed ? 'Expand sidebar' : 'Collapse sidebar');
|
||||
btn.setAttribute('aria-label', collapsed ? 'Expand sidebar' : 'Collapse sidebar');
|
||||
}
|
||||
sync();
|
||||
btn.addEventListener('click', function() {
|
||||
var collapsed = root.getAttribute('data-sidebar-collapsed') === 'true';
|
||||
var next = !collapsed;
|
||||
if (next) root.setAttribute('data-sidebar-collapsed', 'true');
|
||||
else root.removeAttribute('data-sidebar-collapsed');
|
||||
try { localStorage.setItem('projax.sidebar.collapsed', next ? 'true' : 'false'); } catch (e) {}
|
||||
sync();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user