229f89fee2
P1-28: Tailwind standalone CLI wired into the Makefile. `make tailwind` downloads the pinned v3.4.17 binary into bin/tailwindcss (gitignored), builds web/styles/input.css → web/static/css/styles.css. `make build` now runs the CSS pass first; `make tailwind-watch` for dev. Output is embedded in the binary via web.FS — single static binary, no Node. The CSS source carries every component class the v1 mockups defined (status dots, buttons, host row, log viewer, progress bar, fields, chips, snippet panel, empty state) so screens that land later can just reach for them. P1-23: html/template tree at web/templates with two layouts (base with chrome, chromeless for login + bootstrap), one nav partial, and two pages (dashboard placeholder, login). internal/server/ui parses the tree at startup; ui_handlers.go in the http package wires: GET / dashboard (303 → /login when unauthed) GET /login sign-in form POST /login consume form, mint session cookie, 303 → / POST /logout drop cookie, 303 → /login GET /static/* embedded Tailwind bundle The HTML login flow shares store/session logic with /api/auth/login via a new authenticateAndSession helper — same security guarantees, two surface representations (HTML form / JSON). Verified end-to-end: bootstrap → form-login → authed dashboard → sign-out → 303 cycle works in the browser; Tailwind output emits only the component classes referenced in the live templates (9.6kB minified). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
4.0 KiB
Go
136 lines
4.0 KiB
Go
// 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, "<page>", 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 <title>; 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",
|
|
}
|
|
|
|
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).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"
|
|
}
|
|
}
|