lyrics

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

commit 894f416af1ede25f0ae161d3ba4c90c0fb4c1c67
parent 2f27b711f4c63c65133c76519d09cbd38002ca6b
Author: brookjeynes <me@brookjeynes.dev>
Date:   Thu, 18 Jun 2026 06:18:52 +1000

feat: add create as single button

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

Diffstat:
Minternal/db/release.go | 10+++++-----
Minternal/server/edit-release.go | 2+-
Minternal/server/new-release.go | 4++--
Minternal/server/new-song.go | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Minternal/server/search-release.go | 2+-
Minternal/server/ui/partials/release-search-results/release-search-results.templ | 2+-
Minternal/server/ui/views/edit-release/edit-release.templ | 2+-
Minternal/server/ui/views/new-release/new-release.templ | 2+-
Minternal/server/ui/views/new-song/new-song.css | 8++++++++
Minternal/server/ui/views/new-song/new-song.js | 2+-
Minternal/server/ui/views/new-song/new-song.templ | 120+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Dtasks/add_release_types.txt | 10----------
Atasks/done/add_release_types.txt | 10++++++++++
13 files changed, 169 insertions(+), 89 deletions(-)

diff --git a/internal/db/release.go b/internal/db/release.go @@ -223,7 +223,7 @@ func UpdateRelease(e Execer, release domain.EditRelease) error { return nil } -func InsertRelease(e Execer, release domain.NewRelease) error { +func InsertRelease(e Execer, release domain.NewRelease) (domain.ReleaseID, error) { result, err := e.Exec(` insert into releases ( type, @@ -236,12 +236,12 @@ func InsertRelease(e Execer, release domain.NewRelease) error { values (?, ?, ?, ?, ?, ?) `, release.Type, release.MusicBrainzReleaseID, release.Title, release.LatinTitle, release.ReleaseDate.Format(time.RFC3339), time.Now().Format(time.RFC3339)) if err != nil { - return fmt.Errorf("failed to insert release: %w", err) + return 0, fmt.Errorf("failed to insert release: %w", err) } lastID, err := result.LastInsertId() if err != nil { - return fmt.Errorf("failed to retrieve last inserted id: %w", err) + return 0, fmt.Errorf("failed to retrieve last inserted id: %w", err) } for _, artist := range release.Artists { @@ -257,11 +257,11 @@ func InsertRelease(e Execer, release domain.NewRelease) error { sort_order = excluded.sort_order `, lastID, artist.ArtistID, artist.Role, artist.SortOrder) if err != nil { - return fmt.Errorf("failed to insert release artist: %w", err) + return 0, fmt.Errorf("failed to insert release artist: %w", err) } } - return nil + return domain.ReleaseID(lastID), nil } func SearchRelease(e Execer, releaseName string) ([]domain.Release, error) { diff --git a/internal/server/edit-release.go b/internal/server/edit-release.go @@ -14,7 +14,7 @@ import ( ) type EditReleaseForm struct { - Title string `schema:"title"` + Title string `schema:"release-title"` Type domain.ReleaseType `schema:"type"` LatinTitle string `schema:"latin-title"` ArtistID []domain.ArtistID `schema:"artist-id"` diff --git a/internal/server/new-release.go b/internal/server/new-release.go @@ -13,7 +13,7 @@ import ( ) type NewReleaseForm struct { - Title string `schema:"title"` + Title string `schema:"release-title"` Type domain.ReleaseType `schema:"type"` LatinTitle string `schema:"latin-title"` ArtistID []domain.ArtistID `schema:"artist-id"` @@ -151,7 +151,7 @@ func (ss *ServerState) NewRelease(w http.ResponseWriter, r *http.Request) { return } - err = db.InsertRelease(tx, newRelease) + _, err = db.InsertRelease(tx, newRelease) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { logger.Error("failed to rollback db transaction", "err", rollbackErr) diff --git a/internal/server/new-song.go b/internal/server/new-song.go @@ -14,7 +14,7 @@ import ( type NewSongForm struct { ArtistID []domain.ArtistID `schema:"artist-id"` - Title string `schema:"title"` + Title string `schema:"song-title"` LatinTitle string `schema:"latin-title"` ReleaseID []domain.ReleaseID `schema:"release-id"` Genres []string `schema:"genres"` @@ -22,6 +22,7 @@ type NewSongForm struct { FeaturingID []domain.ArtistID `schema:"featuring-id"` ReleaseDate string `schema:"release-date"` MusicBrainzRecordingID domain.MusicBrainzID `schema:"musicbrainz-recording-id"` + CreateAsSingle bool `schema:"create-as-single"` } func parseNewSongForm(form NewSongForm) (domain.NewSong, error) { @@ -98,23 +99,6 @@ func parseNewSongForm(form NewSongForm) (domain.NewSong, error) { genres = append(genres, genre) } - // releases - releases := make([]domain.NewSongRelease, 0, len(form.ReleaseID)) - seenReleases := make(map[int64]struct{}, len(form.ReleaseID)) - for _, id := range form.ReleaseID { - if id < 0 { - return song, fmt.Errorf("Invalid release selection") - } - if _, exists := seenReleases[int64(id)]; exists { - return song, fmt.Errorf("A release cannot be added more than once") - } - seenReleases[int64(id)] = struct{}{} - - releases = append(releases, domain.NewSongRelease{ - ReleaseID: id, - }) - } - // release date releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate) if err != nil { @@ -127,6 +111,25 @@ func parseNewSongForm(form NewSongForm) (domain.NewSong, error) { minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) } + // releases + releases := make([]domain.NewSongRelease, 0, len(form.ReleaseID)) + if !form.CreateAsSingle { + seenReleases := make(map[int64]struct{}, len(form.ReleaseID)) + for _, id := range form.ReleaseID { + if id < 0 { + return song, fmt.Errorf("Invalid release selection") + } + if _, exists := seenReleases[int64(id)]; exists { + return song, fmt.Errorf("A release cannot be added more than once") + } + seenReleases[int64(id)] = struct{}{} + + releases = append(releases, domain.NewSongRelease{ + ReleaseID: id, + }) + } + } + // musicbrainz recording id var musicbrainzRecordingID *domain.MusicBrainzID if form.MusicBrainzRecordingID != "" { @@ -188,6 +191,51 @@ func (ss *ServerState) NewSong(w http.ResponseWriter, r *http.Request) { return } + if newSongForm.CreateAsSingle { + releaseArtists := make([]domain.NewReleaseArtist, 0, len(newSong.Artists)) + for _, artist := range newSong.Artists { + releaseArtists = append(releaseArtists, domain.NewReleaseArtist(artist)) + } + + single := domain.NewRelease{ + Title: newSong.Title, + LatinTitle: newSong.LatinTitle, + ReleaseDate: newSong.ReleaseDate, + Artists: releaseArtists, + Type: domain.ReleaseTypeSingle, + } + + 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 add release.") + return + } + + id, err := db.InsertRelease(tx, single) + if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + logger.Error("failed to rollback db transaction", "err", rollbackErr) + } + logger.Error("failed to save new release into db", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to add release.") + return + } + + if err := tx.Commit(); err != nil { + logger.Error("failed to commit db transaction", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to add release.") + return + } + + logger.Info("added release", "release", single) + + newSong.Releases = append(newSong.Releases, domain.NewSongRelease{ + ReleaseID: id, + TrackNumber: 1, + }) + } + tx, err := ss.db.BeginTx(r.Context(), nil) if err != nil { logger.Error("failed to begin db transaction", "err", err) diff --git a/internal/server/search-release.go b/internal/server/search-release.go @@ -27,7 +27,7 @@ func (ss *ServerState) SearchRelease(w http.ResponseWriter, r *http.Request) { return } - release := r.URL.Query().Get("title") + release := r.URL.Query().Get("release-title") results, err := db.SearchRelease(ss.db, release) if err != nil { logger.Error("failed to query db for releases", "err", err) diff --git a/internal/server/ui/partials/release-search-results/release-search-results.templ b/internal/server/ui/partials/release-search-results/release-search-results.templ @@ -23,7 +23,7 @@ templ ReleaseSearchResults(params ReleaseSearchResultsParams) { value={ fmt.Sprintf("%d", release.ID) } data-name={ release.Title } required - @change="selected = true; releaseId = $event.target.value; releaseTitle = $event.target.dataset.name" + @change="item.selected = true; releaseId = $event.target.value; releaseTitle = $event.target.dataset.name" /> { label } </label> diff --git a/internal/server/ui/views/edit-release/edit-release.templ b/internal/server/ui/views/edit-release/edit-release.templ @@ -56,7 +56,7 @@ templ EditRelease(params EditReleaseParams) { title: <input x-ref="releaseTitle" - name="title" + name="release-title" type="text" value={ params.Release.Title } pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } diff --git a/internal/server/ui/views/new-release/new-release.templ b/internal/server/ui/views/new-release/new-release.templ @@ -35,7 +35,7 @@ templ NewRelease(params NewReleaseParams) { hx-trigger="input changed delay:500ms" hx-target="#similar-releases" hx-swap="innerHTML" - name="title" + name="release-title" type="text" pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } required diff --git a/internal/server/ui/views/new-song/new-song.css b/internal/server/ui/views/new-song/new-song.css @@ -13,6 +13,10 @@ padding: 0.75rem; resize: vertical; } + + .releases-list-group { + margin-bottom: 1.5rem; + } } .genre-options { @@ -32,3 +36,7 @@ } } } + +.disabled-form { + opacity: 30%; +} diff --git a/internal/server/ui/views/new-song/new-song.js b/internal/server/ui/views/new-song/new-song.js @@ -3,6 +3,6 @@ const form = document.querySelector("form"); form.addEventListener("htmx:config:request", function(evt) { if (!evt.detail.ctx.request.action.startsWith("/song/new")) return; evt.detail.ctx.request.body.delete("artist"); - evt.detail.ctx.request.body.delete("release"); + evt.detail.ctx.request.body.delete("release-title"); evt.detail.ctx.request.body.delete("featuring"); }); diff --git a/internal/server/ui/views/new-song/new-song.templ b/internal/server/ui/views/new-song/new-song.templ @@ -21,7 +21,7 @@ templ NewSong(params NewSongParams) { <label> title: <input - name="title" + name="song-title" type="text" pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } required @@ -38,61 +38,85 @@ templ NewSong(params NewSongParams) { /> </label> </fieldset> - <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> + <fieldset + x-data="{ items: [{ id: 0, selected: false }], uid: 1, createAsSingle: false }" + > <legend>Releases</legend> - <ol class="picker-list"> - <template x-for="(item, i) in items" :key="item.id"> - <li - x-data="{ selected: false, releaseId: '', releaseTitle: '' }" - x-init="htmx.process($el); initLucideIcons()" - > - <div class="picker-row"> - <div class="picker-main"> - <label x-show="!selected"> - <span>release:</span> - <input - type="search" - hx-get="/release/search?format=suggest" - hx-trigger="input changed delay:500ms" - hx-target="next .picker-options" - hx-swap="outerHTML" - name="title" - autocomplete="off" - :required="!selected && id > 0" - /> - </label> - <div class="picker-selected-item" x-show="selected"> - <strong x-text="releaseTitle"></strong> + <div class="releases-list-group"> + <ol + class="picker-list" + :class="createAsSingle && 'disabled-form'" + > + <template x-for="(item, i) in items" :key="item.id"> + <li + x-data="{ releaseId: '', releaseTitle: '' }" + x-init="htmx.process($el); initLucideIcons()" + > + <div class="picker-row"> + <div class="picker-main"> + <label x-show="!item.selected"> + <span>release:</span> + <input + type="search" + hx-get="/release/search?format=suggest" + hx-trigger="input changed delay:500ms" + hx-target="next .picker-options" + hx-swap="outerHTML" + name="release-title" + autocomplete="off" + :required="!item.selected && !createAsSingle" + :disabled="createAsSingle" + /> + </label> + <div class="picker-selected-item" x-show="item.selected"> + <strong x-text="releaseTitle"></strong> + <button + type="button" + class="-secondary" + @click="item.selected = false; releaseId = ''; releaseTitle = ''" + > + change + </button> + </div> + </div> + <div class="picker-actions"> <button type="button" - class="-secondary" - @click="selected = false; releaseId = ''; releaseTitle = ''" + aria-label="Remove" + x-show="items.length > 1" + @click="items.splice(i, 1)" > - change + <i data-lucide="x"></i> </button> </div> </div> - <div class="picker-actions"> - <button - type="button" - aria-label="Remove" - x-show="items.length > 1" - @click="items.splice(i, 1)" - > - <i data-lucide="x"></i> - </button> + <div x-show="!item.selected"> + <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> </div> - </div> - <div x-show="!selected"> - <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> - </div> - <input type="hidden" name="release-id" :value="releaseId"/> - </li> - </template> - </ol> - <button class="-secondary" type="button" @click="items.push({ id: uid++ })"> - add additional release - </button> + <input type="hidden" name="release-id" :value="releaseId"/> + </li> + </template> + </ol> + <button + class="-secondary" + type="button" + @click="items.push({ id: uid++, selected: false })" + :disabled="createAsSingle" + > + add additional release + </button> + </div> + <label + :class="items.find(item => item.selected) !== undefined && 'disabled-form'" + > + Create as single? + <input + name="create-as-single" + type="checkbox" + x-model="createAsSingle" + :disabled="items.find(item => item.selected) !== undefined" + /> + </label> </fieldset> <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> <legend>Artists</legend> diff --git a/tasks/add_release_types.txt b/tasks/add_release_types.txt @@ -1,10 +0,0 @@ -(A) add release types @feat -id: tqszpqrlzkvqrqkz - -[x] "album" is renamed to "release" -[x] follow musicbrainz modal of "album", "single", "ep", "other" -[ ] songs must have an explicit release attached to them. -[ ] new song page has a "create as single" which will - - create a song - - create a release with type single - - link the two together diff --git a/tasks/done/add_release_types.txt b/tasks/done/add_release_types.txt @@ -0,0 +1,10 @@ +(A) add release types @feat +id: tqszpqrlzkvqrqkz + +[x] "album" is renamed to "release" +[x] follow musicbrainz modal of "album", "single", "ep", "other" +[x] songs must have an explicit release attached to them. +[x] new song page has a "create as single" which will + - create a song + - create a release with type single + - link the two together