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:
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>
+ }
+}