// Package ui renders the HTMX/Tailwind frontend from server-side // html/templates. Templates live under web/templates and are // embedded into the binary via web.FS. // // Lifecycle: // - At startup, parse every layout, partial, and page into a // single *template.Template tree. // - To render a page, call Render(w, "", ViewData{...}). // Render walks the page's template definitions (which override // the {{block "content"}} / {{block "title"}} placeholders in // the chosen layout) and writes the result. package ui import ( "fmt" "html/template" "io" "io/fs" "path" "strings" "gitea.dcglab.co.uk/steve/restic-manager/web" ) // ViewData is the common frame every template renders against. // Page handlers populate Page with their own concrete shape and the // renderer wraps it. type ViewData struct { // Title is rendered in ; layouts/base default to // "restic-manager" if absent. Pages that {{define "title"}} win. Title string // User is the currently signed-in user, or nil if the session // cookie is missing/invalid. The nav uses this to decide // whether to show "Sign out" or "Sign in". User *User // Active is the slug of the currently active primary nav tab // ("dashboard" / "repos" / "alerts" / "audit" / "settings"). // The nav partial highlights the matching tab. Active string // OpenAlerts is shown next to the Alerts tab when > 0. OpenAlerts int // Version is the build version printed in the top-right of the // chrome. Falls back to "dev" if the binary wasn't built with // -ldflags -X main.version=… Version string // Username pre-fills the login form on a re-render after a bad // attempt. Login-only. Username string // Error is a single banner-level error string. Login uses it // today; other pages can adopt the same field. Error string // Page carries page-specific data. Concrete type is the page's // own struct. Page any } // User is the minimal projection of the authenticated user that the // templates need. Avoids leaking store internals into the view. type User struct { ID string Username string Role string } // Renderer holds the parsed templates. type Renderer struct { pages map[string]*template.Template } // New parses every layout, partial, and page from web.FS into one // template tree per page. Pages associate with a layout via the // path under templates/pages/: anything at templates/pages/login.html // wraps in templates/layouts/chromeless.html, everything else wraps // in templates/layouts/base.html. // // Returns an error if any template fails to parse — fail loud at // startup, not at request time. func New() (*Renderer, error) { // All layouts + partials are shared. commonPaths := []string{ "templates/layouts/base.html", "templates/layouts/chromeless.html", "templates/partials/nav.html", "templates/partials/host_row.html", } pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") if err != nil { return nil, fmt.Errorf("ui: glob pages: %w", err) } if len(pageEntries) == 0 { return nil, fmt.Errorf("ui: no pages found under templates/pages/") } r := &Renderer{pages: make(map[string]*template.Template, len(pageEntries))} for _, p := range pageEntries { base := strings.TrimSuffix(path.Base(p), ".html") t, err := template.New(base).Funcs(funcMap()). ParseFS(web.FS, append(append([]string{}, commonPaths...), p)...) if err != nil { return nil, fmt.Errorf("ui: parse %s: %w", p, err) } r.pages[base] = t } return r, nil } // Render writes the named page (e.g. "dashboard", "login") to w, // wrapped in the appropriate layout. layoutFor decides which. func (r *Renderer) Render(w io.Writer, page string, data ViewData) error { t, ok := r.pages[page] if !ok { return fmt.Errorf("ui: unknown page %q", page) } if data.Version == "" { data.Version = "dev" } return t.ExecuteTemplate(w, layoutFor(page), data) } // layoutFor picks the layout name for a page. Login + bootstrap go // chrome-less; everything else uses the standard navigation chrome. func layoutFor(page string) string { switch page { case "login", "bootstrap": return "chromeless" default: return "base" } }