commit cbeb559fb59ef4b027b24af4a2382ee50d12d44e
parent d33195c234281017be8241f8afebb49a400d70b0
Author: brookjeynes <me@brookjeynes.dev>
Date: Fri, 19 Jun 2026 15:33:37 +1000
feat: add ability to edit artist
Task-id: spkotwvkwmpntlmw
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
7 files changed, 290 insertions(+), 0 deletions(-)
diff --git a/internal/server/edit-artist.go b/internal/server/edit-artist.go
@@ -0,0 +1,168 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "lyrics/internal/db"
+ "lyrics/internal/domain"
+ "lyrics/internal/server/htmx"
+ "lyrics/internal/server/routes"
+ editartist "lyrics/internal/server/ui/views/edit-artist"
+ "net/http"
+ "strconv"
+)
+
+type EditArtistForm struct {
+ Name string `schema:"artist"`
+ Bio string `schema:"biography"`
+ LatinName string `schema:"latin-name"`
+ Disambiguation string `schema:"disambiguation"`
+ MusicBrainzArtistID domain.MusicBrainzID `schema:"musicbrainz-artist-id"`
+}
+
+func parseEditArtistForm(form EditArtistForm) (domain.Artist, error) {
+ var artist domain.Artist
+
+ // name
+ if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Name) {
+ return artist, fmt.Errorf("Artist name must not be empty or have leading/trailing whitespace")
+ }
+
+ // latin name
+ 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
+ var bio *string
+ if form.Bio != "" && !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Bio) {
+ return artist, fmt.Errorf("Artist biography must not be empty or have leading/trailing whitespace")
+ }
+ if form.Bio != "" {
+ bio = &form.Bio
+ }
+
+ // disambiguation
+ var disambiguation *string
+ if form.Disambiguation != "" && !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Disambiguation) {
+ return artist, fmt.Errorf("Artist disambiguation must not be empty or have leading/trailing whitespace")
+ }
+ if form.Disambiguation != "" {
+ disambiguation = &form.Disambiguation
+ }
+
+ // musicbrainz artist id
+ var musicbrainzArtistID *domain.MusicBrainzID
+ if form.MusicBrainzArtistID != "" {
+ if !domain.MusicBrainzIDIsValid(string(form.MusicBrainzArtistID)) {
+ return artist, fmt.Errorf("'%s' must be a valid MusicBrainz ID", form.MusicBrainzArtistID)
+ }
+ musicbrainzArtistID = &form.MusicBrainzArtistID
+ }
+
+ artist = domain.Artist{
+ Name: form.Name,
+ LatinName: latinName,
+ Bio: bio,
+ Disambiguation: disambiguation,
+ MusicBrainzArtistID: musicbrainzArtistID,
+ }
+
+ return artist, nil
+}
+
+func (ss *ServerState) EditArtist(w http.ResponseWriter, r *http.Request) {
+ logger := ss.logger.With("handler", "EditArtist")
+
+ idStr := r.PathValue("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ logger.Error("failed to convert artist id to integer", "id", idStr, "err", err)
+ htmx.HxError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ slug := r.PathValue("slug")
+ logger = logger.With("id", id, "slug", slug)
+
+ switch r.Method {
+ case http.MethodGet:
+ artist, _, _, _, err := db.Artist(ss.db, domain.ArtistID(id))
+ if err != nil {
+ logger.Error("failed to get artist", "err", err)
+ htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for artist '%s'", slug))
+ return
+ }
+
+ err = editartist.EditArtist(editartist.EditArtistParams{
+ Artist: artist,
+ }).Render(r.Context(), w)
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ return
+ }
+
+ logger.Error("failed to render edit artist page", "err", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ 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 edit artist.")
+ return
+ }
+
+ var editArtistForm EditArtistForm
+ err = decoder.Decode(&editArtistForm, r.PostForm)
+ if err != nil {
+ logger.Error("failed to decode http form", "err", err)
+ htmx.HxError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ editedArtist, err := parseEditArtistForm(editArtistForm)
+ if err != nil {
+ logger.Error("failed to parse edit artist form", "err", err)
+ htmx.HxError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ editedArtist.ID = domain.ArtistID(id)
+
+ tx, err := ss.db.BeginTx(r.Context(), nil)
+ if err != nil {
+ logger.Error("failed to begin db transaction", "err", err)
+ htmx.HxError(w, http.StatusInternalServerError, "Failed to edit artist.")
+ return
+ }
+
+ err = db.UpdateArtist(tx, editedArtist)
+ if err != nil {
+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
+ logger.Error("failed to rollback db transaction", "err", rollbackErr)
+ }
+ logger.Error("failed to save edited artist into db", "err", err)
+ htmx.HxError(w, http.StatusInternalServerError, "Failed to edit artist.")
+ return
+ }
+
+ if err := tx.Commit(); err != nil {
+ logger.Error("failed to commit db transaction", "err", err)
+ htmx.HxError(w, http.StatusInternalServerError, "Failed to edit artist.")
+ return
+ }
+
+ logger.Info("edited artist", "artist", editedArtist)
+
+ htmx.HxRedirect(w, http.StatusOK, routes.Artist(editedArtist))
+ }
+}
diff --git a/internal/server/router.go b/internal/server/router.go
@@ -37,6 +37,8 @@ func (ss *ServerState) Router() http.Handler {
router.Route("/artist", func(router chi.Router) {
router.Route("/{id}", func(router chi.Router) {
router.Get("/{slug}", ss.Artist)
+ router.Get("/{slug}/edit", ss.EditArtist)
+ router.Post("/{slug}/edit", ss.EditArtist)
})
router.Get("/new", ss.NewArtist)
router.Post("/new", ss.NewArtist)
diff --git a/internal/server/routes/routes.go b/internal/server/routes/routes.go
@@ -51,3 +51,7 @@ func Artist(artist domain.Artist) string {
func ArtistPath(id domain.ArtistID, name string) string {
return fmt.Sprintf("/artist/%d/%s", id, domain.Slug(name))
}
+
+func ArtistEdit(artist domain.Artist) string {
+ return fmt.Sprintf("%s/edit", Artist(artist))
+}
diff --git a/internal/server/ui/views/artist/artist.templ b/internal/server/ui/views/artist/artist.templ
@@ -97,6 +97,9 @@ templ Artist(params ArtistParams) {
}
</ul>
</section>
+ <footer aria-label="Release actions">
+ <a href={ routes.ArtistEdit(params.Artist) }>edit artist</a>
+ </footer>
</article>
}
}
diff --git a/internal/server/ui/views/edit-artist/edit-artist.go b/internal/server/ui/views/edit-artist/edit-artist.go
@@ -0,0 +1,7 @@
+package editartist
+
+import "lyrics/internal/domain"
+
+type EditArtistParams struct {
+ Artist domain.Artist
+}
diff --git a/internal/server/ui/views/edit-artist/edit-artist.templ b/internal/server/ui/views/edit-artist/edit-artist.templ
@@ -0,0 +1,104 @@
+package editartist
+
+import (
+ "lyrics/internal/domain"
+ "lyrics/internal/server/routes"
+ "lyrics/internal/server/ui/layouts/base"
+)
+
+templ EditArtist(params EditArtistParams) {
+ @base.Base(base.BaseParams{Title: "edit artist", PageStylesheets: []string{"new-artist.css"}}) {
+ <h1>Edit an artist</h1>
+ <form
+ class="artist-form"
+ hx-post={ routes.ArtistEdit(params.Artist) }
+ hx-swap="none"
+ hx-disable-elt="#edit-artist-btn"
+ >
+ <fieldset
+ x-data="{ showLatinName: false }"
+ x-init="showLatinTitle = isNonAscii($refs.artistName.value)"
+ >
+ <legend>Name</legend>
+ <label title="prefer the artists name in their original language. use the latin name input for english names">
+ artist:
+ <input
+ x-ref="artistName"
+ hx-get="/artist/search?format=similar"
+ hx-trigger="input changed delay:500ms"
+ hx-target="#similar-artists"
+ hx-swap="innerHTML"
+ name="artist"
+ value={ params.Artist.Name }
+ type="text"
+ pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
+ required
+ @input="showLatinName = isNonAscii($event.target.value)"
+ />
+ </label>
+ <label x-show="showLatinName">
+ latin name:
+ <input
+ name="latin-name"
+ type="text"
+ :required="showLatinName"
+ if params.Artist.LatinName != nil {
+ value={ *params.Artist.LatinName }
+ }
+ pattern={ domain.IsValidSortNamePattern }
+ />
+ </label>
+ </fieldset>
+ <section id="similar-artists" class="similar-artists" aria-label="Similar artists"></section>
+ <fieldset>
+ <legend>Disambiguation</legend>
+ <label title="helps users distinguish between artists with identical or similar names, e.g. South Korean singer-songwriter">
+ disambiguation:
+ <input
+ name="disambiguation"
+ type="text"
+ if params.Artist.Disambiguation != nil {
+ value={ *params.Artist.Disambiguation }
+ }
+ pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
+ />
+ <i data-lucide="info"></i>
+ </label>
+ </fieldset>
+ <fieldset>
+ <legend>Biography</legend>
+ <label>
+ biography:
+ <textarea
+ if params.Artist.Bio != nil {
+ value={ *params.Artist.Bio }
+ }
+ name="biography"
+ rows="16"
+ spellcheck
+ ></textarea>
+ </label>
+ </fieldset>
+ <fieldset>
+ <legend>MusicBrainz</legend>
+ <label title="External identifier used for metadata lookups such as cover art.">
+ Artist ID (optional):
+ <input
+ name="musicbrainz-artist-id"
+ type="text"
+ if params.Artist.MusicBrainzArtistID != nil {
+ value={ *params.Artist.MusicBrainzArtistID }
+ }
+ pattern={ domain.IsValidMusicBrainzIDPattern }
+ placeholder="e32a4ae3-67d1-4541-84d8-de6ab2d7dec1"
+ />
+ <i data-lucide="info"></i>
+ </label>
+ </fieldset>
+ <footer aria-label="Form actions">
+ <button class="-primary" id="edit-artist-btn" type="submit">save changes</button>
+ <a href={ routes.Artist(params.Artist) }>cancel</a>
+ </footer>
+ </form>
+ }
+}
diff --git a/tasks/done/add_ability_to_edit_artist.txt b/tasks/done/add_ability_to_edit_artist.txt
@@ -0,0 +1,2 @@
+add ability to edit artist @feat +artist
+id: spkotwvkwmpntlmw