commit 3d6d0d855fa7ba3caa35638b1d5a2b408f14cfd5
parent 03b390e3b3ba0d737f9cf891b44eb9c6098a42c7
Author: brookjeynes <me@brookjeynes.dev>
Date: Mon, 4 May 2026 16:31:55 +1000
feat: add artist search to new song page
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
7 files changed, 132 insertions(+), 68 deletions(-)
diff --git a/internal/server/new-song.go b/internal/server/new-song.go
@@ -11,12 +11,14 @@ import (
type NewSongForm struct {
Artist []string `schema:"artist"`
+ ArtistID []int64 `schema:"artist-id"`
Title string `schema:"title"`
LatinTitle string `schema:"latin-title"`
Albums []string `schema:"albums"`
Genres []string `schema:"genres"`
Lyrics string `schema:"lyrics"`
Featuring []string `schema:"featuring"`
+ FeaturingID []int64 `schema:"featuring-id"`
ReleaseDate string `schema:"release-date"`
}
@@ -24,13 +26,31 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) {
var song domain.Song
// artists
- if len(form.Artist) == 0 {
+ if len(form.ArtistID) == 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")
+ artists := make([]domain.SongArtist, 0, len(form.ArtistID)+len(form.FeaturingID))
+ for i, id := range form.ArtistID {
+ if id == 0 {
+ return song, fmt.Errorf("Invalid artist selection")
}
+ artists = append(artists, domain.SongArtist{
+ ArtistID: id,
+ Role: domain.RolePrimary,
+ SortOrder: i,
+ })
+ }
+
+ // featuring
+ for i, id := range form.FeaturingID {
+ if id == 0 {
+ return song, fmt.Errorf("Invalid featured artist selection")
+ }
+ artists = append(artists, domain.SongArtist{
+ ArtistID: id,
+ Role: domain.RoleFeatured,
+ SortOrder: i,
+ })
}
// title
@@ -39,8 +59,8 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) {
}
// latin title
- if form.LatinTitle != "" && !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) {
- return song, fmt.Errorf("Latin title must not be empty")
+ if form.LatinTitle != "" && !domain.LatinNameRegex.MatchString(form.LatinTitle) {
+ return song, fmt.Errorf("Latin title must only contain letters and spaces")
}
// lyrics
@@ -73,17 +93,6 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) {
// 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 {
@@ -96,13 +105,19 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) {
minDate.Format("2006-01-02"), maxDate.Format("2006-01-02"))
}
+ var latinTitle *string
+ if form.LatinTitle != "" {
+ latinTitle = &form.LatinTitle
+ }
+
song = domain.Song{
Title: form.Title,
+ LatinTitle: latinTitle,
Lyrics: form.Lyrics,
ReleaseDate: releaseDate,
- LatinTitle: &form.LatinTitle,
Genres: genres,
AlbumIDs: albumIDs,
+ Artists: artists,
}
return song, nil
diff --git a/internal/server/search-artist.go b/internal/server/search-artist.go
@@ -2,7 +2,7 @@ package server
import (
"lyrics/internal/db"
- artistsearchdatalist "lyrics/internal/server/ui/partials/artist-search-datalist"
+ artistsearchresults "lyrics/internal/server/ui/partials/artist-search-results"
similarartistslist "lyrics/internal/server/ui/partials/similar-artists-list"
"net/http"
)
@@ -39,7 +39,7 @@ func (ss *ServerState) SearchArtist(w http.ResponseWriter, r *http.Request) {
Artists: results,
}).Render(r.Context(), w)
case SearchArtistFormatSuggest:
- artistsearchdatalist.ArtistSearchDatalist(artistsearchdatalist.ArtistSearchDatalistParams{
+ artistsearchresults.ArtistSearchResults(artistsearchresults.ArtistSearchResultsParams{
Artists: results,
}).Render(r.Context(), w)
}
diff --git a/internal/server/ui/partials/artist-search-datalist/artist-search-datalist.go b/internal/server/ui/partials/artist-search-datalist/artist-search-datalist.go
@@ -1,7 +0,0 @@
-package artistsearchdatalist
-
-import "lyrics/internal/domain"
-
-type ArtistSearchDatalistParams struct {
- Artists []domain.Artist
-}
diff --git a/internal/server/ui/partials/artist-search-datalist/artist-search-datalist.templ b/internal/server/ui/partials/artist-search-datalist/artist-search-datalist.templ
@@ -1,22 +0,0 @@
-package artistsearchdatalist
-
-import "fmt"
-
-templ ArtistSearchDatalist(params ArtistSearchDatalistParams) {
- <datalist id="artist-suggestions">
- for _, artist := range params.Artists {
- {{
- value := artist.Name
-
- if artist.LatinName != nil && artist.Disambiguation != nil {
- value = fmt.Sprintf("%s (%s, %s)", artist.Name, *artist.LatinName, *artist.Disambiguation)
- } else if artist.LatinName != nil {
- value = fmt.Sprintf("%s (%s)", artist.Name, *artist.LatinName)
- } else if artist.Disambiguation != nil {
- value = fmt.Sprintf("%s (%s)", artist.Name, *artist.Disambiguation)
- }
- }}
- <option value={ value }></option>
- }
- </datalist>
-}
diff --git a/internal/server/ui/partials/artist-search-results/artist-search-results.go b/internal/server/ui/partials/artist-search-results/artist-search-results.go
@@ -0,0 +1,7 @@
+package artistsearchresults
+
+import "lyrics/internal/domain"
+
+type ArtistSearchResultsParams struct {
+ Artists []domain.Artist
+}
diff --git a/internal/server/ui/partials/artist-search-results/artist-search-results.templ b/internal/server/ui/partials/artist-search-results/artist-search-results.templ
@@ -0,0 +1,36 @@
+package artistsearchresults
+
+import "fmt"
+
+templ ArtistSearchResults(params ArtistSearchResultsParams) {
+ if len(params.Artists) == 0 {
+ <fieldset role="radiogroup" x-show="false"></fieldset>
+ } else {
+ <fieldset role="radiogroup">
+ <legend class="sr-only">select artist</legend>
+ for _, artist := range params.Artists {
+ {{
+ label := artist.Name
+ if artist.LatinName != nil && artist.Disambiguation != nil {
+ label = fmt.Sprintf("%s (%s, %s)", artist.Name, *artist.LatinName, *artist.Disambiguation)
+ } else if artist.LatinName != nil {
+ label = fmt.Sprintf("%s (%s)", artist.Name, *artist.LatinName)
+ } else if artist.Disambiguation != nil {
+ label = fmt.Sprintf("%s (%s)", artist.Name, *artist.Disambiguation)
+ }
+ }}
+ <label>
+ <input
+ type="radio"
+ :name="'artist-result-' + item.id"
+ value={ fmt.Sprintf("%d", artist.ID) }
+ data-name={ artist.Name }
+ required
+ @change="selected = true; artistId = $event.target.value; artistName = $event.target.dataset.name"
+ />
+ { label }
+ </label>
+ }
+ </fieldset>
+ }
+}
diff --git a/internal/server/ui/views/new-song/new-song.templ b/internal/server/ui/views/new-song/new-song.templ
@@ -22,33 +22,51 @@ templ NewSong(params NewSongParams) {
</legend>
<ol>
<template x-for="(item, i) in items" :key="item.id">
- <li>
- <label>
+ <li
+ x-data="{ selected: false, artistId: '', artistName: '' }"
+ x-init="htmx.process($el)"
+ >
+ <label x-show="!selected">
<span>artist</span>
<input
+ type="search"
hx-get="/artist/search?format=suggest"
- hx-trigger="input changed delay:500ms, load"
- hx-target="#artist-suggestions"
+ hx-trigger="input changed delay:500ms"
+ hx-target="next [role='radiogroup']"
hx-swap="outerHTML"
- type="text"
+ hx-include="[name='group']"
name="artist"
- list="artist-suggestions"
- :required="i === 0"
- pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
+ autocomplete="off"
+ :required="i === 0 && !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)"
- >×</button>
+ >
+ ×
+ </button>
+ <input type="hidden" name="artist-id" :value="artistId"/>
+ <input type="hidden" name="group" :value="item.id"/>
+ <div x-show="!selected">
+ <fieldset role="radiogroup" x-show="false"></fieldset>
+ </div>
</li>
</template>
</ol>
- <datalist id="artist-suggestions"></datalist>
</fieldset>
- <div x-data="{ showLatinName: false }">
+ <div x-data="{ showLatinTitle: false }">
<label>
title:
<input
@@ -56,15 +74,15 @@ templ NewSong(params NewSongParams) {
type="text"
pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
required
- @input="showLatinName = /[^\u0000-\u007F]/.test($event.target.value)"
+ @input="showLatinTitle = /[^\u0000-\u007F]/.test($event.target.value)"
/>
</label>
- <label x-show="showLatinName">
+ <label x-show="showLatinTitle">
latin name:
<input
- name="latin-name"
+ name="latin-title"
type="text"
- :required="showLatinName"
+ :required="showLatinTitle"
pattern={ domain.LatinNameRegex.String() }
/>
</label>
@@ -119,15 +137,32 @@ templ NewSong(params NewSongParams) {
</legend>
<ol>
<template x-for="(item, i) in items" :key="item.id">
- <li>
- <label>
+ <li x-data="{ selected: false, artistId: '', artistName: '' }">
+ <label x-show="!selected">
<span>artist</span>
<input
- type="text"
+ type="search"
+ :hx-get="'/artist/search?format=suggest&group=' + item.id"
+ hx-trigger="input changed delay:500ms"
+ hx-target="next [role='radiogroup']"
+ hx-swap="outerHTML"
+ hx-include="[name='featuring-group']"
name="featuring"
- pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
+ autocomplete="off"
/>
</label>
+ <p x-show="selected">
+ <span x-text="artistName"></span>
+ <button
+ type="button"
+ @click="selected = false; artistId = ''; artistName = ''"
+ >change</button>
+ </p>
+ <input type="hidden" name="featuring-id" :value="artistId"/>
+ <input type="hidden" name="featuring-group" :value="item.id"/>
+ <div x-show="!selected">
+ <fieldset role="radiogroup" x-show="false"></fieldset>
+ </div>
<button
type="button"
aria-label="Remove"