The theme handoff encoded as CSS custom properties rather than a Tailwind config, since there is no Tailwind here. Palette, spacing, radii, shadows and motion follow it as written; docs/theme.md lists what differs and why. - Oswald vendored as a 21KB variable woff2, latin subset, no CDN. Its phantom weight 900 resolved to 700 — loading a weight you don't have is what made the brand render differently per platform - color-scheme: dark makes the native audio element fit the palette, which was the handoff's complaint about it - Songs are text cards rather than artwork tiles, because there is no artwork. The unreviewed state keeps its red-brown border and gains a badge, so it is never carried by colour alone - Nav is the three-column grid; the mobile menu is <details>, no JS - Flash messages became bottom-right toasts Favicon carried over from the Nuxt project.
99 lines
2.5 KiB
Go
99 lines
2.5 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
//go:embed templates static
|
|
var assetFS embed.FS
|
|
|
|
var funcs = template.FuncMap{
|
|
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
|
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
|
|
}
|
|
|
|
// Each page is parsed with the layout into its own set, so two pages may both define "content".
|
|
var pages = map[string]*template.Template{}
|
|
|
|
func init() {
|
|
entries, err := assetFS.ReadDir("templates")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
for _, e := range entries {
|
|
if e.Name() == "layout.html" {
|
|
continue
|
|
}
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs).
|
|
ParseFS(assetFS, "templates/layout.html", "templates/partials/*.html", "templates/"+e.Name()))
|
|
}
|
|
}
|
|
|
|
// page is everything the layout needs, plus whatever the page itself wants in Data.
|
|
type page struct {
|
|
Title string
|
|
Member *member
|
|
Admin bool
|
|
Flash string
|
|
Path string
|
|
Narrow bool // auth pages are a 420px column
|
|
Data any
|
|
}
|
|
|
|
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
|
|
t, ok := pages[name]
|
|
if !ok {
|
|
slog.Error("unknown template", "name", name)
|
|
http.Error(w, "template", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
p.Member = memberFrom(r.Context())
|
|
p.Path = r.URL.Path
|
|
p.Flash = a.takeFlash(w, r)
|
|
|
|
// Render to memory first: a template that fails halfway must not leave a half-written 200.
|
|
var buf bytes.Buffer
|
|
if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
|
|
slog.Error("render", "name", name, "error", err)
|
|
http.Error(w, "template", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
buf.WriteTo(w)
|
|
}
|
|
|
|
// Toasts are a cookie rendered server-side and cleared on read — no JS, no session storage.
|
|
func (a *app) flash(w http.ResponseWriter, msg string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "flash", Value: url.QueryEscape(msg), Path: "/",
|
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (a *app) takeFlash(w http.ResponseWriter, r *http.Request) string {
|
|
c, err := r.Cookie("flash")
|
|
if err != nil || c.Value == "" {
|
|
return ""
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "flash", Value: "", Path: "/", MaxAge: -1,
|
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
msg, err := url.QueryUnescape(c.Value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return msg
|
|
}
|