feat(panel): navigation as a left column, with a section index on the long pages

The navigation was a bar across the top that did not fit on one row — six page
entries and the session block against the panel's width — and had to be split
into two, costing the top of every page. It is now a column down the left edge:
one left edge to scan, the current entry marked down its leading edge, sticky so
it stays in view, and room under the entries for the current page's own
sections. Below the width the two columns need it lies back down into the same
wrapping rows as before; six entries need no drawer.

The section index is for the two pages long enough to need one — the domain page
(nine cards) and the status page (eight). Each card carries an id and the page's
template defines the list by overriding an empty "sections" block in the layout,
so a page that defines nothing renders no index. panel.js marks the section in
view, looking targets up by id on each pass so the status page swapping its
cards out every five seconds cannot leave it measuring boxes that have left the
document; the links themselves are plain fragment links and need no script.

Verified against the real pages rendered by a local panel at 1300px, 924px and
481px wide.
This commit is contained in:
2026-08-07 04:11:32 +03:00
parent 76ad20efdf
commit c0c66dfcdb
8 changed files with 349 additions and 70 deletions
+29 -16
View File
@@ -22,13 +22,17 @@
<script src="/static/panel.js" defer></script>
</head>
<body>
{{/* Two columns: the navigation on the left and the page beside it, centred
together (see .shell in panel.css). The signed-out pages have no navigation
and the shell holds their card alone. */}}
<div class="shell">
{{if .User}}{{template "nav" .}}{{end}}
{{/* The page name travels onto <main> as a class so the stylesheet can size a
page to what it holds — the monitoring pages are wider than the panel's
form pages (see main.page-* in panel.css). It is .Active, already carried
for the navigation, rather than a second field every handler would have to
remember to set. */}}
<main class="page-{{.Active}}">
{{if .User}}{{template "nav" .}}{{end}}
{{template "content" .}}
{{/* The running version, on every authenticated page: it is what a backup
manifest is checked against on restore and the first thing to establish
@@ -36,6 +40,7 @@
the login and setup pages must not advertise it to the internet. */}}
{{if .User}}<footer class="version">SelfPost {{.Version}}</footer>{{end}}
</main>
</div>
</body>
</html>{{end}}
@@ -46,21 +51,11 @@
.User and get no nav. */}}
{{define "nav"}}
<nav class="nav">
{{/* The top row is first here because it is drawn first (see .nav in
panel.css), which keeps the tab order matching the visual one. It holds
the mark on the left and the session block on the right; the page entries
follow underneath. The mark goes to /status, the page the panel opens on —
named outright rather than as "/", which is only a redirect to it. */}}
<div class="top">
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="110" height="50" alt="SelfPost"></a>
<div class="session">
<span class="muted">{{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
</div>
</div>
{{/* The order of the blocks is the order they are read in, top to bottom of
the column: the mark, the panel's pages, the current page's own sections,
and the session last. The mark goes to /status, the page the panel opens
on — named outright rather than as "/", which is only a redirect to it. */}}
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="110" height="50" alt="SelfPost"></a>
<div class="links">
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
@@ -69,9 +64,27 @@
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
</div>
{{template "sections" .}}
<div class="session">
<span class="muted">{{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
</div>
</nav>
{{end}}
{{/* The index of the current page's own sections, shown in the navigation
column under the page entries. Most pages are one or two cards and need no
index, so the block is empty here and only the pages that are genuinely
long — the domain page, the status page — redefine it with their own list
(see .sections in panel.css for what it looks like, and panel.js for the
marking of the section in view). Each page's template file is parsed after
this one, so its definition replaces this empty one; a page that defines
nothing keeps it and renders no index. */}}
{{define "sections"}}{{end}}
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
inherit the link's colour through currentColor, cost no extra request, and
need no exemption from the panel's "default-src 'self'" policy. Each is