lyrics

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

commit 756334431a4018da6eef92130cc5170b7cfa179a
parent fb8f44acd164e1b2f5899fb31e76f0f44a6bce61
Author: brookjeynes <me@brookjeynes.dev>
Date:   Wed,  6 May 2026 13:03:00 +1000

refactor: improvements with new-[song|artist] pages

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

Diffstat:
Ainternal/domain/regex.go | 17+++++++++++++++++
Minternal/domain/song.go | 4----
Minternal/server/new-artist.go | 13++++++++++---
Minternal/server/new-song.go | 16+++++++++-------
Minternal/server/search-artist.go | 76+++++++++++++++++++++++++++++++++++-----------------------------------------
Ainternal/server/ui/js/lucide-utils.js | 8++++++++
Ainternal/server/ui/js/string-utils.js | 5+++++
Minternal/server/ui/layouts/base/base.templ | 5+++++
Minternal/server/ui/styles.css | 4++++
Minternal/server/ui/views/new-artist/new-artist.templ | 11++++++-----
Minternal/server/ui/views/new-song/new-song.js | 6------
Minternal/server/ui/views/new-song/new-song.templ | 23+++++++++++++++--------
Msetup.sh | 1+
13 files changed, 115 insertions(+), 74 deletions(-)

diff --git a/internal/domain/regex.go b/internal/domain/regex.go @@ -0,0 +1,17 @@ +package domain + +import "regexp" + +const ( + noTrailingOrLeadingSpacePattern = `\S(.*\S)?` + validSortNameCharsPattern = `[a-zA-ZÀ-ÖØ-öø-ÿ0-9 ',.\-&!\(\)]` +) + +// Go regexes (server-side validation) +var ( + NoTrailingOrLeadingSpaceRegex = regexp.MustCompile(`^` + noTrailingOrLeadingSpacePattern + `$`) + IsValidSortNameRegex = regexp.MustCompile(`^` + validSortNameCharsPattern + `+$`) +) + +// HTML pattern (client-side validation with lookahead) +const IsValidSortNamePattern = `(?=^` + noTrailingOrLeadingSpacePattern + `$)` + validSortNameCharsPattern + `+$` diff --git a/internal/domain/song.go b/internal/domain/song.go @@ -1,13 +1,9 @@ package domain import ( - "regexp" "time" ) -var NoTrailingOrLeadingSpaceRegex = regexp.MustCompile(`^\S(\s*\S)*$`) -var IsLatinStringRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z ]*[a-zA-Z]$`) - type Song struct { ID int64 Title string diff --git a/internal/server/new-artist.go b/internal/server/new-artist.go @@ -25,8 +25,15 @@ func parseNewArtistForm(form NewArtistForm) (domain.Artist, error) { } // latin name - if form.LatinName != "" && !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinName) { - return artist, fmt.Errorf("Latin name must not be empty or have leading/trailing whitespace") + var latinName *string + if form.LatinName != "" { + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinName) { + return artist, fmt.Errorf("Latin name must not be empty or have leading/trailing whitespace") + } + if !domain.IsValidSortNameRegex.MatchString(form.LatinName) { + return artist, fmt.Errorf("Latin name contains invalid characters") + } + latinName = &form.LatinName } // bio @@ -41,7 +48,7 @@ func parseNewArtistForm(form NewArtistForm) (domain.Artist, error) { artist = domain.Artist{ Name: form.Name, - LatinName: &form.LatinName, + LatinName: latinName, Bio: &form.Bio, Disambiguation: &form.Disambiguation, } diff --git a/internal/server/new-song.go b/internal/server/new-song.go @@ -71,8 +71,15 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { } // latin title - if form.LatinTitle != "" && !domain.IsLatinStringRegex.MatchString(form.LatinTitle) { - return song, fmt.Errorf("Latin title must only contain letters and spaces") + var latinTitle *string + if form.LatinTitle != "" { + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) { + return song, fmt.Errorf("Latin title must not be empty or have leading/trailing whitespace") + } + if !domain.IsValidSortNameRegex.MatchString(form.LatinTitle) { + return song, fmt.Errorf("Latin title contains invalid characters") + } + latinTitle = &form.LatinTitle } // lyrics @@ -117,11 +124,6 @@ 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, diff --git a/internal/server/search-artist.go b/internal/server/search-artist.go @@ -2,7 +2,6 @@ package server import ( "lyrics/internal/db" - "lyrics/internal/domain" artistsearchresults "lyrics/internal/server/ui/partials/artist-search-results" similarartistslist "lyrics/internal/server/ui/partials/similar-artists-list" "net/http" @@ -18,45 +17,40 @@ const ( func (ss *ServerState) SearchArtist(w http.ResponseWriter, r *http.Request) { logger := ss.logger.With("handler", "SearchArtist") - switch r.Method { - case http.MethodGet: - format := SearchArtistFormat(r.URL.Query().Get("format")) - if format != SearchArtistFormatSimilar && format != SearchArtistFormatSuggest { - http.Error(w, "invalid format param", http.StatusBadRequest) - return - } - - artist := r.URL.Query().Get("artist") - featuring := r.URL.Query().Get("featuring") - - var results []domain.Artist - var err error - - if artist != "" { - results, err = db.SearchArtist(ss.db, artist) - if err != nil { - logger.Error("failed to query db for artists", "err", err) - return - } - } - - if featuring != "" { - results, err = db.SearchArtist(ss.db, featuring) - if err != nil { - logger.Error("failed to query db for artists", "err", err) - return - } - } - - switch format { - case SearchArtistFormatSimilar: - similarartistslist.SimilarArtistsList(similarartistslist.SimilarArtistsListParams{ - Artists: results, - }).Render(r.Context(), w) - case SearchArtistFormatSuggest: - artistsearchresults.ArtistSearchResults(artistsearchresults.ArtistSearchResultsParams{ - Artists: results, - }).Render(r.Context(), w) - } + format := SearchArtistFormat(r.URL.Query().Get("format")) + if format != SearchArtistFormatSimilar && format != SearchArtistFormatSuggest { + logger.Error("invalid artist search format", "format", format) + http.Error(w, "invalid format param", http.StatusBadRequest) + return + } + + artist := r.URL.Query().Get("artist") + featuring := r.URL.Query().Get("featuring") + + search := artist + 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 { + logger.Error("failed to query db for artists", "err", err) + return + } + + switch format { + case SearchArtistFormatSimilar: + similarartistslist.SimilarArtistsList(similarartistslist.SimilarArtistsListParams{ + Artists: results, + }).Render(r.Context(), w) + case SearchArtistFormatSuggest: + artistsearchresults.ArtistSearchResults(artistsearchresults.ArtistSearchResultsParams{ + Artists: results, + }).Render(r.Context(), w) } } diff --git a/internal/server/ui/js/lucide-utils.js b/internal/server/ui/js/lucide-utils.js @@ -0,0 +1,8 @@ +function initLucideIcons() { + lucide.createIcons({ + icons: { + X: lucide.X, + Info: lucide.Info, + } + }); +} diff --git a/internal/server/ui/js/string-utils.js b/internal/server/ui/js/string-utils.js @@ -0,0 +1,5 @@ +const isNonAsciiRegex = /[^\u0000-\u007F]/ + +function isNonAscii(string) { + return isNonAsciiRegex.test(string) +} diff --git a/internal/server/ui/layouts/base/base.templ b/internal/server/ui/layouts/base/base.templ @@ -8,7 +8,10 @@ templ Base(params BaseParams) { <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <script src="/static/js/htmx.min.js" defer></script> <script src="/static/js/alpine.min.js" defer></script> + <script src="/static/js/lucide.min.js"></script> + <script src="/static/js/lucide-utils.js"></script> <script src="/static/js/toast.js" defer></script> + <script src="/static/js/string-utils.js" defer></script> <link rel="stylesheet" href="/static/styles.css" type="text/css"/> <link rel="stylesheet" href="/static/toast.css" type="text/css"/> <title>{ params.Title }</title> @@ -20,6 +23,8 @@ templ Base(params BaseParams) { </main> </body> <script> + initLucideIcons(); + document.body.addEventListener("HXToast", (evt) => { const toast = new Toast(evt.detail.type, evt.detail.body); toast.show(); diff --git a/internal/server/ui/styles.css b/internal/server/ui/styles.css @@ -0,0 +1,4 @@ +svg[data-lucide] { + width: 12px; + height: 12px; +} diff --git a/internal/server/ui/views/new-artist/new-artist.templ b/internal/server/ui/views/new-artist/new-artist.templ @@ -13,8 +13,8 @@ templ NewArtist(params NewArtistParams) { hx-swap="none" hx-disable-elt="#add-artist-btn" > - <div x-data="{ showLatinName: false }"> - <label> + <fieldset x-data="{ showLatinName: false }"> + <label title="prefer the artists name in their original language. use the latin name input for english names"> artist: <input hx-get="/artist/search?format=similar" @@ -25,7 +25,7 @@ templ NewArtist(params NewArtistParams) { type="text" pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } required - @input="showLatinName = /[^\u0000-\u007F]/.test($event.target.value)" + @input="showLatinName = isNonAscii($event.target.value)" /> </label> <label x-show="showLatinName"> @@ -34,10 +34,10 @@ templ NewArtist(params NewArtistParams) { name="latin-name" type="text" :required="showLatinName" - pattern={ domain.IsLatinStringRegex.String() } + pattern={ domain.IsValidSortNamePattern } /> </label> - </div> + </fieldset> <label title="helps users distinguish between artists with identical or similar names, e.g. South Korean singer-songwriter"> disambiguation: <input @@ -46,6 +46,7 @@ templ NewArtist(params NewArtistParams) { pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } title="helps users distinguish between artists with identical or similar names, e.g. South Korean singer-songwriter" /> + <i data-lucide="info"></i> </label> <div> <span>similar artists</span> diff --git a/internal/server/ui/views/new-song/new-song.js b/internal/server/ui/views/new-song/new-song.js @@ -1,9 +1,3 @@ -const isLatinStringRegex = /[^\u0000-\u007F]/ - -function isLatinString(string) { - return isLatinStringRegex.test(string) -} - // filter out unnecessary data from request const form = document.querySelector("form"); form.addEventListener("htmx:config:request", function(evt) { diff --git a/internal/server/ui/views/new-song/new-song.templ b/internal/server/ui/views/new-song/new-song.templ @@ -24,7 +24,7 @@ templ NewSong(params NewSongParams) { <template x-for="(item, i) in items" :key="item.id"> <li x-data="{ selected: false, artistId: '', artistName: '' }" - x-init="htmx.process($el)" + x-init="htmx.process($el); initLucideIcons()" > <label x-show="!selected"> <span>artist</span> @@ -54,7 +54,7 @@ templ NewSong(params NewSongParams) { 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> @@ -75,7 +75,7 @@ templ NewSong(params NewSongParams) { type="text" pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } required - @input="showLatinTitle = isLatinString($event.target.value)" + @input="showLatinTitle = isNonAscii($event.target.value)" /> </label> <label x-show="showLatinTitle"> @@ -84,7 +84,7 @@ templ NewSong(params NewSongParams) { name="latin-title" type="text" :required="showLatinTitle" - pattern={ domain.IsLatinStringRegex.String() } + pattern={ domain.IsValidSortNamePattern } /> </label> </fieldset> @@ -95,7 +95,9 @@ templ NewSong(params NewSongParams) { </legend> <ol> <template x-for="(item, i) in items" :key="item.id"> - <li> + <li + x-init="initLucideIcons()" + > <label> <span>album</span> <input @@ -109,7 +111,9 @@ templ NewSong(params NewSongParams) { aria-label="Remove" x-show="items.length > 1" @click="items.splice(i, 1)" - >×</button> + > + <i data-lucide="x"></i> + </button> </li> </template> </ol> @@ -138,7 +142,10 @@ templ NewSong(params NewSongParams) { </legend> <ol> <template x-for="(item, i) in items" :key="item.id"> - <li x-data="{ selected: false, artistId: '', artistName: '' }"> + <li + x-data="{ selected: false, artistId: '', artistName: '' }" + x-init="htmx.process($el); initLucideIcons()" + > <label x-show="!selected"> <span>featuring</span> <input @@ -167,7 +174,7 @@ templ NewSong(params NewSongParams) { 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> diff --git a/setup.sh b/setup.sh @@ -4,3 +4,4 @@ mkdir -p static/files/js curl -sLo static/files/js/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js curl -sLo static/files/js/alpine.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.15.11/dist/cdn.min.js +curl -sLo static/files/js/lucide.min.js https://unpkg.com/lucide@1.14.0/dist/umd/lucide.min.js