The app is about operating something — playing a track and setting a level on it — but every screen looked like a form. One metaphor now does three jobs. - Reviewing: a vertical fader beside the text, so the two things you do at once stop being a screen apart. Native range input, so keyboard, focus and form submission are unchanged; on mobile it lies down and the ticks reverse - The reveal: everyone's scores as a row of channels. The silhouette of that row is the spread, which the stats page can only tell you as a number - Profiles: given versus received as two faders, the one comparison that says something about a person The player is now a transport: play/pause, a range input for seeking so arrow keys come free, and a stereo level meter driven by a real AnalyserNode. It is progressive enhancement — the page ships native audio controls and the script takes over, so no JS means the browser's own player. The meter is dark until audio actually plays and stops when it does; reduced motion skips it entirely. Also: hidden scores are hatched rather than blank, the nav carries the queue count, "Seuraava jonossa" keeps the loop going after a review, leaderboards gained level bars and a range bar where divisive is the point, durations read 3:54, both lists can get back to the start, and the admin invite table lists unused codes instead of silently truncating at 50. Slogan restored from the original app, three decades on.
119 lines
3.3 KiB
Go
119 lines
3.3 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) },
|
|
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
|
|
// Lets one board partial be called with a title and a list, instead of two near-identical
|
|
// partials per leaderboard.
|
|
"dict": func(pairs ...any) map[string]any {
|
|
m := map[string]any{}
|
|
for i := 0; i+1 < len(pairs); i += 2 {
|
|
m[pairs[i].(string)] = pairs[i+1]
|
|
}
|
|
return m
|
|
},
|
|
}
|
|
|
|
// 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
|
|
Queued int // songs still owed a review, shown in the nav
|
|
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
|
|
if p.Member != nil {
|
|
// The queue is a worklist, so its size belongs in the nav.
|
|
a.pool.QueryRow(r.Context(), `
|
|
select count(*)::int from songs s
|
|
where s.submitted_by <> $1
|
|
and not exists (select 1 from reviews r
|
|
where r.song_id = s.id and r.reviewer_id = $1)`,
|
|
p.Member.ID).Scan(&p.Queued)
|
|
}
|
|
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
|
|
}
|