commit 5083fe684fd6ce8612d58c10ce22546533dff606
parent 32de8e817e22882b59bef51d493f031935dd84fc
Author: brookjeynes <me@brookjeynes.dev>
Date: Mon, 22 Jun 2026 08:06:40 +1000
feat: order songs via input type number
Task-id: rrpptxupmmsrnrsz
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
7 files changed, 85 insertions(+), 32 deletions(-)
diff --git a/internal/server/edit-release.go b/internal/server/edit-release.go
@@ -20,6 +20,7 @@ type EditReleaseForm struct {
LatinTitle string `schema:"latin-title"`
ArtistID []domain.ArtistID `schema:"artist-id"`
SongID []domain.SongID `schema:"song-id"`
+ TrackNumber []int `schema:"track-number"`
ReleaseDate string `schema:"release-date"`
MusicBrainzReleaseID domain.MusicBrainzID `schema:"musicbrainz-release-id"`
}
@@ -87,22 +88,40 @@ func parseEditReleaseForm(form EditReleaseForm) (domain.EditRelease, error) {
minDate.Format("2006-01-02"), maxDate.Format("2006-01-02"))
}
+ if len(form.SongID) != len(form.TrackNumber) {
+ return release, fmt.Errorf("Song ID and track number counts do not match", err)
+ }
+
songs := make([]domain.EditReleaseSong, 0, len(form.SongID))
- seenSongs := make(map[int64]struct{}, len(form.SongID))
+ seenTracknumbers := make(map[int]domain.SongID, len(form.SongID))
+ seenSongs := make(map[domain.SongID]int, len(form.SongID))
- for i, id := range form.SongID {
- if id < 0 {
+ for i := 0; i < len(form.SongID); i++ {
+ songID := form.SongID[i]
+ trackNumber := form.TrackNumber[i]
+
+ if songID < 0 {
return release, fmt.Errorf("Invalid song selection")
}
- if _, exists := seenSongs[int64(id)]; exists {
+
+ if trackNumber < 1 {
+ return release, fmt.Errorf("Track number can not be less that 1")
+ }
+
+ if _, exists := seenTracknumbers[trackNumber]; exists {
+ return release, fmt.Errorf("Songs may not share the same track number")
+ }
+
+ if _, exists := seenSongs[songID]; exists {
return release, fmt.Errorf("A song cannot be added more than once")
}
- seenSongs[int64(id)] = struct{}{}
+
+ seenTracknumbers[trackNumber] = songID
+ seenSongs[songID] = trackNumber
songs = append(songs, domain.EditReleaseSong{
- SongID: id,
- // Track numbers should start at 1
- TrackNumber: i + 1,
+ SongID: songID,
+ TrackNumber: trackNumber,
})
}
diff --git a/internal/server/ui/picker-list.css b/internal/server/ui/picker-list.css
@@ -5,6 +5,10 @@
display: flex;
align-items: center;
gap: 0.75rem;
+
+ input[type="number"] {
+ width: 3em;
+ }
}
.picker-main {
diff --git a/internal/server/ui/views/edit-release/edit-release.go b/internal/server/ui/views/edit-release/edit-release.go
@@ -17,8 +17,9 @@ type editReleaseArtist struct {
}
type editReleaseSong struct {
- ID domain.SongID `json:"id"`
- Title string `json:"title"`
+ ID domain.SongID `json:"id"`
+ Title string `json:"title"`
+ TrackNumber int `json:"trackNumber"`
}
func editReleaseArtistsJSON(artists []domain.ReleaseArtist) string {
@@ -50,8 +51,9 @@ func editReleaseSongsJSON(songs []domain.ReleaseSong) string {
title += " (" + *as.Song.LatinTitle + ")"
}
jsonSongs = append(jsonSongs, editReleaseSong{
- ID: as.Song.ID,
- Title: title,
+ ID: as.Song.ID,
+ Title: title,
+ TrackNumber: as.TrackNumber,
})
}
diff --git a/internal/server/ui/views/edit-release/edit-release.js b/internal/server/ui/views/edit-release/edit-release.js
@@ -7,3 +7,41 @@ form.addEventListener("htmx:config:request", function(evt) {
) return;
evt.detail.ctx.request.body.delete("artist");
});
+
+/**
+ * @typedef {Object} EditReleaseSong
+ * @property {number} id
+ * @property {string} title
+ * @property {number} trackNumber
+ */
+
+/**
+ * @param {Event} e
+ * @param {EditReleaseSong[]} items
+ * @param {number} a Current track number
+ * @param {string} b Desired track number
+ */
+function changeTrackOrder(e, items, a, b) {
+ if (b <= 0) return;
+ if (isNaN(b)) return;
+
+ if (items.length === 1) {
+ items[a].trackNumber = b;
+ return;
+ }
+
+ const swapTo = items.findIndex(item => item.trackNumber == b);
+
+ if (swapTo !== -1) {
+ [items[a].trackNumber, items[swapTo].trackNumber] = [items[swapTo].trackNumber, items[a].trackNumber];
+ [items[a], items[swapTo]] = [items[swapTo], items[a]];
+ return;
+ }
+
+ const item = items[a];
+ item.trackNumber = b;
+ items.push(item);
+ items.splice(a, 1);
+
+ items.sort((a, b) => a.trackNumber - b.trackNumber);
+}
diff --git a/internal/server/ui/views/edit-release/edit-release.templ b/internal/server/ui/views/edit-release/edit-release.templ
@@ -183,31 +183,21 @@ templ EditRelease(params EditReleaseParams) {
} else {
<ol class="picker-list">
<template x-for="(item, i) in items" :key="item.id">
- <li x-init="htmx.process($el); initLucideIcons()">
+ <li :value="item.trackNumber" x-init="htmx.process($el); initLucideIcons()">
<div class="picker-row">
+ <input
+ type="number"
+ :value="item.trackNumber"
+ min="1"
+ @change="changeTrackOrder($event, items, i, $event.target.value)"
+ required
+ />
<div class="picker-main">
<strong x-text="item.title"></strong>
</div>
- <div class="picker-actions">
- <button
- type="button"
- aria-label="Move up"
- x-show="i >= 1"
- @click="[items[i - 1], items[i]] = [items[i], items[i - 1]]"
- >
- <i data-lucide="chevron-up"></i>
- </button>
- <button
- type="button"
- aria-label="Move down"
- x-show="i + 1 < items.length"
- @click="[items[i + 1], items[i]] = [items[i], items[i + 1]]"
- >
- <i data-lucide="chevron-down"></i>
- </button>
- </div>
</div>
<input type="hidden" name="song-id" :value="item.id"/>
+ <input type="hidden" name="track-number" :value="item.trackNumber"/>
</li>
</template>
</ol>
diff --git a/internal/server/ui/views/release/release.templ b/internal/server/ui/views/release/release.templ
@@ -24,7 +24,7 @@ templ Release(params ReleaseParams) {
} else {
<ol>
for _, as := range params.Release.Songs {
- <li>
+ <li value={ as.TrackNumber }>
<a href={ routes.Song(as.Song) }>
{ as.Song.Title }
if as.Song.LatinTitle != nil {
diff --git a/tasks/order_songs_via_input_type_number.txt b/tasks/done/order_songs_via_input_type_number.txt