Stage 0, steps 3-5, in one commit: the three were built together (the kit
page is what exercises the partials and the shell) and do not split into
states that build on their own. Written by Sonnet, reviewed by Opus.
- static/panel.css: shared/brand.css + panel/theme.css, rule for rule.
wordmark.svg and perf-edge.svg beside it; the stamp is logo.svg.
- templates/components.html: every partial of the plan's Three layers,
with its typed input in components.go. Wrappers are open/close pairs
(box_open/box_close, body_open/body_close, record_open/record_close).
Rich text is a Text value built from escaped runs - no field is
trusted HTML. status_tag and wbr_at are template functions; copy_field
is a partial whose button panel.js wires through data-copy.
- templates/layout.html: the shell as build.py draws it, the menu paths
written out literally, visibility from a typed Shell (role, feature
flags, the user's reach); the signed-out screen as a second define.
Not in the 2.0 menu: Inbound Log / Queue / Quarantine / Filter lists
and Preflight.
- GET /server/components (global role, 404 otherwise): the mockup
components page rebuilt from fixtures (kit.go) by the real templates.
- The old panel steps aside unchanged: panel.css -> legacy.css,
layout.html -> layout_legacy.html; a page uses the new layout iff it
is off legacy_pages.txt.
- legacy_pages.txt: @kit removed - the kit is under the guards for good.
Differences from the mockup components page, for the owner:
- the head button is Help, not "Cover sheet"; two "Example" links are
dropped (no fixed URL), two point at /outbound/domains and /login;
- the page sits in the Server group, so the sibling strip shows;
- Sign out is a POST form whose button carries navbar-item: same size
and type as the links, but without Bulma's hover tint;
- a head's actions are always wrapped in div.buttons.
Evidence
- docs/assets/panel-redesign/evidence/components.png: 1440 px wide, full
height, headless Edge. Taken from the real templates served locally,
NOT from the container (no Docker on this machine); the page reads
only its own fixtures and embedded assets.
- panel/audit.js on the kit page, 1440 and 1280:
{"pages":1,"widths":[1440,1280],"textsChecked":516,"problems":[]}
- go test ./internal/web/... (with @kit removed)
ok github.com/mixeme/selfpost/internal/web
ok github.com/mixeme/selfpost/internal/web/auth
ok github.com/mixeme/selfpost/internal/web/handlers
ok github.com/mixeme/selfpost/internal/web/validate
ok github.com/mixeme/selfpost/internal/web/view
TestRoutesFollowNavigation stays skipped behind @routes (stage 1).
Roadmap 10/27.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
8.7 KiB
Go
235 lines
8.7 KiB
Go
// Package view embeds the panel's HTML templates and static assets and renders
|
|
// pages and HTMX polling fragments.
|
|
package view
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/mixeme/selfpost/internal/legal"
|
|
)
|
|
|
|
//go:embed templates/*.html static/*
|
|
var assetsFS embed.FS
|
|
|
|
// Engine holds parsed page and fragment templates.
|
|
type Engine struct {
|
|
pages map[string]*template.Template
|
|
fragments map[string]*template.Template
|
|
version string
|
|
inboundEnabled bool
|
|
dmarcEnabled bool
|
|
}
|
|
|
|
// pageFiles maps a logical page name to its template files. Every page
|
|
// composes with a layout — layout_legacy.html for the pages still in the old
|
|
// design, layout.html and components.html for those that have been rebuilt
|
|
// from the component kit (kitPages); pages that embed a polling fragment
|
|
// (architecture.md § Panel HTTP surface) list that fragment's file too, so the
|
|
// same {{define}} block renders both the initial page and the fragment's own
|
|
// refresh responses identically. Pages sharing a block of markup (the
|
|
// encryption fields on the two secret downloads) list that partial the same
|
|
// way.
|
|
var pageFiles = map[string][]string{
|
|
"setup": {"templates/setup.html"},
|
|
"login": {"templates/login.html"},
|
|
"dashboard": {"templates/dashboard.html"},
|
|
"settings": {"templates/settings.html"},
|
|
"users": {"templates/users.html"},
|
|
"user_form": {"templates/user_form.html"},
|
|
"user_delete": {"templates/user_delete.html"},
|
|
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
|
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
|
"domain_delete": {"templates/domain_delete.html"},
|
|
"inbound": {"templates/inbound.html"},
|
|
"inbound_domain": {"templates/inbound_domain.html"},
|
|
"inbound_delete": {"templates/inbound_delete.html"},
|
|
"dmarc": {"templates/dmarc.html"},
|
|
"dmarc_domain": {"templates/dmarc_domain.html"},
|
|
"dmarc_report": {"templates/dmarc_report.html"},
|
|
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
|
|
"delivery": {"templates/delivery.html"},
|
|
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
|
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
|
"status": {"templates/status.html", "templates/status_body.html"},
|
|
"help": {"templates/help.html"},
|
|
"components": {"templates/kit.html"},
|
|
}
|
|
|
|
// kitPages are the pages built from the component kit: they are rendered by
|
|
// layout.html with the partials of components.html and load panel.css. Every
|
|
// other page is still the old design — layout_legacy.html and legacy.css — and
|
|
// is listed in legacy_pages.txt; a page is in exactly one of the two, and
|
|
// restyling one means moving it from the list to here in the same commit.
|
|
// pageFiles stays a plain map of file lists because the guard tests read it.
|
|
var kitPages = map[string]bool{
|
|
"components": true,
|
|
}
|
|
|
|
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
|
// template file, for standalone rendering by the HTMX polling endpoints.
|
|
var fragmentFiles = map[string]string{
|
|
"deliveries_rows": "templates/deliveries_rows.html",
|
|
"mail_queue_body": "templates/mail_queue_body.html",
|
|
"system_log_body": "templates/system_log_body.html",
|
|
"status_body": "templates/status_body.html",
|
|
}
|
|
|
|
// New parses embedded templates. version is stamped into every page footer.
|
|
func New(version string) (*Engine, error) {
|
|
e := &Engine{
|
|
pages: make(map[string]*template.Template),
|
|
fragments: make(map[string]*template.Template),
|
|
version: version,
|
|
}
|
|
for name, files := range pageFiles {
|
|
// A page parses with the layout of its design. The template is named
|
|
// after the layout file so that its content is the layout's.
|
|
layout, shared := "layout_legacy.html", []string{"templates/help_drawer.html"}
|
|
if kitPages[name] {
|
|
layout, shared = "layout.html", []string{"templates/components.html"}
|
|
}
|
|
patterns := append([]string{"templates/" + layout}, append(shared, files...)...)
|
|
tmpl, err := template.New(layout).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
|
}
|
|
e.pages[name] = tmpl
|
|
}
|
|
for name, file := range fragmentFiles {
|
|
tmpl, err := template.ParseFS(assetsFS, file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse fragment %s: %w", name, err)
|
|
}
|
|
e.fragments[name] = tmpl
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// SetInboundEnabled controls whether the Inbound nav item is shown. The
|
|
// listener and routes are gated the same way (INBOUND_RELAY_ENABLE).
|
|
func (e *Engine) SetInboundEnabled(v bool) {
|
|
e.inboundEnabled = v
|
|
}
|
|
|
|
// SetDMARCEnabled controls whether the DMARC nav item is shown.
|
|
func (e *Engine) SetDMARCEnabled(v bool) {
|
|
e.dmarcEnabled = v
|
|
}
|
|
|
|
// templateFuncs supplies helpers shared across page templates.
|
|
func templateFuncs() template.FuncMap {
|
|
return template.FuncMap{
|
|
"dict": func(values ...any) (map[string]any, error) {
|
|
if len(values)%2 != 0 {
|
|
return nil, fmt.Errorf("dict: odd argument count")
|
|
}
|
|
m := make(map[string]any, len(values)/2)
|
|
for i := 0; i < len(values); i += 2 {
|
|
key, ok := values[i].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("dict: key %d is not a string", i)
|
|
}
|
|
m[key] = values[i+1]
|
|
}
|
|
return m, nil
|
|
},
|
|
// back builds the map back_link reads; keeps href and label paired at
|
|
// the call site instead of repeating the <a class="back"> markup.
|
|
"back": func(href, label string) map[string]string {
|
|
return map[string]string{"Href": href, "Label": label}
|
|
},
|
|
// status_tag and wbr_at are the adapters of the component kit
|
|
// (components.go): the one mapping of a status to Bulma's tag
|
|
// classes, and the break opportunity after the @ of an address.
|
|
"status_tag": StatusTag,
|
|
"wbr_at": wbrAt,
|
|
}
|
|
}
|
|
|
|
// Page returns a parsed page template by logical name. It is exported for
|
|
// template guard tests that assert structural properties across all pages.
|
|
func (e *Engine) Page(name string) *template.Template {
|
|
return e.pages[name]
|
|
}
|
|
|
|
// Pages returns all parsed page templates keyed by logical name.
|
|
func (e *Engine) Pages() map[string]*template.Template {
|
|
return e.pages
|
|
}
|
|
|
|
// Render writes a page using its layout. Rendering to a buffer first means a
|
|
// template error yields a clean 500 instead of a half-written page.
|
|
func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) {
|
|
tmpl, ok := e.pages[page]
|
|
if !ok {
|
|
http.Error(w, "template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var (
|
|
buf bytes.Buffer
|
|
layout string
|
|
frame = data
|
|
)
|
|
if kitPages[page] {
|
|
// A page of the component kit: the shell is typed data derived from the
|
|
// page's Meta (components.go), and the page's own data travels beside it.
|
|
// With no user there is no navigation — the signed-out screen.
|
|
layout = "layout.html"
|
|
sh := e.shell(metaOf(data), legal.CopyrightLine, legal.SourceURL)
|
|
if sh.User == "" {
|
|
layout = "layout_signed_out"
|
|
}
|
|
frame = Frame{Shell: sh, Page: data}
|
|
} else {
|
|
layout = "layout_legacy.html"
|
|
// The layout's navigation compares .Active against each item, so the key
|
|
// must exist on every authenticated page. Defaulting it here keeps a page
|
|
// that forgets it from failing to render — it simply highlights nothing.
|
|
// Footer fields (.Version, .Copyright, .SourceURL) are the same on every
|
|
// page, so no handler should have to pass them.
|
|
if m, ok := data.(map[string]any); ok {
|
|
if _, has := m["Active"]; !has {
|
|
m["Active"] = ""
|
|
}
|
|
m["Version"] = e.version
|
|
m["Copyright"] = legal.CopyrightLine
|
|
m["SourceURL"] = legal.SourceURL
|
|
m["InboundEnabled"] = e.inboundEnabled
|
|
m["DMARCEnabled"] = e.dmarcEnabled
|
|
}
|
|
}
|
|
if err := tmpl.ExecuteTemplate(&buf, layout, frame); err != nil {
|
|
log.Printf("panel: render %s: %v", page, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = buf.WriteTo(w)
|
|
}
|
|
|
|
// RenderFragment writes an HTMX polling fragment as a bare HTML snippet, with
|
|
// no surrounding layout (architecture.md § Panel HTTP surface: fragment
|
|
// endpoints return HTML, not JSON).
|
|
func (e *Engine) RenderFragment(w http.ResponseWriter, status int, name string, data any) {
|
|
tmpl, ok := e.fragments[name]
|
|
if !ok {
|
|
http.Error(w, "template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
log.Printf("panel: render fragment %s: %v", name, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = buf.WriteTo(w)
|
|
}
|