lyrics

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

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:
Ainternal/server/edit-artist.go | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/router.go | 2++
Minternal/server/routes/routes.go | 4++++
Minternal/server/ui/views/artist/artist.templ | 3+++
Ainternal/server/ui/views/edit-artist/edit-artist.go | 7+++++++
Ainternal/server/ui/views/edit-artist/edit-artist.templ | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atasks/done/add_ability_to_edit_artist.txt | 2++
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