commit 3fff705c8b00eb5656cffed1eac851d945d968df
parent 53a2a72d5fe6ba242bfbbcf5a7e73da5a95cf65c
Author: brookjeynes <me@brookjeynes.dev>
Date: Fri, 8 May 2026 09:36:33 +1000
feat(ui): create song page
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
12 files changed, 376 insertions(+), 20 deletions(-)
diff --git a/go.mod b/go.mod
@@ -9,6 +9,7 @@ require (
github.com/gorilla/schema v1.4.1
github.com/mattn/go-sqlite3 v1.14.44
github.com/sethvargo/go-envconfig v1.3.0
+ golang.org/x/text v0.27.0
)
require (
diff --git a/go.sum b/go.sum
@@ -74,6 +74,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/internal/db/song.go b/internal/db/song.go
@@ -3,10 +3,197 @@ package db
import (
"fmt"
"lyrics/internal/domain"
+ "strings"
"time"
)
-func InsertSong(e Execer, song domain.Song) error {
+func GetSong(e Execer, id domain.SongID) (domain.Song, error) {
+ var song domain.Song
+ var releaseDateStr string
+
+ err := e.QueryRow(`
+ select id, title, latin_title, lyrics, release_date
+ from songs
+ where id = ?
+ `, id).Scan(&song.ID, &song.Title, &song.LatinTitle, &song.Lyrics, &releaseDateStr)
+ if err != nil {
+ return domain.Song{}, fmt.Errorf("failed to get song: %w", err)
+ }
+
+ song.ReleaseDate, err = time.Parse(time.RFC3339, releaseDateStr)
+ if err != nil {
+ return domain.Song{}, fmt.Errorf("failed to parse release_date: %w", err)
+ }
+
+ // fetch song_artists rows
+ type songArtistRow struct {
+ artistID domain.ArtistID
+ role domain.ArtistRole
+ sortOrder int
+ }
+ var artistRows []songArtistRow
+
+ rows, err := e.Query(`
+ select artist_id, role, sort_order
+ from song_artists
+ where song_id = ?
+ order by sort_order
+ `, id)
+ if err != nil {
+ return domain.Song{}, fmt.Errorf("failed to query song artists: %w", err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var r songArtistRow
+ if err := rows.Scan(&r.artistID, &r.role, &r.sortOrder); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to scan song artist: %w", err)
+ }
+ artistRows = append(artistRows, r)
+ }
+ if err := rows.Err(); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to iterate song artists: %w", err)
+ }
+ rows.Close()
+
+ // fetch full artist records in one query
+ if len(artistRows) > 0 {
+ artistIDs := make([]any, len(artistRows))
+ for i, r := range artistRows {
+ artistIDs[i] = r.artistID
+ }
+ placeholders := strings.Repeat("?,", len(artistIDs))
+ placeholders = placeholders[:len(placeholders)-1]
+
+ artistMap := make(map[domain.ArtistID]domain.Artist, len(artistIDs))
+ aRows, err := e.Query(`
+ select id, name, latin_name, disambiguation, bio
+ from artists
+ where id in (`+placeholders+`)
+ `, artistIDs...)
+ if err != nil {
+ return domain.Song{}, fmt.Errorf("failed to query artists: %w", err)
+ }
+ defer aRows.Close()
+ for aRows.Next() {
+ var a domain.Artist
+ if err := aRows.Scan(&a.ID, &a.Name, &a.LatinName, &a.Disambiguation, &a.Bio); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to scan artist: %w", err)
+ }
+ artistMap[a.ID] = a
+ }
+ if err := aRows.Err(); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to iterate artists: %w", err)
+ }
+ aRows.Close()
+
+ song.Artists = make([]domain.SongArtist, len(artistRows))
+ for i, r := range artistRows {
+ song.Artists[i] = domain.SongArtist{
+ Artist: artistMap[r.artistID],
+ Role: r.role,
+ SortOrder: r.sortOrder,
+ }
+ }
+ }
+
+ // fetch song_albums rows
+ type songAlbumRow struct {
+ albumID domain.AlbumID
+ trackNumber *int
+ }
+ var albumRows []songAlbumRow
+
+ alRows, err := e.Query(`
+ select album_id, track_number
+ from song_albums
+ where song_id = ?
+ `, id)
+ if err != nil {
+ return domain.Song{}, fmt.Errorf("failed to query song albums: %w", err)
+ }
+ defer alRows.Close()
+ for alRows.Next() {
+ var r songAlbumRow
+ if err := alRows.Scan(&r.albumID, &r.trackNumber); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to scan song album: %w", err)
+ }
+ albumRows = append(albumRows, r)
+ }
+ if err := alRows.Err(); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to iterate song albums: %w", err)
+ }
+ alRows.Close()
+
+ // fetch full album records in one query
+ if len(albumRows) > 0 {
+ albumIDs := make([]any, len(albumRows))
+ for i, r := range albumRows {
+ albumIDs[i] = r.albumID
+ }
+ placeholders := strings.Repeat("?,", len(albumIDs))
+ placeholders = placeholders[:len(placeholders)-1]
+
+ albumMap := make(map[domain.AlbumID]domain.Album, len(albumIDs))
+ abRows, err := e.Query(`
+ select id, title, latin_name, release_date
+ from albums
+ where id in (`+placeholders+`)
+ `, albumIDs...)
+ if err != nil {
+ return domain.Song{}, fmt.Errorf("failed to query albums: %w", err)
+ }
+ defer abRows.Close()
+ for abRows.Next() {
+ var a domain.Album
+ var relDate string
+ if err := abRows.Scan(&a.ID, &a.Title, &a.LatinName, &relDate); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to scan album: %w", err)
+ }
+ a.ReleaseDate, err = time.Parse(time.RFC3339, relDate)
+ if err != nil {
+ return domain.Song{}, fmt.Errorf("failed to parse album release_date: %w", err)
+ }
+ albumMap[a.ID] = a
+ }
+ if err := abRows.Err(); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to iterate albums: %w", err)
+ }
+ abRows.Close()
+
+ song.Albums = make([]domain.SongAlbum, len(albumRows))
+ for i, r := range albumRows {
+ song.Albums[i] = domain.SongAlbum{
+ Album: albumMap[r.albumID],
+ TrackNumber: r.trackNumber,
+ }
+ }
+ }
+
+ // fetch genres
+ gRows, err := e.Query(`
+ select genre
+ from song_genres
+ where song_id = ?
+ `, id)
+ if err != nil {
+ return domain.Song{}, fmt.Errorf("failed to query song genres: %w", err)
+ }
+ defer gRows.Close()
+ for gRows.Next() {
+ var g domain.Genre
+ if err := gRows.Scan(&g); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to scan song genre: %w", err)
+ }
+ song.Genres = append(song.Genres, g)
+ }
+ if err := gRows.Err(); err != nil {
+ return domain.Song{}, fmt.Errorf("failed to iterate song genres: %w", err)
+ }
+
+ return song, nil
+}
+
+func InsertSong(e Execer, song domain.SongRecord) error {
result, err := e.Exec(`
insert into songs (
title,
diff --git a/internal/domain/song.go b/internal/domain/song.go
@@ -6,6 +6,30 @@ import (
type SongID int64
+// SongRecord is the write model used for inserts.
+type SongRecord struct {
+ ID SongID
+ Title string
+ LatinTitle *string
+ Lyrics string
+ ReleaseDate time.Time
+ Artists []SongRecordArtist
+ Albums []SongRecordAlbum
+ Genres []Genre
+}
+
+type SongRecordArtist struct {
+ ArtistID ArtistID
+ Role ArtistRole
+ SortOrder int
+}
+
+type SongRecordAlbum struct {
+ AlbumID AlbumID
+ TrackNumber *int
+}
+
+// Song is the full read model with all related data embedded.
type Song struct {
ID SongID
Title string
@@ -18,14 +42,12 @@ type Song struct {
}
type SongArtist struct {
- SongID SongID
- ArtistID ArtistID
+ Artist Artist
Role ArtistRole
SortOrder int
}
type SongAlbum struct {
- SongID SongID
- AlbumID AlbumID
+ Album Album
TrackNumber *int
}
diff --git a/internal/server/htmx/htmx.go b/internal/server/htmx/htmx.go
@@ -18,7 +18,8 @@ func HxError(w http.ResponseWriter, status int, msg string) {
w.Write([]byte(msg))
}
-func HxRedirect(w http.ResponseWriter, location string) {
+func HxRedirect(w http.ResponseWriter, status int, location string) {
w.Header().Set("HX-Redirect", location)
- w.WriteHeader(http.StatusOK)
+ w.Header().Set("Location", location)
+ w.WriteHeader(status)
}
diff --git a/internal/server/new-album.go b/internal/server/new-album.go
@@ -136,6 +136,6 @@ func (ss *ServerState) NewAlbum(w http.ResponseWriter, r *http.Request) {
logger.Info("added album", "album", newAlbum)
- htmx.HxRedirect(w, "/")
+ htmx.HxRedirect(w, http.StatusOK, "/")
}
}
diff --git a/internal/server/new-artist.go b/internal/server/new-artist.go
@@ -110,6 +110,6 @@ func (ss *ServerState) NewArtist(w http.ResponseWriter, r *http.Request) {
logger.Info("added artist", "artist", newArtist)
- htmx.HxRedirect(w, "/")
+ htmx.HxRedirect(w, http.StatusOK, "/")
}
}
diff --git a/internal/server/new-song.go b/internal/server/new-song.go
@@ -21,15 +21,15 @@ type NewSongForm struct {
ReleaseDate string `schema:"release-date"`
}
-func parseNewSongForm(form NewSongForm) (domain.Song, error) {
- var song domain.Song
+func parseNewSongForm(form NewSongForm) (domain.SongRecord, error) {
+ var song domain.SongRecord
// artists
if len(form.ArtistID) == 0 {
return song, fmt.Errorf("At least one artist is required")
}
- artists := make([]domain.SongArtist, 0, len(form.ArtistID)+len(form.FeaturingID))
+ artists := make([]domain.SongRecordArtist, 0, len(form.ArtistID)+len(form.FeaturingID))
seenArtists := make(map[int64]struct{}, len(form.ArtistID)+len(form.FeaturingID))
for i, id := range form.ArtistID {
@@ -41,7 +41,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) {
}
seenArtists[int64(id)] = struct{}{}
- artists = append(artists, domain.SongArtist{
+ artists = append(artists, domain.SongRecordArtist{
ArtistID: id,
Role: domain.RolePrimary,
SortOrder: i,
@@ -58,7 +58,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) {
}
seenArtists[int64(id)] = struct{}{}
- artists = append(artists, domain.SongArtist{
+ artists = append(artists, domain.SongRecordArtist{
ArtistID: id,
Role: domain.RoleFeatured,
SortOrder: i,
@@ -96,7 +96,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) {
}
// albums
- albums := make([]domain.SongAlbum, 0, len(form.AlbumID))
+ albums := make([]domain.SongRecordAlbum, 0, len(form.AlbumID))
seenAlbums := make(map[int64]struct{}, len(form.AlbumID))
for _, id := range form.AlbumID {
if id < 0 {
@@ -107,7 +107,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) {
}
seenAlbums[int64(id)] = struct{}{}
- albums = append(albums, domain.SongAlbum{
+ albums = append(albums, domain.SongRecordAlbum{
AlbumID: id,
})
}
@@ -124,7 +124,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) {
minDate.Format("2006-01-02"), maxDate.Format("2006-01-02"))
}
- song = domain.Song{
+ song = domain.SongRecord{
Title: form.Title,
LatinTitle: latinTitle,
Lyrics: form.Lyrics,
@@ -191,6 +191,6 @@ func (ss *ServerState) NewSong(w http.ResponseWriter, r *http.Request) {
logger.Info("added song", "song", newSong)
- htmx.HxRedirect(w, "/")
+ htmx.HxRedirect(w, http.StatusOK, "/")
}
}
diff --git a/internal/server/router.go b/internal/server/router.go
@@ -14,8 +14,13 @@ func (ss *ServerState) Router() http.Handler {
router.Get("/", ss.Index)
- router.Get("/new", ss.NewSong)
- router.Post("/new", ss.NewSong)
+ router.Route("/song", func(router chi.Router) {
+ router.Route("/{id}", func(router chi.Router) {
+ router.Get("/{slug}", ss.Song)
+ })
+ router.Get("/new", ss.NewSong)
+ router.Post("/new", ss.NewSong)
+ })
router.Route("/album", func(router chi.Router) {
router.Get("/new", ss.NewAlbum)
diff --git a/internal/server/song.go b/internal/server/song.go
@@ -0,0 +1,74 @@
+package server
+
+import (
+ "fmt"
+ "lyrics/internal/db"
+ "lyrics/internal/domain"
+ "lyrics/internal/server/htmx"
+ "lyrics/internal/server/ui/views/song"
+ "net/http"
+ "strconv"
+ "strings"
+ "unicode"
+
+ "golang.org/x/text/runes"
+ "golang.org/x/text/transform"
+ "golang.org/x/text/unicode/norm"
+)
+
+func Slug(title string) string {
+ // strip apostrophes before normalizing
+ s := strings.ReplaceAll(title, "'", "")
+
+ // unicode normalize: NFD decompose, strip non-spacing marks, recompose
+ t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
+ s, _, _ = transform.String(t, s)
+
+ s = strings.ToLower(s)
+ s = strings.Map(func(r rune) rune {
+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
+ return r
+ }
+ return '-'
+ }, s)
+
+ for strings.Contains(s, "--") {
+ s = strings.ReplaceAll(s, "--", "-")
+ }
+
+ return strings.Trim(s, "-")
+}
+
+func (ss *ServerState) Song(w http.ResponseWriter, r *http.Request) {
+ logger := ss.logger.With("handler", "Song")
+
+ idStr := r.PathValue("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ logger.Error("failed to convert song id to integer", "id", idStr, "err", err)
+ htmx.HxError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ slug := r.PathValue("slug")
+ logger = logger.With("id", id, "slug", slug)
+
+ s, err := db.GetSong(ss.db, domain.SongID(id))
+ if err != nil {
+ logger.Error("failed to get song", "err", err)
+ htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for song '%s'", slug))
+ return
+ }
+
+ songTitle := s.Title
+ if s.LatinTitle != nil {
+ songTitle = *s.LatinTitle
+ }
+
+ if Slug(songTitle) != slug {
+ htmx.HxRedirect(w, http.StatusMovedPermanently, fmt.Sprintf("/song/%d/%s", id, Slug(songTitle)))
+ return
+ }
+
+ song.Song(song.SongParams{Song: s}).Render(r.Context(), w)
+}
diff --git a/internal/server/ui/views/song/song.go b/internal/server/ui/views/song/song.go
@@ -0,0 +1,7 @@
+package song
+
+import "lyrics/internal/domain"
+
+type SongParams struct {
+ Song domain.Song
+}
diff --git a/internal/server/ui/views/song/song.templ b/internal/server/ui/views/song/song.templ
@@ -0,0 +1,57 @@
+package song
+
+import (
+ "fmt"
+ "lyrics/internal/server/ui/layouts/base"
+ "time"
+)
+
+templ Song(params SongParams) {
+ @base.Base(base.BaseParams{Title: params.Song.Title}) {
+ <article>
+ <header>
+ <hgroup>
+ <h1>
+ { params.Song.Title }
+ if params.Song.LatinTitle != nil {
+ <span>({ *params.Song.LatinTitle })</span>
+ }
+ </h1>
+ </hgroup>
+ <ul>
+ for _, artist := range params.Song.Artists {
+ <li>
+ <a href={ fmt.Sprintf("/artist/%d/%s", artist.Artist.ID, artist.Artist.Name) }>
+ { artist.Artist.Name }
+ if artist.Artist.LatinName != nil {
+ <span>({ *artist.Artist.LatinName })</span>
+ }
+ </a>
+ </li>
+ }
+ </ul>
+ <time datetime={ params.Song.ReleaseDate.Format(time.DateOnly) }>{ params.Song.ReleaseDate.Format("Jan 2, 2006") }</time>
+ <ul>
+ for _, genre := range params.Song.Genres {
+ <li>
+ { genre }
+ </li>
+ }
+ </ul>
+ <ul>
+ for _, album := range params.Song.Albums {
+ <li>
+ <a href={ fmt.Sprintf("/album/%d/%s", album.Album.ID, album.Album.Title) }>
+ { album.Album.Title }
+ if album.Album.LatinName != nil {
+ <span>({ *album.Album.LatinName })</span>
+ }
+ </a>
+ </li>
+ }
+ </ul>
+ </header>
+ <pre>{ params.Song.Lyrics }</pre>
+ </article>
+ }
+}