lyrics

lyrics
git clone git://brookjeynes.dev/bjeynes/lyrics.git
Log | Files | Refs

commit ee0fe62ca4f011b5e05365adec12964970e70e22
parent b3995b782c0e91799d48bae881f2b300f17083a4
Author: brookjeynes <me@brookjeynes.dev>
Date:   Tue, 28 Apr 2026 21:13:40 +1000

feat(ui/views): add new song page

Signed-off-by: brookjeynes <me@brookjeynes.dev>

Diffstat:
M.gitignore | 1+
Mdev.sh | 3+++
Mgo.mod | 3++-
Mgo.sum | 4++--
Ainternal/domain/genre.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Minternal/domain/song.go | 19+++++++++++++------
Ainternal/server/htmx/htmx.go | 24++++++++++++++++++++++++
Ainternal/server/new-song.go | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/router.go | 7+++++++
Minternal/server/ui/layouts/base/base.templ | 5++++-
Dinternal/server/ui/layouts/base/base_templ.go | 61-------------------------------------------------------------
Ainternal/server/ui/styles.css | 0
Dinternal/server/ui/views/index/index_templ.go | 60------------------------------------------------------------
Ainternal/server/ui/views/new-song/new-song.go | 3+++
Ainternal/server/ui/views/new-song/new-song.templ | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msetup.sh | 1+
16 files changed, 378 insertions(+), 131 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,2 +1,3 @@ .env */**/*templ.go +static/files diff --git a/dev.sh b/dev.sh @@ -4,6 +4,9 @@ go tool templ generate go tool templ fmt ./internal/server/ui/ go fmt ./... +find internal static -path 'static/files' -prune -o -name '*.js' -print | while read f; do minify "$f" -o "static/files/js/$(basename "$f")"; done +find internal static -path 'static/files' -prune -o -name '*.css' -print | while read f; do minify "$f" -o "static/files/$(basename "$f")"; done + if [ -f .env ]; then export $(cat .env | sed 's/#.*//g' | xargs) fi diff --git a/go.mod b/go.mod @@ -3,14 +3,15 @@ module lyrics go 1.23.2 require ( + github.com/a-h/templ v0.3.1001 github.com/charmbracelet/log v1.0.0 github.com/go-chi/chi/v5 v5.2.5 + github.com/gorilla/schema v1.4.1 github.com/sethvargo/go-envconfig v1.3.0 ) require ( github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect - github.com/a-h/templ v0.3.1001 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect diff --git a/go.sum b/go.sum @@ -34,6 +34,8 @@ github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkv github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -68,8 +70,6 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= diff --git a/internal/domain/genre.go b/internal/domain/genre.go @@ -0,0 +1,45 @@ +package domain + +type Genre string + +const ( + GenreRap Genre = "rap" + GenrePop Genre = "pop" + GenreRnB Genre = "r&b" + GenreRock Genre = "rock" + GenreCountry Genre = "country" + GenreElectronic Genre = "electronic" + GenreJazz Genre = "jazz" + GenreClassical Genre = "classical" + GenreNonMusic Genre = "non-music" + GenreIndie Genre = "indie" + GenreInstrumental Genre = "instrumental" +) + +var Genres = []Genre{ + GenreRap, + GenrePop, + GenreRnB, + GenreRock, + GenreCountry, + GenreElectronic, + GenreJazz, + GenreClassical, + GenreNonMusic, + GenreIndie, + GenreInstrumental, +} + +var ValidGenres = map[Genre]struct{}{ + GenreRap: {}, + GenrePop: {}, + GenreRnB: {}, + GenreRock: {}, + GenreCountry: {}, + GenreElectronic: {}, + GenreJazz: {}, + GenreClassical: {}, + GenreNonMusic: {}, + GenreIndie: {}, + GenreInstrumental: {}, +} diff --git a/internal/domain/song.go b/internal/domain/song.go @@ -1,11 +1,18 @@ package domain -import "time" +import ( + "regexp" + "time" +) + +var NoTrailingOrLeadingSpaceRegex = regexp.MustCompile(`^\S(\s*\S)*$`) type Song struct { - ID int64 - Title string - Lyrics string - Date time.Time - Artists []SongArtist + ID int64 + Title string + Lyrics string + Date time.Time + Artists []SongArtist + AlbumIDs []int64 + Genres []Genre } diff --git a/internal/server/htmx/htmx.go b/internal/server/htmx/htmx.go @@ -0,0 +1,24 @@ +package htmx + +import ( + "lyrics/internal/server/toast" + "net/http" +) + +func HxError(w http.ResponseWriter, status int, msg string) { + toast := toast.Error(msg) + toastJSON, err := toast.Marshal() + if err != nil { + toastJSON = []byte(`{"HXToast":{"type":"error","body":"An error occurred"}}`) + } + + w.Header().Set("HX-Trigger", string(toastJSON)) + w.Header().Set("HX-Reswap", "none") + w.WriteHeader(status) + w.Write([]byte(msg)) +} + +func HxRedirect(w http.ResponseWriter, location string) { + w.Header().Set("HX-Redirect", location) + w.WriteHeader(http.StatusOK) +} diff --git a/internal/server/new-song.go b/internal/server/new-song.go @@ -0,0 +1,135 @@ +package server + +import ( + "fmt" + "lyrics/internal/domain" + "lyrics/internal/server/htmx" + newsong "lyrics/internal/server/ui/views/new-song" + "net/http" + "time" +) + +type NewSongForm struct { + Artist []string `schema:"artist"` + Title string `schema:"title"` + Albums []string `schema:"albums"` + Genres []string `schema:"genres"` + Lyrics string `schema:"lyrics"` + Featuring []string `schema:"featuring"` + ReleaseDate string `schema:"release-date"` +} + +func parseNewSongForm(form NewSongForm) (domain.Song, error) { + var song domain.Song + + // artists + if len(form.Artist) == 0 { + return song, fmt.Errorf("At least one artist is required") + } + for _, a := range form.Artist { + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(a) { + return song, fmt.Errorf("Artist name must not be empty or have leading/trailing whitespace") + } + } + + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) { + return song, fmt.Errorf("Title must not be empty or have leading/trailing whitespace") + } + + if form.Lyrics == "" { + return song, fmt.Errorf("Lyrics must not be empty") + } + + // genres + if len(form.Genres) == 0 { + return song, fmt.Errorf("At least one genre is required") + } + genres := make([]domain.Genre, 0, len(form.Genres)) + for _, g := range form.Genres { + genre := domain.Genre(g) + if _, ok := domain.ValidGenres[genre]; !ok { + return song, fmt.Errorf("Unknown genre: %q", g) + } + genres = append(genres, genre) + } + + // albums + var albumIDs []int64 + for _, a := range form.Albums { + if a == "" { + continue + } + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(a) { + return song, fmt.Errorf("Album name must not be empty or have leading/trailing whitespace") + } + // TODO: look up or create album + } + + // featuring + for _, f := range form.Featuring { + if f == "" { + continue + } + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(f) { + return song, fmt.Errorf("Featured artist name must not be empty or have leading/trailing whitespace") + } + // TODO: look up or create featured artist + } + + // release date + releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate) + if err != nil { + return song, fmt.Errorf("Invalid release date: %w", err) + } + minDate := time.Date(1000, 1, 1, 0, 0, 0, 0, time.UTC) + maxDate := time.Now().AddDate(3, 0, 0) + if releaseDate.Before(minDate) || releaseDate.After(maxDate) { + return song, fmt.Errorf("Release date must be between %s and %s", + minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) + } + + song = domain.Song{ + Title: form.Title, + Lyrics: form.Lyrics, + Date: releaseDate, + Genres: genres, + AlbumIDs: albumIDs, + } + + return song, nil +} + +func (ss *ServerState) NewSong(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "NewSong") + + switch r.Method { + case http.MethodGet: + newsong.NewSong(newsong.NewSongParams{}).Render(r.Context(), w) + case http.MethodPost: + err := r.ParseForm() + if err != nil { + logger.Error("failed to parse http form", "err", err) + htmx.HxError(w, http.StatusBadRequest, "Failed to add new song.") + return + } + + var newSongForm NewSongForm + err = decoder.Decode(&newSongForm, r.PostForm) + if err != nil { + logger.Error("failed to decode http form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + + newSong, err := parseNewSongForm(newSongForm) + if err != nil { + logger.Error("failed to parse new song form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + + logger.Debug("added new song", "song", newSong) + + htmx.HxRedirect(w, "/") + } +} diff --git a/internal/server/router.go b/internal/server/router.go @@ -4,13 +4,20 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "github.com/gorilla/schema" ) +var decoder = schema.NewDecoder() + func (ss *ServerState) Router() http.Handler { router := chi.NewRouter() router.Get("/", ss.Index) + router.Get("/new", ss.NewSong) + router.Post("/new", ss.NewSong) + router.Handle("/static/*", ss.Static()) + return router } diff --git a/internal/server/ui/layouts/base/base.templ b/internal/server/ui/layouts/base/base.templ @@ -6,15 +6,18 @@ templ Base(params BaseParams) { <head> <meta charset="UTF-8"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <script src="/static/js/htmx.min.js" defer></script> + <script src="/static/js/alpine.min.js" defer></script> <script src="/static/js/toast.js" defer></script> + <link rel="stylesheet" href="/static/styles.css" type="text/css"/> <link rel="stylesheet" href="/static/toast.css" type="text/css"/> <title>{ params.Title }</title> </head> <body> <main> + <section id="toast-container"></section> { children... } </main> - <section id="toast-container"></section> </body> <script> document.body.addEventListener("HXToast", (evt) => { diff --git a/internal/server/ui/layouts/base/base_templ.go b/internal/server/ui/layouts/base/base_templ.go @@ -1,61 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1001 -package base - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func Base(params BaseParams) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var2 string - templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(params.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/ui/layouts/base/base.templ`, Line: 9, Col: 24} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - shlf.space</title><link rel=\"preload\" href=\"/static/fonts/epilogue.woff2\" as=\"font\" type=\"font/woff2\" crossorigin><link rel=\"preload\" href=\"/static/fonts/caveat.woff2\" as=\"font\" type=\"font/woff2\" crossorigin><script src=\"/static/js/htmx.min.js\" defer></script><script src=\"/static/js/lucide.min.js\"></script><script src=\"/static/js/alpinejs.min.js\" defer></script><link rel=\"stylesheet\" href=\"/static/style.css\" type=\"text/css\"></head><body><main>") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main></body><script type=\"module\" defer>\n lucide.createIcons();\n </script></html>") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/internal/server/ui/styles.css b/internal/server/ui/styles.css diff --git a/internal/server/ui/views/index/index_templ.go b/internal/server/ui/views/index/index_templ.go @@ -1,60 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1001 -package index - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -import "lyrics/internal/server/ui/layouts/base" - -func Index(params IndexParams) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<span>hi</span>") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = base.Base(base.BaseParams{Title: "home"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/internal/server/ui/views/new-song/new-song.go b/internal/server/ui/views/new-song/new-song.go @@ -0,0 +1,3 @@ +package newsong + +type NewSongParams struct{} diff --git a/internal/server/ui/views/new-song/new-song.templ b/internal/server/ui/views/new-song/new-song.templ @@ -0,0 +1,138 @@ +package newsong + +import ( + "fmt" + "lyrics/internal/domain" + "lyrics/internal/server/ui/layouts/base" + "time" +) + +templ NewSong(params NewSongParams) { + @base.Base(base.BaseParams{Title: "new song"}) { + <h1>Add a song</h1> + <form + hx-post="/new" + hx-swap="none" + hx-disable-elt="#add-song-btn" + > + <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> + <legend> + artists + <button type="button" @click="items.push({ id: uid++ })">+ add</button> + </legend> + <ol> + <template x-for="(item, i) in items" :key="item.id"> + <li> + <label> + <span>artist</span> + <input + type="text" + name="artist" + :required="i === 0" + pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } + /> + </label> + <button + type="button" + aria-label="Remove" + x-show="items.length > 1" + @click="items.splice(i, 1)" + >×</button> + </li> + </template> + </ol> + </fieldset> + <label> + title: + <input + name="title" + type="text" + pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } + required + /> + </label> + <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> + <legend> + albums + <button type="button" @click="items.push({ id: uid++ })">+ add</button> + </legend> + <ol> + <template x-for="(item, i) in items" :key="item.id"> + <li> + <label> + <span>album</span> + <input + type="text" + name="albums" + pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } + /> + </label> + <button + type="button" + aria-label="Remove" + x-show="items.length > 1" + @click="items.splice(i, 1)" + >×</button> + </li> + </template> + </ol> + </fieldset> + <fieldset> + <legend>genres</legend> + for _, genre := range domain.Genres { + <label> + <input type="checkbox" name="genres" value={ string(genre) }/> + <span>{ string(genre) }</span> + </label> + } + </fieldset> + <label> + lyrics: + <textarea + name="lyrics" + rows="20" + spellcheck + ></textarea> + </label> + <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> + <legend> + featuring + <button type="button" @click="items.push({ id: uid++ })">+ add</button> + </legend> + <ol> + <template x-for="(item, i) in items" :key="item.id"> + <li> + <label> + <span>artist</span> + <input + type="text" + name="featuring" + pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } + /> + </label> + <button + type="button" + aria-label="Remove" + x-show="items.length > 1" + @click="items.splice(i, 1)" + >×</button> + </li> + </template> + </ol> + </fieldset> + <label> + release date: + <input + name="release-date" + type="date" + min="1000-01-01" + max={ fmt.Sprintf("%s", time.Now().AddDate(3, 0, 0).Format("2006-01-02")) } + required + /> + </label> + <button id="add-song-btn" type="submit"> + add song + </button> + </form> + } +} diff --git a/setup.sh b/setup.sh @@ -3,3 +3,4 @@ mkdir -p static/files/js curl -sLo static/files/js/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js +curl -sLo static/files/js/alpine.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.15.11/dist/cdn.min.js