lyrics

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

commit e9b41a33f4481c54b123231b5d01e9a155a955b5
parent dad844ccb63032156019cb34ee58f6da2cc6848b
Author: brookjeynes <me@brookjeynes.dev>
Date:   Fri,  8 May 2026 06:38:08 +1000

feat(ui): add new album page

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

Diffstat:
Ainternal/db/album.go | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/db/db.go | 1+
Minternal/domain/album.go | 1+
Minternal/domain/artist.go | 7+++++++
Minternal/domain/song.go | 9+--------
Ainternal/server/new-album.go | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/router.go | 6++++++
Ainternal/server/search-album.go | 22++++++++++++++++++++++
Minternal/server/search-artist.go | 5-----
Ainternal/server/ui/partials/similar-albums-list/similar-albums-list.go | 7+++++++
Ainternal/server/ui/partials/similar-albums-list/similar-albums-list.templ | 20++++++++++++++++++++
Ainternal/server/ui/views/new-album/new-album.go | 3+++
Ainternal/server/ui/views/new-album/new-album.js | 6++++++
Ainternal/server/ui/views/new-album/new-album.templ | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 415 insertions(+), 13 deletions(-)

diff --git a/internal/db/album.go b/internal/db/album.go @@ -0,0 +1,89 @@ +package db + +import ( + "fmt" + "lyrics/internal/domain" + "strings" + "time" +) + +func InsertAlbum(e Execer, album domain.Album) error { + result, err := e.Exec(` + insert into albums ( + title, + latin_name, + release_date, + created_at + ) + values (?, ?, ?, ?) + `, album.Title, album.LatinName, album.ReleaseDate, time.Now().Format(time.RFC3339)) + if err != nil { + return fmt.Errorf("failed to insert album: %w", err) + } + + lastID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to retrieve last inserted id: %w", err) + } + + for _, artist := range album.Artists { + _, err = e.Exec(` + insert into album_artists ( + album_id, + artist_id, + role, + sort_order + ) + values (?, ?, ?, ?) + on conflict(album_id, artist_id, role) do update set + sort_order = excluded.sort_order + `, lastID, artist.ArtistID, artist.Role, artist.SortOrder) + if err != nil { + return fmt.Errorf("failed to insert album artist: %w", err) + } + } + + return nil +} + +func SearchAlbum(e Execer, albumName string) ([]domain.Album, error) { + albumName = strings.TrimSpace(albumName) + if albumName == "" { + return nil, nil + } + + wildcard := "%" + albumName + "%" + rows, err := e.Query(` + select id, title, latin_name, release_date + from albums + where title like ? collate nocase + or latin_name like ? collate nocase + order by title + limit 20 + `, wildcard, wildcard) + if err != nil { + return nil, fmt.Errorf("failed to query albums: %w", err) + } + defer rows.Close() + + var results []domain.Album + for rows.Next() { + var album domain.Album + var releaseDateStr string + + if err := rows.Scan(&album.ID, &album.Title, &album.LatinName, &releaseDateStr); err != nil { + return nil, fmt.Errorf("failed to scan album row: %w", err) + } + + releaseDate, err := time.Parse(time.RFC3339, releaseDateStr) + if err != nil { + return nil, fmt.Errorf("failed to parse release date string '%s': %w", releaseDateStr, err) + } + + album.ReleaseDate = releaseDate + + results = append(results, album) + } + + return results, rows.Err() +} diff --git a/internal/db/db.go b/internal/db/db.go @@ -105,6 +105,7 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { create table if not exists album_artists ( album_id integer not null, artist_id integer not null, + role text not null, sort_order integer not null, unique(album_id, sort_order), diff --git a/internal/domain/album.go b/internal/domain/album.go @@ -15,5 +15,6 @@ type Album struct { type AlbumArtist struct { AlbumID AlbumID ArtistID ArtistID + Role ArtistRole SortOrder int } diff --git a/internal/domain/artist.go b/internal/domain/artist.go @@ -2,6 +2,13 @@ package domain type ArtistID int64 +type ArtistRole string + +const ( + RolePrimary ArtistRole = "primary" + RoleFeatured ArtistRole = "featured" +) + type Artist struct { ID ArtistID Name string diff --git a/internal/domain/song.go b/internal/domain/song.go @@ -17,16 +17,9 @@ type Song struct { Genres []Genre } -type SongArtistRole string - -const ( - RolePrimary SongArtistRole = "primary" - RoleFeatured SongArtistRole = "featured" -) - type SongArtist struct { SongID SongID ArtistID ArtistID - Role SongArtistRole + Role ArtistRole SortOrder int } diff --git a/internal/server/new-album.go b/internal/server/new-album.go @@ -0,0 +1,140 @@ +package server + +import ( + "fmt" + "lyrics/internal/db" + "lyrics/internal/domain" + "lyrics/internal/server/htmx" + newalbum "lyrics/internal/server/ui/views/new-album" + "net/http" + "time" +) + +type NewAlbumForm struct { + Title string `schema:"album"` + LatinTitle string `schema:"latin-title"` + ArtistID []domain.ArtistID `schema:"artist-id"` + ReleaseDate string `schema:"release-date"` +} + +func parseNewAlbumForm(form NewAlbumForm) (domain.Album, error) { + var album domain.Album + + // title + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) { + return album, fmt.Errorf("Title name must not be empty or have leading/trailing whitespace") + } + + // artists + if len(form.ArtistID) == 0 { + return album, fmt.Errorf("At least one artist is required") + } + + artists := make([]domain.AlbumArtist, 0, len(form.ArtistID)) + seenArtists := make(map[int64]struct{}, len(form.ArtistID)) + + for i, id := range form.ArtistID { + if id < 0 { + return album, fmt.Errorf("Invalid artist selection") + } + if _, exists := seenArtists[int64(id)]; exists { + return album, fmt.Errorf("An artist cannot be added more than once") + } + seenArtists[int64(id)] = struct{}{} + + artists = append(artists, domain.AlbumArtist{ + ArtistID: id, + SortOrder: i, + }) + } + + // latin title + var latinTitle *string + if form.LatinTitle != "" { + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) { + return album, fmt.Errorf("Latin title must not be empty or have leading/trailing whitespace") + } + if !domain.IsValidSortNameRegex.MatchString(form.LatinTitle) { + return album, fmt.Errorf("Latin title contains invalid characters") + } + latinTitle = &form.LatinTitle + } + + releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate) + if err != nil { + return album, 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 album, fmt.Errorf("Release date must be between %s and %s", + minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) + } + + album = domain.Album{ + Title: form.Title, + LatinName: latinTitle, + ReleaseDate: releaseDate, + Artists: artists, + } + + return album, nil +} + +func (ss *ServerState) NewAlbum(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "NewAlbum") + + switch r.Method { + case http.MethodGet: + newalbum.NewAlbum(newalbum.NewAlbumParams{}).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 album.") + return + } + + var newAlbumForm NewAlbumForm + err = decoder.Decode(&newAlbumForm, r.PostForm) + if err != nil { + logger.Error("failed to decode http form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + + newAlbum, err := parseNewAlbumForm(newAlbumForm) + if err != nil { + logger.Error("failed to parse new album form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + + tx, err := ss.db.Begin() + if err != nil { + logger.Error("failed to begin db transaction", "err", err) + htmx.HxError(w, http.StatusBadRequest, "Failed to add album.") + return + } + + err = db.InsertAlbum(tx, newAlbum) + if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + logger.Error("failed to rollback db transaction", "err", rollbackErr) + } + logger.Error("failed to save new album into db", "err", err) + htmx.HxError(w, http.StatusBadRequest, "Failed to add album.") + return + } + + if err := tx.Commit(); err != nil { + logger.Error("failed to commit db transaction", "err", err) + htmx.HxError(w, http.StatusBadRequest, "Failed to add album.") + return + } + + logger.Info("added album", "album", newAlbum) + + htmx.HxRedirect(w, "/") + } +} diff --git a/internal/server/router.go b/internal/server/router.go @@ -17,6 +17,12 @@ func (ss *ServerState) Router() http.Handler { router.Get("/new", ss.NewSong) router.Post("/new", ss.NewSong) + router.Route("/album", func(router chi.Router) { + router.Get("/new", ss.NewAlbum) + router.Post("/new", ss.NewAlbum) + router.Get("/search", ss.SearchAlbum) + }) + router.Route("/artist", func(router chi.Router) { router.Get("/new", ss.NewArtist) router.Post("/new", ss.NewArtist) diff --git a/internal/server/search-album.go b/internal/server/search-album.go @@ -0,0 +1,22 @@ +package server + +import ( + "lyrics/internal/db" + similaralbumslist "lyrics/internal/server/ui/partials/similar-albums-list" + "net/http" +) + +func (ss *ServerState) SearchAlbum(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "SearchAlbum") + + album := r.URL.Query().Get("album") + results, err := db.SearchAlbum(ss.db, album) + if err != nil { + logger.Error("failed to query db for albums", "err", err) + return + } + + similaralbumslist.SimilarAlbumsList(similaralbumslist.SimilarAlbumsListParams{ + Albums: results, + }).Render(r.Context(), w) +} diff --git a/internal/server/search-artist.go b/internal/server/search-artist.go @@ -31,11 +31,6 @@ func (ss *ServerState) SearchArtist(w http.ResponseWriter, r *http.Request) { if search == "" { search = featuring } - if search == "" { - logger.Error("artist search query empty") - http.Error(w, "search query empty, must provide either an artist or featuring param", http.StatusBadRequest) - return - } results, err := db.SearchArtist(ss.db, search) if err != nil { diff --git a/internal/server/ui/partials/similar-albums-list/similar-albums-list.go b/internal/server/ui/partials/similar-albums-list/similar-albums-list.go @@ -0,0 +1,7 @@ +package similaralbumslist + +import "lyrics/internal/domain" + +type SimilarAlbumsListParams struct { + Albums []domain.Album +} diff --git a/internal/server/ui/partials/similar-albums-list/similar-albums-list.templ b/internal/server/ui/partials/similar-albums-list/similar-albums-list.templ @@ -0,0 +1,20 @@ +package similaralbumslist + +import "fmt" + +templ SimilarAlbumsList(params SimilarAlbumsListParams) { + <ul id="similar-albums"> + for _, album := range params.Albums { + {{ + value := album.Title + + if album.LatinName != nil { + value = fmt.Sprintf("%s (%s)", album.Title, *album.LatinName) + } + }} + <li title={ value }> + { value } + </li> + } + </ul> +} diff --git a/internal/server/ui/views/new-album/new-album.go b/internal/server/ui/views/new-album/new-album.go @@ -0,0 +1,3 @@ +package newalbum + +type NewAlbumParams struct{} diff --git a/internal/server/ui/views/new-album/new-album.js b/internal/server/ui/views/new-album/new-album.js @@ -0,0 +1,6 @@ +// filter out unnecessary data from request +const form = document.querySelector("form"); +form.addEventListener("htmx:config:request", function(evt) { + if (!evt.detail.ctx.request.action.startsWith("/album/new")) return; + evt.detail.ctx.request.body.delete("artist"); +}); diff --git a/internal/server/ui/views/new-album/new-album.templ b/internal/server/ui/views/new-album/new-album.templ @@ -0,0 +1,112 @@ +package newalbum + +import ( + "fmt" + "lyrics/internal/domain" + "lyrics/internal/server/ui/layouts/base" + "time" +) + +templ NewAlbum(params NewAlbumParams) { + @base.Base(base.BaseParams{Title: "new album"}) { + <h1>Add an album</h1> + <form + hx-post="/album/new" + hx-swap="none" + hx-disable-elt="#add-album-btn" + > + <fieldset x-data="{ showLatinTitle: false }"> + <label> + album: + <input + hx-get="/album/search" + hx-trigger="input changed delay:500ms, load" + hx-target="#similar-albums" + hx-swap="outerHTML" + name="album" + type="text" + pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } + required + @input="showLatinTitle = isNonAscii($event.target.value)" + /> + </label> + <label x-show="showLatinTitle"> + latin title: + <input + name="latin-title" + type="text" + :required="showLatinTitle" + pattern={ domain.IsValidSortNamePattern } + /> + </label> + <div> + <span>similar albums</span> + <ul id="similar-albums"></ul> + </div> + </fieldset> + <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 + x-data="{ selected: false, artistId: '', artistName: '' }" + x-init="htmx.process($el); initLucideIcons()" + > + <label x-show="!selected"> + <span>artist</span> + <input + type="search" + hx-get="/artist/search?format=suggest" + hx-trigger="input changed delay:500ms" + hx-target="next [role='radiogroup']" + hx-swap="outerHTML" + name="artist" + autocomplete="off" + :required="!selected" + /> + </label> + <p x-show="selected"> + <span x-text="artistName"></span> + <button + type="button" + @click="selected = false; artistId = ''; artistName = ''" + > + change + </button> + </p> + <button + type="button" + aria-label="Remove" + x-show="items.length > 1" + @click="items.splice(i, 1)" + > + <i data-lucide="x"></i> + </button> + <div x-show="!selected"> + <fieldset role="radiogroup" x-show="false"></fieldset> + </div> + <input type="hidden" name="artist-id" :value="artistId"/> + </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-album-btn" type="submit"> + add album + </button> + </form> + <script src="/static/js/new-song.js" defer></script> + } +}