Move the package into src/
Thirty-seven entries in the root, most of them .go files. The assets had to come along: //go:embed cannot reach outside its own directory, so templates/, static/ and migrations/ live beside the code that embeds them, and testdata/ beside the test that reads it. storage/ stays put — runtime data, not source. go build now needs -o. Without it the output would be named after the package directory and collide with src/ itself.
This commit is contained in:
+124
@@ -0,0 +1,124 @@
|
||||
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.db.QueryRowContext(r.Context(), `
|
||||
select count(*) 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
|
||||
}
|
||||
Reference in New Issue
Block a user