commit 89c9fa25ffa7bd3929b60e02c4704b82808fd40c
parent af0dcec372cd34daf5d1c50f6fce7eaf1e0b34b1
Author: brookjeynes <me@brookjeynes.dev>
Date: Sat, 13 Jun 2026 13:58:49 +1000
feat(albums): add album editing
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
14 files changed, 589 insertions(+), 22 deletions(-)
diff --git a/internal/db/album.go b/internal/db/album.go
@@ -107,10 +107,9 @@ func Album(e Execer, id domain.AlbumID) (domain.Album, error) {
// fetch songs on this album ordered by track_number
type albumSongRow struct {
songID domain.SongID
- trackNumber *int
+ trackNumber int
}
var songRows []albumSongRow
-
{
rows, err := e.Query(`
select song_id, track_number
@@ -137,18 +136,91 @@ func Album(e Execer, id domain.AlbumID) (domain.Album, error) {
_ = rows.Close()
}
- album.Songs = make([]domain.Song, 0, len(songRows))
+ album.Songs = make([]domain.AlbumSong, 0, len(songRows))
for _, r := range songRows {
song, err := Song(e, r.songID)
if err != nil {
return domain.Album{}, fmt.Errorf("failed to get song %d: %w", r.songID, err)
}
- album.Songs = append(album.Songs, song)
+ album.Songs = append(album.Songs, domain.AlbumSong{
+ Song: song,
+ TrackNumber: r.trackNumber,
+ })
}
return album, nil
}
+func UpdateAlbum(e Execer, album domain.EditAlbum) error {
+ result, err := e.Exec(`
+ update albums
+ set
+ title = ?,
+ latin_title = ?,
+ release_date = ?
+ where id = ?
+ `, album.Title, album.LatinTitle, album.ReleaseDate.Format(time.RFC3339), album.ID)
+ if err != nil {
+ return fmt.Errorf("failed to update album: %w", err)
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to check updated album rows: %w", err)
+ }
+ if rowsAffected == 0 {
+ return fmt.Errorf("album %d does not exist", album.ID)
+ }
+
+ _, err = e.Exec(`
+ delete from album_artists
+ where album_id = ?
+ and role = ?
+ `, album.ID, domain.RolePrimary)
+ if err != nil {
+ return fmt.Errorf("failed to delete album primary artists: %w", err)
+ }
+
+ for _, artist := range album.Artists {
+ _, err = e.Exec(`
+ insert into album_artists (
+ album_id,
+ artist_id,
+ role,
+ sort_order
+ )
+ values (?, ?, ?, ?)
+ `, album.ID, artist.ArtistID, artist.Role, artist.SortOrder)
+ if err != nil {
+ return fmt.Errorf("failed to insert album artist: %w", err)
+ }
+ }
+
+ _, err = e.Exec(`
+ delete from song_albums
+ where album_id = ?
+ `, album.ID)
+ if err != nil {
+ return fmt.Errorf("failed to delete album songs: %w", err)
+ }
+
+ for _, song := range album.Songs {
+ _, err = e.Exec(`
+ insert into song_albums (
+ song_id,
+ album_id,
+ track_number
+ )
+ values (?, ?, ?)
+ `, song.SongID, album.ID, song.TrackNumber)
+ if err != nil {
+ return fmt.Errorf("failed to update album song: %w", err)
+ }
+ }
+
+ return nil
+}
+
func InsertAlbum(e Execer, album domain.NewAlbum) error {
result, err := e.Exec(`
insert into albums (
diff --git a/internal/db/db.go b/internal/db/db.go
@@ -108,7 +108,7 @@ func New(ctx context.Context, dbPath string) (*DB, error) {
role text not null,
sort_order integer not null,
- unique(album_id, sort_order),
+ unique(album_id, role, sort_order),
foreign key (album_id) references albums(id) on delete cascade,
foreign key (artist_id) references artists(id) on delete cascade,
primary key (album_id, artist_id, role)
@@ -152,8 +152,9 @@ func New(ctx context.Context, dbPath string) (*DB, error) {
create table if not exists song_albums (
song_id integer not null,
album_id integer not null,
- track_number integer,
+ track_number integer not null,
+ unique(album_id, track_number),
foreign key (song_id) references songs(id) on delete cascade,
foreign key (album_id) references albums(id) on delete cascade,
primary key (song_id, album_id)
diff --git a/internal/db/song.go b/internal/db/song.go
@@ -333,10 +333,19 @@ func InsertSong(e Execer, song domain.NewSong) error {
_, err = e.Exec(`
insert into song_albums (
song_id,
- album_id
+ album_id,
+ track_number
)
- values (?, ?)
- `, lastID, album.AlbumID)
+ values (
+ ?,
+ ?,
+ coalesce((
+ select max(track_number)
+ from song_albums
+ where album_id = ?
+ ), 0) + 1
+ )
+ `, lastID, album.AlbumID, album.AlbumID)
if err != nil {
return fmt.Errorf("failed to insert song album: %w", err)
}
diff --git a/internal/domain/album.go b/internal/domain/album.go
@@ -18,13 +18,27 @@ type NewAlbumArtist struct {
SortOrder int
}
+type EditAlbum struct {
+ ID AlbumID
+ Title string
+ LatinTitle *string
+ ReleaseDate time.Time
+ Artists []NewAlbumArtist
+ Songs []EditAlbumSong
+}
+
+type EditAlbumSong struct {
+ SongID SongID
+ TrackNumber int
+}
+
type Album struct {
ID AlbumID
Title string
LatinTitle *string
ReleaseDate time.Time
Artists []AlbumArtist
- Songs []Song
+ Songs []AlbumSong
}
type AlbumArtist struct {
@@ -32,3 +46,8 @@ type AlbumArtist struct {
Role ArtistRole
SortOrder int
}
+
+type AlbumSong struct {
+ Song Song
+ TrackNumber int
+}
diff --git a/internal/domain/song.go b/internal/domain/song.go
@@ -25,7 +25,7 @@ type NewSongArtist struct {
type NewSongAlbum struct {
AlbumID AlbumID
- TrackNumber *int
+ TrackNumber int
}
type Song struct {
@@ -47,5 +47,5 @@ type SongArtist struct {
type SongAlbum struct {
Album Album
- TrackNumber *int
+ TrackNumber int
}
diff --git a/internal/server/edit-album.go b/internal/server/edit-album.go
@@ -0,0 +1,195 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "lyrics/internal/db"
+ "lyrics/internal/domain"
+ "lyrics/internal/server/htmx"
+ editalbum "lyrics/internal/server/ui/views/edit-album"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+type EditAlbumForm struct {
+ Title string `schema:"album"`
+ LatinTitle string `schema:"latin-title"`
+ ArtistID []domain.ArtistID `schema:"artist-id"`
+ SongID []domain.SongID `schema:"song-id"`
+ ReleaseDate string `schema:"release-date"`
+}
+
+func parseEditAlbumForm(form EditAlbumForm) (domain.EditAlbum, error) {
+ var album domain.EditAlbum
+
+ // title
+ if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) {
+ return album, fmt.Errorf("Title name must not be empty or have leading/trailing whitespace")
+ }
+
+ // artists
+ if len(form.ArtistID) == 0 {
+ return album, fmt.Errorf("At least one artist is required")
+ }
+
+ artists := make([]domain.NewAlbumArtist, 0, len(form.ArtistID))
+ seenArtists := make(map[int64]struct{}, len(form.ArtistID))
+
+ for i, id := range form.ArtistID {
+ if id < 0 {
+ return album, fmt.Errorf("Invalid artist selection")
+ }
+ if _, exists := seenArtists[int64(id)]; exists {
+ return album, fmt.Errorf("An artist cannot be added more than once")
+ }
+ seenArtists[int64(id)] = struct{}{}
+
+ artists = append(artists, domain.NewAlbumArtist{
+ ArtistID: id,
+ Role: domain.RolePrimary,
+ SortOrder: i,
+ })
+ }
+
+ // latin title
+ var latinTitle *string
+ if form.LatinTitle != "" {
+ if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) {
+ return album, fmt.Errorf("Latin title must not be empty or have leading/trailing whitespace")
+ }
+ if !domain.IsValidSortNameRegex.MatchString(form.LatinTitle) {
+ return album, fmt.Errorf("Latin title contains invalid characters")
+ }
+ latinTitle = &form.LatinTitle
+ }
+
+ releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate)
+ if err != nil {
+ return album, fmt.Errorf("Invalid release date: %w", err)
+ }
+ minDate := time.Date(1000, 1, 1, 0, 0, 0, 0, time.UTC)
+ maxDate := time.Now().AddDate(3, 0, 0)
+ if releaseDate.Before(minDate) || releaseDate.After(maxDate) {
+ return album, fmt.Errorf("Release date must be between %s and %s",
+ minDate.Format("2006-01-02"), maxDate.Format("2006-01-02"))
+ }
+
+ songs := make([]domain.EditAlbumSong, 0, len(form.SongID))
+ seenSongs := make(map[int64]struct{}, len(form.SongID))
+
+ for i, id := range form.SongID {
+ if id < 0 {
+ return album, fmt.Errorf("Invalid song selection")
+ }
+ if _, exists := seenSongs[int64(id)]; exists {
+ return album, fmt.Errorf("A song cannot be added more than once")
+ }
+ seenSongs[int64(id)] = struct{}{}
+
+ songs = append(songs, domain.EditAlbumSong{
+ SongID: id,
+ // Track numbers should start at 1
+ TrackNumber: i + 1,
+ })
+ }
+
+ album = domain.EditAlbum{
+ Title: form.Title,
+ LatinTitle: latinTitle,
+ ReleaseDate: releaseDate,
+ Artists: artists,
+ Songs: songs,
+ }
+
+ return album, nil
+}
+
+func (ss *ServerState) EditAlbum(w http.ResponseWriter, r *http.Request) {
+ logger := ss.logger.With("handler", "EditAlbum")
+
+ idStr := r.PathValue("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ logger.Error("failed to convert album 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:
+ album, err := db.Album(ss.db, domain.AlbumID(id))
+ if err != nil {
+ logger.Error("failed to get album", "err", err)
+ htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for album '%s'", slug))
+ return
+ }
+
+ err = editalbum.EditAlbum(editalbum.EditAlbumParams{
+ Album: album,
+ }).Render(r.Context(), w)
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ return
+ }
+
+ logger.Error("failed to render edit album 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 album.")
+ return
+ }
+
+ var editAlbumForm EditAlbumForm
+ err = decoder.Decode(&editAlbumForm, r.PostForm)
+ if err != nil {
+ logger.Error("failed to decode http form", "err", err)
+ htmx.HxError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ updatedAlbum, err := parseEditAlbumForm(editAlbumForm)
+ if err != nil {
+ logger.Error("failed to parse edited album form", "err", err)
+ htmx.HxError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ updatedAlbum.ID = domain.AlbumID(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 album.")
+ return
+ }
+
+ err = db.UpdateAlbum(tx, updatedAlbum)
+ if err != nil {
+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
+ logger.Error("failed to rollback db transaction", "err", rollbackErr)
+ }
+ logger.Error("failed to save edited album into db", "err", err)
+ htmx.HxError(w, http.StatusInternalServerError, "Failed to edit album.")
+ return
+ }
+
+ if err := tx.Commit(); err != nil {
+ logger.Error("failed to commit db transaction", "err", err)
+ htmx.HxError(w, http.StatusInternalServerError, "Failed to edit album.")
+ return
+ }
+
+ logger.Info("edited album", "album", updatedAlbum)
+
+ htmx.HxRedirect(w, http.StatusOK, "/")
+ }
+}
diff --git a/internal/server/router.go b/internal/server/router.go
@@ -25,6 +25,8 @@ func (ss *ServerState) Router() http.Handler {
router.Route("/album", func(router chi.Router) {
router.Route("/{id}", func(router chi.Router) {
router.Get("/{slug}", ss.Album)
+ router.Get("/{slug}/edit", ss.EditAlbum)
+ router.Post("/{slug}/edit", ss.EditAlbum)
})
router.Get("/new", ss.NewAlbum)
router.Post("/new", ss.NewAlbum)
diff --git a/internal/server/routes/routes.go b/internal/server/routes/routes.go
@@ -27,6 +27,10 @@ func Album(album domain.Album) string {
return AlbumPath(album.ID, title)
}
+func AlbumEdit(album domain.Album) string {
+ return fmt.Sprintf("%s/edit", Album(album))
+}
+
func AlbumPath(id domain.AlbumID, title string) string {
return fmt.Sprintf("/album/%d/%s", id, domain.Slug(title))
}
diff --git a/internal/server/ui/views/album/album.templ b/internal/server/ui/views/album/album.templ
@@ -23,12 +23,12 @@ templ Album(params AlbumParams) {
<p class="section-label -muted">No songs yet.</p>
} else {
<ol>
- for _, song := range params.Album.Songs {
+ for _, as := range params.Album.Songs {
<li>
- <a href={ routes.Song(song) }>
- { song.Title }
- if song.LatinTitle != nil {
- <span>({ *song.LatinTitle })</span>
+ <a href={ routes.Song(as.Song) }>
+ { as.Song.Title }
+ if as.Song.LatinTitle != nil {
+ <span>({ *as.Song.LatinTitle })</span>
}
</a>
</li>
@@ -36,6 +36,9 @@ templ Album(params AlbumParams) {
</ol>
}
</section>
+ <footer aria-label="Album actions">
+ <a href={ routes.AlbumEdit(params.Album) }>edit album</a>
+ </footer>
</section>
<aside class="album-details">
<dl>
diff --git a/internal/server/ui/views/artist/artist.templ b/internal/server/ui/views/artist/artist.templ
@@ -46,12 +46,12 @@ templ Artist(params ArtistParams) {
</header>
<section aria-label="Track List">
<ol>
- for _, song := range album.Songs {
+ for _, as := range album.Songs {
<li>
- <a href={ routes.Song(song) }>
- { song.Title }
- if song.LatinTitle != nil {
- <span>({ *song.LatinTitle })</span>
+ <a href={ routes.Song(as.Song) }>
+ { as.Song.Title }
+ if as.Song.LatinTitle != nil {
+ <span>({ *as.Song.LatinTitle })</span>
}
</a>
</li>
diff --git a/internal/server/ui/views/edit-album/edit-album.go b/internal/server/ui/views/edit-album/edit-album.go
@@ -0,0 +1,64 @@
+package editalbum
+
+import (
+ "encoding/json"
+ "lyrics/internal/domain"
+)
+
+type EditAlbumParams struct {
+ Album domain.Album
+}
+
+type editAlbumArtist struct {
+ UID int `json:"uid"`
+ Selected bool `json:"selected"`
+ ArtistID domain.ArtistID `json:"artistId"`
+ ArtistName string `json:"artistName"`
+}
+
+type editAlbumSong struct {
+ ID domain.SongID `json:"id"`
+ Title string `json:"title"`
+}
+
+func editAlbumArtistsJSON(artists []domain.AlbumArtist) string {
+ jsonArtists := make([]editAlbumArtist, 0, len(artists))
+
+ for i, aa := range artists {
+ jsonArtists = append(jsonArtists, editAlbumArtist{
+ UID: i,
+ Selected: true,
+ ArtistID: aa.Artist.ID,
+ ArtistName: aa.Artist.Name,
+ })
+ }
+
+ bytes, err := json.Marshal(jsonArtists)
+ if err != nil {
+ return "[]"
+ }
+
+ return string(bytes)
+}
+
+func editAlbumSongsJSON(songs []domain.AlbumSong) string {
+ jsonSongs := make([]editAlbumSong, 0, len(songs))
+
+ for _, as := range songs {
+ title := as.Song.Title
+ if as.Song.LatinTitle != nil {
+ title += " (" + *as.Song.LatinTitle + ")"
+ }
+ jsonSongs = append(jsonSongs, editAlbumSong{
+ ID: as.Song.ID,
+ Title: title,
+ })
+ }
+
+ bytes, err := json.Marshal(jsonSongs)
+ if err != nil {
+ return "[]"
+ }
+
+ return string(bytes)
+}
diff --git a/internal/server/ui/views/edit-album/edit-album.js b/internal/server/ui/views/edit-album/edit-album.js
@@ -0,0 +1,9 @@
+// filter out unnecessary data from request
+const form = document.querySelector("form");
+form.addEventListener("htmx:config:request", function(evt) {
+ if (
+ !evt.detail.ctx.request.action.startsWith("/album") ||
+ !evt.detail.ctx.request.action.endsWith("/edit")
+ ) return;
+ evt.detail.ctx.request.body.delete("artist");
+});
diff --git a/internal/server/ui/views/edit-album/edit-album.templ b/internal/server/ui/views/edit-album/edit-album.templ
@@ -0,0 +1,188 @@
+package editalbum
+
+import (
+ "fmt"
+ "lyrics/internal/domain"
+ "lyrics/internal/server/routes"
+ "lyrics/internal/server/ui/layouts/base"
+ "time"
+)
+
+templ EditAlbum(params EditAlbumParams) {
+ @base.Base(base.BaseParams{Title: "edit " + params.Album.Title}) {
+ <h1>Edit album</h1>
+ <form
+ class="album-form"
+ hx-post={ routes.AlbumEdit(params.Album) }
+ hx-swap="none"
+ hx-disable-elt="#save-album-btn"
+ >
+ <fieldset
+ x-data="{ showLatinTitle: false }"
+ x-init="showLatinTitle = isNonAscii($refs.albumTitle.value)"
+ >
+ <legend>Album</legend>
+ <label>
+ title:
+ <input
+ x-ref="albumTitle"
+ name="album"
+ type="text"
+ value={ params.Album.Title }
+ pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() }
+ required
+ @input="showLatinTitle = isNonAscii($event.target.value)"
+ />
+ </label>
+ <label x-show="showLatinTitle">
+ latin title:
+ <input
+ name="latin-title"
+ type="text"
+ if params.Album.LatinTitle != nil {
+ value={ *params.Album.LatinTitle }
+ }
+ :required="showLatinTitle"
+ pattern={ domain.IsValidSortNamePattern }
+ />
+ </label>
+ </fieldset>
+ <fieldset
+ x-data={ fmt.Sprintf("{ items: %s, uid: %d }", editAlbumArtistsJSON(params.Album.Artists), len(params.Album.Artists)) }
+ >
+ <legend>Artists</legend>
+ <ol class="artist-picker-list">
+ <template x-for="(item, i) in items" :key="item.uid">
+ <li
+ x-data="item"
+ x-init="htmx.process($el); initLucideIcons()"
+ >
+ <div class="artist-picker-row">
+ <div class="artist-picker-main">
+ <label x-show="!item.selected">
+ <span>artist:</span>
+ <input
+ type="search"
+ hx-get="/artist/search?format=suggest"
+ hx-trigger="input changed delay:500ms"
+ hx-target="next [role='radiogroup']"
+ hx-swap="outerHTML"
+ name="artist"
+ autocomplete="off"
+ :required="!item.selected"
+ />
+ </label>
+ <div class="selected-picker-item" x-show="item.selected">
+ <strong x-text="item.artistName"></strong>
+ <button
+ type="button"
+ class="-secondary"
+ @click="item.selected = false; item.artistId = ''; item.artistName = ''"
+ >
+ change
+ </button>
+ </div>
+ </div>
+ <div class="artist-picker-actions">
+ <button
+ type="button"
+ aria-label="Remove"
+ x-show="items.length > 1"
+ @click="items.splice(i, 1)"
+ >
+ <i data-lucide="x"></i>
+ </button>
+ <button
+ type="button"
+ aria-label="Move up"
+ x-show="item.selected && items.length > 1 && 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="item.selected && items.length > 1 && i + 1 < items.length"
+ @click="[items[i + 1], items[i]] = [items[i], items[i + 1]]"
+ >
+ <i data-lucide="chevron-down"></i>
+ </button>
+ </div>
+ </div>
+ <div x-show="!item.selected">
+ <fieldset role="radiogroup" x-show="false"></fieldset>
+ </div>
+ <input type="hidden" :name="item.selected ? 'artist-id' : ''" :value="item.artistId"/>
+ </li>
+ </template>
+ </ol>
+ <button
+ class="-secondary"
+ type="button"
+ @click="items.push({ uid: uid++, selected: false, artistId: '', artistName: '' })"
+ >
+ add additional artist
+ </button>
+ </fieldset>
+ <fieldset>
+ <legend>Release</legend>
+ <label>
+ release date:
+ <input
+ name="release-date"
+ type="date"
+ value={ params.Album.ReleaseDate.Format(time.DateOnly) }
+ min="1000-01-01"
+ max={ fmt.Sprintf("%s", time.Now().AddDate(3, 0, 0).Format("2006-01-02")) }
+ required
+ />
+ </label>
+ </fieldset>
+ <fieldset
+ x-data={ fmt.Sprintf("{ items: %s }", editAlbumSongsJSON(params.Album.Songs)) }
+ >
+ <legend>Songs</legend>
+ if len(params.Album.Songs) == 0 {
+ <p class="section-label -muted">No songs yet.</p>
+ } else {
+ <ol class="artist-picker-list">
+ <template x-for="(item, i) in items" :key="item.id">
+ <li x-init="htmx.process($el); initLucideIcons()">
+ <div class="artist-picker-row">
+ <div class="artist-picker-main">
+ <strong x-text="item.title"></strong>
+ </div>
+ <div class="artist-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"/>
+ </li>
+ </template>
+ </ol>
+ }
+ </fieldset>
+ <footer aria-label="Form actions">
+ <button class="-primary" id="save-album-btn" type="submit">save changes</button>
+ <a href={ routes.Album(params.Album) }>cancel</a>
+ </footer>
+ </form>
+ <script src="/static/js/edit-album.js" defer></script>
+ }
+}
diff --git a/scripts/build-assets.sh b/scripts/build-assets.sh
@@ -24,4 +24,5 @@ minify internal/server/ui/js/string-utils.js -o static/files/js/string-utils.js
minify internal/server/ui/js/lucide-utils.js -o static/files/js/lucide-utils.js
minify internal/server/ui/views/new-song/new-song.js -o static/files/js/new-song.js
minify internal/server/ui/views/new-album/new-album.js -o static/files/js/new-album.js
+minify internal/server/ui/views/edit-album/edit-album.js -o static/files/js/edit-album.js
minify internal/server/toast/toast.js -o static/files/js/toast.js