Lyrics, versioning, and the song page's fact line. - Lyrics are suggested at submission and never imposed: the worker makes one LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist are typed. Neither overwrites what the submitter wrote. They live on the submission, travel to the song at publish, and stay editable after the song locks — the lock freezes what a song claims to be, and nobody reviewed the lyrics - The review strip reads and writes side by side: lyrics left, review right. Synced LRC highlights the playing line and seeks on click; plain text scrolls continuously with a nudge knob. Following can be turned off - CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the footer, the startup log and /healthz - The song page's metadata became four labelled cells instead of one flat run of five different kinds of fact Fixes: publishing wiped lyrics the worker had just fetched (a request that omitted a field cleared it), lyric auto-scroll landed in the wrong place, and the fader shifted the deck sideways at score 100.
125 lines
3.6 KiB
Go
125 lines
3.6 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") },
|
|
// Date without the clock: the minute a song was published is noise.
|
|
"fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") },
|
|
// Lyrics as they are meant to be read: LRC timestamps belong to the player, not the reader.
|
|
"lyricstext": stripLRC,
|
|
"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
|
|
Version string
|
|
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.Version = version
|
|
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
|
|
}
|