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