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