lyrics

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

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:
Minternal/server/new-song.go | 51+++++++++++++++++++++++++++++++++------------------
Minternal/server/search-artist.go | 4++--
Dinternal/server/ui/partials/artist-search-datalist/artist-search-datalist.go | 7-------
Dinternal/server/ui/partials/artist-search-datalist/artist-search-datalist.templ | 22----------------------
Ainternal/server/ui/partials/artist-search-results/artist-search-results.go | 7+++++++
Ainternal/server/ui/partials/artist-search-results/artist-search-results.templ | 36++++++++++++++++++++++++++++++++++++
Minternal/server/ui/views/new-song/new-song.templ | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
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"