The owner's "проверь, что документация актуальна с кодом" (2026-10-16): the words the mockups put on screen against what the panel renders and does, page by page (docs-audit, 2026-10-17). Where the code is right about behaviour the mockup follows it; where the mockup is right the code follows in the next commit. The mockups now say: "Refreshes automatically" / "refreshes on its own" instead of "every 10 s" (panel.js polls at 5 s, 30 s, up to 60 s) on Health, Overview, System log and both queues; System log points at the Outbound log, the one with a page per message; the Spam filter facts are what the panel knows without the quarantine (Applies to, Filter, If the filter is down), and Valid recipients takes one per line or comma; a DMARC report's title has no year, as its own title line; the "same form, empty" sentences go (a new application and a new user have their own heads); an application's login is fixed; the auto limit's multiplier is 1.5-5, default 2.5 (store/ratelimits.go); the user form's e-mail is for notifications (no reset link yet) and All makes the single domains ignored (nothing greys them); a user's deletion says "(outbound)"; a message's history is the panel's own steps, with the queue's wait after a deferral; the footer is the panel's, "Copyright © 2026 Mikhail Yenuchenko". Help, true to the code: an application's password is kept by the server (sasldb2) so an export can carry it; a refusal for From:/Sender: is one row of the Outbound log; every password check of a backup counts, right or wrong, ten in 15 minutes; a password set on the user form signs that user out everywhere, but your own keeps your session; the limit mail is daily per sending domain (notify.go, "limit:"+domain). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
7.4 KiB
Python
148 lines
7.4 KiB
Python
"""Wrap every fragment in src/ with the panel shell and write it next to this file.
|
|
|
|
The mockups are static HTML, but thirty pages share one navbar; keeping that
|
|
navbar in one place is the only reason this script exists. Run it from
|
|
anywhere: python docs/assets/panel-redesign/panel/build.py
|
|
|
|
A fragment starts with one meta comment:
|
|
<!-- title: Outbound domains | section: outbound | page: domains -->
|
|
section/page pick the highlighted entries; "shell: bare" skips the navbar
|
|
(sign-in, setup).
|
|
"""
|
|
import pathlib
|
|
import re
|
|
|
|
HERE = pathlib.Path(__file__).parent
|
|
|
|
NAV = [
|
|
("overview", "Overview", "overview.html", "ti-activity-heartbeat", []),
|
|
("outbound", "Outbound", "out-domains.html", "ti-send", [
|
|
("domains", "Domains", "out-domains.html", "ti-world-upload"),
|
|
("log", "Log", "out-log.html", "ti-list-details"),
|
|
("queue", "Queue", "out-queue.html", "ti-stack-2"),
|
|
("dmarc", "DMARC reports", "dmarc.html", "ti-report-analytics"),
|
|
]),
|
|
("inbound", "Inbound", "in-domains.html", "ti-inbox", [
|
|
("domains", "Domains", "in-domains.html", "ti-world-download"),
|
|
("log", "Log", "in-log.html", "ti-list-details"),
|
|
("queue", "Queue", "in-queue.html", "ti-stack-2"),
|
|
("quarantine", "Quarantine", "in-quarantine.html", "ti-mail-pause"),
|
|
("filter-lists", "Filter lists", "in-filter-lists.html", "ti-filter"),
|
|
]),
|
|
("server", "Server", "health.html", "ti-server-2", [
|
|
("health", "Health", "health.html", "ti-heartbeat"),
|
|
("preflight", "Preflight", "preflight.html", "ti-checklist"),
|
|
("system-log", "System log", "system-log.html", "ti-file-text"),
|
|
("backup", "Backup", "backup.html", "ti-archive"),
|
|
("users", "Users", "users.html", "ti-users"),
|
|
("settings", "Settings", "settings.html", "ti-settings"),
|
|
]),
|
|
]
|
|
|
|
HEAD = """<!DOCTYPE html>
|
|
<html lang="en" data-theme="light">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{title} · SelfPost</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
|
|
<link rel="stylesheet" href="../shared/brand.css">
|
|
<link rel="stylesheet" href="theme.css">
|
|
</head>
|
|
"""
|
|
|
|
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · Copyright © 2026 Mikhail Yenuchenko · <a href="#">License (AGPL-3.0)</a>'
|
|
' · <a href="#">Source</a> · No warranty</footer>')
|
|
|
|
|
|
def touchnav(section, page):
|
|
"""The navigation below 1024 px: a Menu that opens over the page, its
|
|
sections folded but the current one - details elements, no script."""
|
|
out = ['<details class="sp-touchnav"><summary>Menu</summary><div class="sp-touchnav-panel">']
|
|
for key, label, href, _icon, pages in NAV:
|
|
if not pages:
|
|
cur = ' aria-current="page"' if key == section else ""
|
|
out.append(f'<a href="{href}"{cur}>{label}</a>')
|
|
continue
|
|
opened = " open" if key == section else ""
|
|
out.append(f'<details class="sp-touchnav-group"{opened}><summary>{label}</summary>')
|
|
for pkey, plabel, phref, picon in pages:
|
|
cur = ' aria-current="page"' if key == section and pkey == page else ""
|
|
out.append(f'<a href="{phref}"{cur}><i class="ti {picon}"></i>{plabel}</a>')
|
|
out.append('</details>')
|
|
opened = " open" if section == "user" else ""
|
|
out += [f'<details class="sp-touchnav-group"{opened}><summary><i class="ti ti-user-circle"></i>admin</summary>',
|
|
'<a href="account.html"><i class="ti ti-key"></i>Account</a>',
|
|
'<a href="help.html"><i class="ti ti-help"></i>Help</a>',
|
|
'<form method="post" action="login.html"><button type="submit"><i class="ti ti-logout"></i>Sign out</button></form>',
|
|
'</details>', '</div></details>']
|
|
return "".join(out)
|
|
|
|
|
|
def navbar(section, page):
|
|
out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">',
|
|
' <div class="navbar-brand"><a class="navbar-item" href="overview.html">'
|
|
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a>' + touchnav(section, page) + '</div>',
|
|
' <div class="navbar-menu">', ' <div class="navbar-start">']
|
|
for key, label, href, _icon, pages in NAV:
|
|
cur = " sp-current" if key == section else ""
|
|
if not pages:
|
|
out.append(f' <a class="navbar-item{cur}" href="{href}">{label}</a>')
|
|
continue
|
|
out.append(f' <div class="navbar-item has-dropdown is-hoverable{cur}">')
|
|
out.append(f' <a class="navbar-link" href="{href}">{label}</a>')
|
|
out.append(' <div class="navbar-dropdown">')
|
|
for pkey, plabel, phref, picon in pages:
|
|
act = " is-active" if key == section and pkey == page else ""
|
|
out.append(f' <a class="navbar-item{act}" href="{phref}"><i class="ti {picon}"></i>{plabel}</a>')
|
|
out.append(' </div>')
|
|
out.append(' </div>')
|
|
out += [' </div>', ' <div class="navbar-end">',
|
|
' <div class="navbar-item has-dropdown is-hoverable%s">' % (" sp-current" if section == "user" else ""),
|
|
' <a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>',
|
|
' <div class="navbar-dropdown is-right">',
|
|
' <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>',
|
|
' <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>',
|
|
' <hr class="navbar-divider">',
|
|
# Signing out changes state, so it is a POST: a form with a button, not a link.
|
|
' <form method="post" action="login.html"><button type="submit" class="navbar-item">'
|
|
'<i class="ti ti-logout"></i>Sign out</button></form>',
|
|
' </div>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>',
|
|
'<div class="sp-perf" aria-hidden="true"></div>']
|
|
return "\n".join(out)
|
|
|
|
|
|
def subnav(section, page):
|
|
for key, _label, _href, _icon, pages in NAV:
|
|
if key == section and pages:
|
|
cur = ' aria-current="page"'
|
|
links = "".join(
|
|
f'<a href="{phref}"{cur if pkey == page else ""}><i class="ti {picon}"></i>{plabel}</a>'
|
|
for pkey, plabel, phref, picon in pages)
|
|
return f'<div class="sp-subnav"><div class="container">{links}</div></div>'
|
|
return ""
|
|
|
|
|
|
def build(path):
|
|
text = path.read_text(encoding="utf-8")
|
|
m = re.match(r"\s*<!--(.*?)-->\s*", text, re.S)
|
|
meta = dict(part.split(":", 1) for part in m.group(1).split("|"))
|
|
meta = {k.strip(): v.strip() for k, v in meta.items()}
|
|
body = text[m.end():]
|
|
html = HEAD.format(title=meta["title"])
|
|
if meta.get("shell") == "bare":
|
|
html += f"<body>\n{body}\n</body>\n</html>\n"
|
|
else:
|
|
section, page = meta.get("section", ""), meta.get("page", "")
|
|
html += (f"<body>\n{navbar(section, page)}\n{subnav(section, page)}\n<main>\n"
|
|
f' <div class="container sp-page">\n{body}\n {FOOT}\n </div>\n</main>\n</body>\n</html>\n')
|
|
(HERE / path.name).write_text(html, encoding="utf-8", newline="\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pages = sorted((HERE / "src").glob("*.html"))
|
|
for p in pages:
|
|
build(p)
|
|
print(f"built {len(pages)} pages")
|