Artists ({{len .Artists}} total) + Create New +
There are no artists.
+ {{end}} +diff --git a/.air.toml b/.air.toml index 070166a..c6d499b 100644 --- a/.air.toml +++ b/.air.toml @@ -7,14 +7,14 @@ tmp_dir = "tmp" bin = "./tmp/main" cmd = "go build -o ./tmp/main ." delay = 1000 - exclude_dir = ["admin/static", "admin\\static", "public", "uploads", "test", "db", "res"] + exclude_dir = ["uploads", "test", "db", "res"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] - include_ext = ["go", "tpl", "tmpl", "html"] + include_ext = ["go", "tpl", "tmpl", "html", "css"] include_file = [] kill_delay = "0s" log = "build-errors.log" diff --git a/.forgejo/workflows/push-prod.yaml b/.forgejo/workflows/push-prod.yaml new file mode 100644 index 0000000..7a9fd05 --- /dev/null +++ b/.forgejo/workflows/push-prod.yaml @@ -0,0 +1,50 @@ +on: + push: + branches: + - main + +env: + EXEC: arimelody-web + REMOTE: ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} + PORT: ${{ secrets.SSH_PORT }} + +jobs: + deploy: + runs-on: docker + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '^1.25.1' + + - name: Run tests + run: go test -v ./model + + - name: Build binary + run: make build + + - name: Bundle tarball + run: make bundle + + - name: Set up SSH keys + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Copy to production server + run: | + ssh-keyscan -p $PORT ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts + scp -P $PORT ./$EXEC.tar.gz $REMOTE:~/ + + - name: Restart production + run: | + ssh -o StrictHostKeyChecking=no $REMOTE -p $PORT << EOT + cd ${{ secrets.DEPLOY_DIR }} + tar xzf ~/$EXEC.tar.gz + /bin/bash ~/restart.sh + rm ~/$EXEC.tar.gz + EOT + diff --git a/Makefile b/Makefile index 11e565a..98a4ea1 100644 --- a/Makefile +++ b/Makefile @@ -2,11 +2,11 @@ EXEC = arimelody-web .PHONY: $(EXEC) -$(EXEC): +build: GOOS=linux GOARCH=amd64 go build -o $(EXEC) -bundle: $(EXEC) - tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ +bundle: build + tar czf $(EXEC).tar.gz --exclude ".DS_Store" $(EXEC) admin/static/ public/ clean: rm $(EXEC) $(EXEC).tar.gz diff --git a/admin/account/accounthttp.go b/admin/account/accounthttp.go index 5601a2e..d1b0d49 100644 --- a/admin/account/accounthttp.go +++ b/admin/account/accounthttp.go @@ -7,6 +7,7 @@ import ( "net/url" "os" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/log" @@ -20,12 +21,12 @@ func Handler(app *model.AppState) http.Handler { mux.Handle("/", accountIndexHandler(app)) - mux.Handle("/totp-setup", totpSetupHandler(app)) - mux.Handle("/totp-confirm", totpConfirmHandler(app)) - mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) + mux.Handle("/account/totp-setup", totpSetupHandler(app)) + mux.Handle("/account/totp-confirm", totpConfirmHandler(app)) + mux.Handle("/account/totp-delete", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) - mux.Handle("/password", changePasswordHandler(app)) - mux.Handle("/delete", deleteAccountHandler(app)) + mux.Handle("/account/password", changePasswordHandler(app)) + mux.Handle("/account/delete", deleteAccountHandler(app)) return mux } @@ -47,7 +48,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { } accountResponse struct { - Session *model.Session + core.AdminPageData TOTPs []TOTP } ) @@ -68,7 +69,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { session.Error = sessionError err = templates.AccountTemplate.Execute(w, accountResponse{ - Session: session, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, TOTPs: totps, }) if err != nil { @@ -172,7 +173,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { } type totpConfirmData struct { - Session *model.Session + core.AdminPageData TOTP *model.TOTP NameEscaped string QRBase64Image string @@ -181,13 +182,9 @@ type totpConfirmData struct { func totpSetupHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - type totpSetupData struct { - Session *model.Session - } - session := r.Context().Value("session").(*model.Session) - err := templates.TotpSetupTemplate.Execute(w, totpSetupData{ Session: session }) + err := templates.TOTPSetupTemplate.Execute(w, core.AdminPageData{ Path: "/account", Session: session }) if err != nil { fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -224,7 +221,9 @@ func totpSetupHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to create TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - err := templates.TotpSetupTemplate.Execute(w, totpConfirmData{ Session: session }) + err := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, + }) if err != nil { fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -238,8 +237,8 @@ func totpSetupHandler(app *model.AppState) http.Handler { fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) } - err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{ - Session: session, + err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, TOTP: &totp, NameEscaped: url.PathEscape(totp.Name), QRBase64Image: qrBase64Image, @@ -270,11 +269,6 @@ func totpConfirmHandler(app *model.AppState) http.Handler { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - code := r.FormValue("totp") - if len(code) != controller.TOTP_CODE_LENGTH { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) if err != nil { @@ -294,23 +288,22 @@ func totpConfirmHandler(app *model.AppState) http.Handler { fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) } + code := r.FormValue("totp") confirmCode := controller.GenerateTOTP(totp.Secret, 0) - if code != confirmCode { - confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) - if code != confirmCodeOffset { - session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } - err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{ - Session: session, - TOTP: totp, - NameEscaped: url.PathEscape(totp.Name), - QRBase64Image: qrBase64Image, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - return + confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) + if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) { + session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } + err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, + TOTP: totp, + NameEscaped: url.PathEscape(totp.Name), + QRBase64Image: qrBase64Image, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } + return } err = controller.ConfirmTOTP(app.DB, session.Account.ID, name) @@ -331,18 +324,23 @@ func totpConfirmHandler(app *model.AppState) http.Handler { func totpDeleteHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { + if r.Method != http.MethodPost { http.NotFound(w, r) return } - if len(r.URL.Path) < 2 { + session := r.Context().Value("session").(*model.Session) + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + name := r.FormValue("totp-name") + if len(name) == 0 { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - name := r.URL.Path[1:] - - session := r.Context().Value("session").(*model.Session) totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) if err != nil { diff --git a/admin/auth/authhttp.go b/admin/auth/authhttp.go index aba4074..7805638 100644 --- a/admin/auth/authhttp.go +++ b/admin/auth/authhttp.go @@ -1,6 +1,7 @@ package auth import ( + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/log" @@ -135,12 +136,8 @@ func LoginHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - type loginData struct { - Session *model.Session - } - render := func() { - err := templates.LoginTemplate.Execute(w, loginData{ Session: session }) + err := templates.LoginTemplate.Execute(w, core.AdminPageData{ Session: session }) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -251,12 +248,8 @@ func LoginTOTPHandler(app *model.AppState) http.Handler { return } - type loginTOTPData struct { - Session *model.Session - } - render := func() { - err := templates.LoginTOTPTemplate.Execute(w, loginTOTPData{ Session: session }) + err := templates.LoginTOTPTemplate.Execute(w, core.AdminPageData{ Session: session }) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/core/structs.go b/admin/core/structs.go new file mode 100644 index 0000000..1a4639f --- /dev/null +++ b/admin/core/structs.go @@ -0,0 +1,8 @@ +package core + +import "arimelody-web/model" + +type AdminPageData struct { + Path string + Session *model.Session +} diff --git a/admin/http.go b/admin/http.go index e04c636..35bb153 100644 --- a/admin/http.go +++ b/admin/http.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http" "os" - "path/filepath" "arimelody-web/admin/account" "arimelody-web/admin/auth" @@ -14,6 +13,7 @@ import ( "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" + "arimelody-web/view" ) func Handler(app *model.AppState) http.Handler { @@ -28,7 +28,20 @@ func Handler(app *model.AppState) http.Handler { mux.Handle("/logs", core.RequireAccount(logs.Handler(app))) mux.Handle("/account/", core.RequireAccount(http.StripPrefix("/account", account.Handler(app)))) - mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) + mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/static/admin.css" { + http.ServeFile(w, r, "./admin/static/admin.css") + return + } + if r.URL.Path == "/static/admin.js" { + http.ServeFile(w, r, "./admin/static/admin.js") + return + } + core.RequireAccount( + http.StripPrefix("/static", + view.ServeFiles("./admin/static"))).ServeHTTP(w, r) + })) + mux.Handle("/", core.RequireAccount(AdminIndexHandler(app))) // response wrapper to make sure a session cookie exists @@ -44,21 +57,63 @@ func AdminIndexHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - latestRelease, err := controller.GetLatestRelease(app.DB) + releases, err := controller.GetAllReleases(app.DB, false, 3, true) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull latest release: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + releaseCount, err := controller.GetReleaseCount(app.DB, false) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + artists, err := controller.GetAllArtists(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + artistCount, err := controller.GetArtistCount(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + tracks, err := controller.GetOrphanTracks(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + trackCount, err := controller.GetTrackCount(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type IndexData struct { - Session *model.Session - LatestRelease *model.Release + core.AdminPageData + Releases []*model.Release + ReleaseCount int + Artists []*model.Artist + ArtistCount int + Tracks []*model.Track + TrackCount int } err = templates.IndexTemplate.Execute(w, IndexData{ - Session: session, - LatestRelease: latestRelease, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, + Releases: releases, + ReleaseCount: releaseCount, + Artists: artists, + ArtistCount: artistCount, + Tracks: tracks, + TrackCount: trackCount, }) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) @@ -68,23 +123,23 @@ func AdminIndexHandler(app *model.AppState) http.Handler { }) } +/* +//go:embed "static" +var staticFS embed.FS + func staticHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path))) - // does the file exist? + uri := strings.TrimPrefix(r.URL.Path, "/static") + file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri))) if err != nil { - if os.IsNotExist(err) { - http.NotFound(w, r) - return - } - } - - // is thjs a directory? (forbidden) - if info.IsDir() { http.NotFound(w, r) return } - http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r) + w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path))) + w.WriteHeader(http.StatusOK) + + w.Write(file) }) } +*/ diff --git a/admin/logs/logshttp.go b/admin/logs/logshttp.go index 5c5c5c9..270721e 100644 --- a/admin/logs/logshttp.go +++ b/admin/logs/logshttp.go @@ -1,6 +1,7 @@ package logs import ( + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/log" "arimelody-web/model" @@ -51,12 +52,12 @@ func Handler(app *model.AppState) http.Handler { } type LogsResponse struct { - Session *model.Session + core.AdminPageData Logs []*log.Log } err = templates.LogsTemplate.Execute(w, LogsResponse{ - Session: session, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Logs: logs, }) if err != nil { diff --git a/admin/music/artisthttp.go b/admin/music/artisthttp.go index a936d05..822ee72 100644 --- a/admin/music/artisthttp.go +++ b/admin/music/artisthttp.go @@ -3,52 +3,86 @@ package music import ( "fmt" "net/http" + "os" "strings" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" ) -func serveArtist(app *model.AppState) http.Handler { +func serveArtists(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - slices := strings.Split(r.URL.Path[1:], "/") - id := slices[0] - artist, err := controller.GetArtist(app.DB, id) + session := r.Context().Value("session").(*model.Session) + + slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/artists")[1:], "/") + artistID := slices[0] + + if len(artistID) > 0 { + serveArtist(app, artistID).ServeHTTP(w, r) + return + } + + artists, err := controller.GetAllArtists(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + type ArtistsResponse struct { + core.AdminPageData + Artists []*model.Artist + } + + err = templates.ArtistsTemplate.Execute(w, ArtistsResponse{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, + Artists: artists, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artists page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + +func serveArtist(app *model.AppState, artistID string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + artist, err := controller.GetArtist(app.DB, artistID) if err != nil { if artist == nil { http.NotFound(w, r) return } - fmt.Printf("Error rendering admin artist page for %s: %s\n", id, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) if err != nil { - fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type ArtistResponse struct { - Session *model.Session + core.AdminPageData Artist *model.Artist Credits []*model.Credit } - session := r.Context().Value("session").(*model.Session) - - err = templates.ArtistTemplate.Execute(w, ArtistResponse{ - Session: session, + err = templates.EditArtistTemplate.Execute(w, ArtistResponse{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Artist: artist, Credits: credits, }) if err != nil { - fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } - diff --git a/admin/music/musichttp.go b/admin/music/musichttp.go index c212248..cc2ef93 100644 --- a/admin/music/musichttp.go +++ b/admin/music/musichttp.go @@ -1,69 +1,18 @@ package music import ( - "arimelody-web/admin/templates" - "arimelody-web/controller" "arimelody-web/model" - "fmt" "net/http" - "os" ) func Handler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux := http.NewServeMux() - mux.Handle("/release/", http.StripPrefix("/release", serveRelease(app))) - mux.Handle("/artist/", http.StripPrefix("/artist", serveArtist(app))) - mux.Handle("/track/", http.StripPrefix("/track", serveTrack(app))) - mux.Handle("/", musicHandler(app)) + mux.Handle("/releases/", serveReleases(app)) + mux.Handle("/artists/", serveArtists(app)) + mux.Handle("/tracks/", serveTracks(app)) mux.ServeHTTP(w, r) }) } - -func musicHandler(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - releases, err := controller.GetAllReleases(app.DB, false, 0, true) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - artists, err := controller.GetAllArtists(app.DB) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - tracks, err := controller.GetOrphanTracks(app.DB) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - type MusicData struct { - Session *model.Session - Releases []*model.Release - Artists []*model.Artist - Tracks []*model.Track - } - - err = templates.MusicTemplate.Execute(w, MusicData{ - Session: session, - Releases: releases, - Artists: artists, - Tracks: tracks, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - }) -} diff --git a/admin/music/releasehttp.go b/admin/music/releasehttp.go index 6716b1a..de77655 100644 --- a/admin/music/releasehttp.go +++ b/admin/music/releasehttp.go @@ -3,18 +3,61 @@ package music import ( "fmt" "net/http" + "os" "strings" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" ) -func serveRelease(app *model.AppState) http.Handler { +func serveReleases(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - slices := strings.Split(r.URL.Path[1:], "/") + session := r.Context().Value("session").(*model.Session) + + slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/") releaseID := slices[0] + var action string = "" + if len(slices) > 1 { + action = slices[1] + } + + if len(releaseID) > 0 { + serveRelease(app, releaseID, action).ServeHTTP(w, r) + return + } + + type ReleasesData struct { + core.AdminPageData + Releases []*model.Release + } + + releases, err := controller.GetAllReleases(app.DB, false, 0, true) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + err = templates.ReleasesTemplate.Execute(w, ReleasesData{ + AdminPageData: core.AdminPageData{ + Path: r.URL.Path, + Session: session, + }, + Releases: releases, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} + +func serveRelease(app *model.AppState, releaseID string, action string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) release, err := controller.GetRelease(app.DB, releaseID, true) @@ -23,13 +66,13 @@ func serveRelease(app *model.AppState) http.Handler { http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch full release data for %s: %s\n", releaseID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - if len(slices) > 1 { - switch slices[1] { + if len(action) > 0 { + switch action { case "editcredits": serveEditCredits(release).ServeHTTP(w, r) return @@ -57,16 +100,18 @@ func serveRelease(app *model.AppState) http.Handler { } type ReleaseResponse struct { - Session *model.Session + core.AdminPageData Release *model.Release } - err = templates.ReleaseTemplate.Execute(w, ReleaseResponse{ - Session: session, + for i, track := range release.Tracks { track.Number = i + 1 } + + err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Release: release, }) if err != nil { - fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin release page for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -77,7 +122,7 @@ func serveEditCredits(release *model.Release) http.Handler { w.Header().Set("Content-Type", "text/html") err := templates.EditCreditsTemplate.Execute(w, release) if err != nil { - fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -87,7 +132,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) if err != nil { - fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -103,7 +148,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { Artists: artists, }) if err != nil { - fmt.Printf("Error rendering add credits component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve add credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -114,7 +159,7 @@ func serveNewCredit(app *model.AppState) http.Handler { artistID := strings.Split(r.URL.Path, "/")[3] artist, err := controller.GetArtist(app.DB, artistID) if err != nil { - fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -126,7 +171,7 @@ func serveNewCredit(app *model.AppState) http.Handler { w.Header().Set("Content-Type", "text/html") err = templates.NewCreditTemplate.Execute(w, artist) if err != nil { - fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -137,7 +182,7 @@ func serveEditLinks(release *model.Release) http.Handler { w.Header().Set("Content-Type", "text/html") err := templates.EditLinksTemplate.Execute(w, release) if err != nil { - fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit links component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -147,17 +192,13 @@ func serveEditTracks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - type editTracksData struct { - Release *model.Release - Add func(a int, b int) int - } + type editTracksData struct { Release *model.Release } - err := templates.EditTracksTemplate.Execute(w, editTracksData{ - Release: release, - Add: func(a, b int) int { return a + b }, - }) + for i, track := range release.Tracks { track.Number = i + 1 } + + err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release }) if err != nil { - fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -167,7 +208,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) if err != nil { - fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -183,10 +224,9 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { Tracks: tracks, }) if err != nil { - fmt.Printf("Error rendering add tracks component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to add tracks component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - return }) } @@ -195,7 +235,7 @@ func serveNewTrack(app *model.AppState) http.Handler { trackID := strings.Split(r.URL.Path, "/")[3] track, err := controller.GetTrack(app.DB, trackID) if err != nil { - fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -207,9 +247,8 @@ func serveNewTrack(app *model.AppState) http.Handler { w.Header().Set("Content-Type", "text/html") err = templates.NewTrackTemplate.Execute(w, track) if err != nil { - fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve new track component for %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - return }) } diff --git a/admin/music/trackhttp.go b/admin/music/trackhttp.go index 5f67a83..80d9ba5 100644 --- a/admin/music/trackhttp.go +++ b/admin/music/trackhttp.go @@ -3,20 +3,57 @@ package music import ( "fmt" "net/http" + "os" "strings" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" ) -func serveTrack(app *model.AppState) http.Handler { +func serveTracks(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - slices := strings.Split(r.URL.Path[1:], "/") - id := slices[0] - track, err := controller.GetTrack(app.DB, id) + session := r.Context().Value("session").(*model.Session) + + slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/tracks")[1:], "/") + trackID := slices[0] + + if len(trackID) > 0 { + serveTrack(app, trackID).ServeHTTP(w, r) + return + } + + tracks, err := controller.GetAllTracks(app.DB) if err != nil { - fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + type TracksResponse struct { + core.AdminPageData + Tracks []*model.Track + } + + err = templates.TracksTemplate.Execute(w, TracksResponse{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, + Tracks: tracks, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin tracks page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + +func serveTrack(app *model.AppState, trackID string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + track, err := controller.GetTrack(app.DB, trackID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin track page for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -27,28 +64,25 @@ func serveTrack(app *model.AppState) http.Handler { releases, err := controller.GetTrackReleases(app.DB, track.ID, true) if err != nil { - fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases for track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type TrackResponse struct { - Session *model.Session + core.AdminPageData Track *model.Track Releases []*model.Release } - session := r.Context().Value("session").(*model.Session) - - err = templates.TrackTemplate.Execute(w, TrackResponse{ - Session: session, + err = templates.EditTrackTemplate.Execute(w, TrackResponse{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Track: track, Releases: releases, }) if err != nil { - fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin track page for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } - diff --git a/admin/static/admin.css b/admin/static/admin.css index 99dc276..a8e17c3 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -1,91 +1,266 @@ @import url("/style/prideflag.css"); @import url("/font/inter/inter.css"); +:root { + --bg-0: #101010; + --bg-1: #181818; + --bg-2: #282828; + --bg-3: #404040; + + --fg-0: #b0b0b0; + --fg-1: #c0c0c0; + --fg-2: #d0d0d0; + --fg-3: #e0e0e0; + + --col-shadow-0: #0002; + --col-shadow-1: #0004; + --col-shadow-2: #0006; + --col-highlight-0: #ffffff08; + --col-highlight-1: #fff1; + --col-highlight-2: #fff2; + + --col-new: #b3ee5b; + --col-on-new: #1b2013; + --col-save: #6fd7ff; + --col-on-save: #283f48; + --col-delete: #ff7171; + --col-on-delete: #371919; + + --col-warn: #ffe86a; + --col-on-warn: var(--bg-0); + --col-warn-hover: #ffec81; + + --shadow-sm: + 0 1px 2px var(--col-shadow-2), + inset 0 1px 1px var(--col-highlight-2); + --shadow-md: + 0 2px 4px var(--col-shadow-1), + inset 0 2px 2px var(--col-highlight-1); + --shadow-lg: + 0 4px 8px var(--col-shadow-0), + inset 0 4px 4px var(--col-highlight-0); +} + +@media (prefers-color-scheme: light) { + :root { + --bg-0: #e8e8e8; + --bg-1: #f0f0f0; + --bg-2: #f8f8f8; + --bg-3: #ffffff; + + --fg-0: #606060; + --fg-1: #404040; + --fg-2: #303030; + --fg-3: #202020; + + --col-shadow-0: #0002; + --col-shadow-1: #0004; + --col-shadow-2: #0008; + --col-highlight-0: #fff2; + --col-highlight-1: #fff4; + --col-highlight-2: #fff8; + + --col-warn: #ffe86a; + --col-on-warn: var(--fg-3); + --col-warn-hover: #ffec81; + } +} + +@media (prefers-color-scheme: green) { + :root { + --bg-0: #d0d9c7; + --bg-1: #e2e5de; + --bg-2: #f1f1f1; + --bg-3: #ffffff; + --fg-0: #626f54; + --fg-1: #505c43; + --fg-2: #49523e; + --fg-3: #2c3522; + } +} + +@media (prefers-color-scheme: purple) { + :root { + --bg-0: #15121c; + --bg-1: #1e1a27; + --bg-2: #302a3d; + --bg-3: #4a4358; + --fg-0: #9e8fbf; + --fg-1: #a29bb3; + --fg-2: #b9b0cd; + --fg-3: #dcd5ec; + } +} + +@media (prefers-color-scheme: dark) { + img.icon { + -webkit-filter: invert(.9); + filter: invert(.9); + } +} + body { - width: 100%; + width: calc(100% - 180px); height: calc(100vh - 1em); - margin: 0; + margin: 0 0 0 180px; padding: 0; + display: flex; + flex-direction: row; font-family: "Inter", sans-serif; font-size: 16px; + color: var(--fg-0); + background: var(--bg-0); - color: #303030; - background: #f0f0f0; + transition: background .1s ease-out, color .1s ease-out; } -nav { - width: min(720px, calc(100% - 2em)); - height: 2em; - margin: 1em auto; +h1, h2, h3, h4, h5, h6 { + color: var(--fg-3); +} + +header { display: flex; - flex-direction: row; + justify-content: space-between; + align-items: center; +} +nav { + position: fixed; + top: 0; + left: 0; + width: 180px; + height: calc(100vh - 2em); + margin: 0; + padding: 1em 0; + display: flex; + flex-direction: column; justify-content: left; - background: #f8f8f8; - border-radius: 4px; - border: 1px solid #808080; + background-color: var(--bg-1); + box-shadow: var(--shadow-md); + transition: background .1s ease-out, color .1s ease-out; + + user-select: none; } -.nav-item.icon { +nav .icon { + width: fit-content; + height: fit-content; padding: 0; -} -.nav-item.icon img { - height: 100%; -} -nav .title { - width: auto; - height: 100%; - - margin: 0 1em 0 0; - + margin: 0 auto 1em auto; display: flex; - line-height: 2em; - text-decoration: none; - - color: inherit; + border-radius: 100%; + box-shadow: var(--shadow-sm); + overflow: clip; +} +nav .icon img { + width: 3em; + height: 3em; } .nav-item { - width: auto; - height: 100%; - - margin: 0px; - padding: 0 1em; - display: flex; - + color: var(--fg-2); line-height: 2em; + font-weight: 500; + transition: color .1s ease-out, background-color .1s ease-out; } .nav-item:hover { - background: #00000010; + color: var(--bg-2); + background-color: var(--fg-2); text-decoration: none; } +.nav-item.active { + border-left: 4px solid var(--fg-2); +} +.nav-item.active a { + padding-left: calc(1em - 3.5px); +} nav a { + padding: .2em 1em; text-decoration: none; color: inherit; + width: 100%; } -nav #logout { - /* margin-left: auto; */ +nav a.active { + border-left: 5px solid var(--fg-0); + padding-left: calc(1em - 5px); +} +nav hr { + width: calc(100% - 2em); + margin: .5em auto; + border: none; + border-bottom: 1px solid var(--fg-0); +} +nav .section-label { + margin: .6em 0 .1em 1.6em; + font-size: .6em; + text-transform: uppercase; + font-weight: 600; +} +#toggle-nav { + position: fixed; + top: 16px; + left: 16px; + padding: 8px; + width: 48px; + height: 48px; + display: none; + justify-content: center; + align-items: center; + z-index: 1; +} +#toggle-nav img { + width: 100%; + height: 100%; + object-fit: cover; + transform: translate(1px, 1px); +} +#toggle-nav img:hover { + -webkit-filter: invert(.9); + filter: invert(.9); +} +@media (prefers-color-scheme: dark) { + #toggle-nav img { + -webkit-filter: invert(.9); + filter: invert(.9); + } + #toggle-nav img:hover { + -webkit-filter: none; + filter: none; + } } main { - width: min(720px, calc(100% - 2em)); + width: 720px; + max-width: calc(100% - 2em); + height: fit-content; + min-height: calc(100vh - 2em); margin: 0 auto; padding: 1em; } +main.dashboard { + width: 100%; +} a { color: inherit; text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: color .1s ease-out, background-color .1s ease-out; } +/* a:hover { text-decoration: underline; } +*/ -a img.icon { +img.icon { height: .8em; + transition: filter .1s ease-out; } code { @@ -101,7 +276,24 @@ h1 { +.cards { + width: 100%; + height: fit-content; + display: flex; + gap: 2em; + flex-wrap: wrap; +} + .card { + flex-basis: 40em; + padding: 1em; + background: var(--bg-1); + border-radius: 16px; + box-shadow: var(--shadow-lg); + + transition: background .1s ease-out, color .1s ease-out; +} +main:not(.dashboard) .card { margin-bottom: 1em; } @@ -109,7 +301,7 @@ h1 { margin: 0 0 .5em 0; } -.card-title { +.card-header { margin-bottom: 1em; display: flex; gap: 1em; @@ -117,21 +309,31 @@ h1 { align-items: center; justify-content: space-between; } - -.card-title h1, -.card-title h2, -.card-title h3 { +.card-header h1, +.card-header h2, +.card-header h3 { margin: 0; } +.card-header a:hover { + text-decoration: underline; +} + +header :is(h1, h2, h3) small, +.card-header :is(h1, h2, h3) small { + display: inline-block; + font-size: .6em; + transform: translateY(-0.1em); + color: var(--fg-0); +} .flex-fill { flex-grow: 1; } -@media screen and (max-width: 520px) { - body { - font-size: 12px; - } +.artists-group { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 1em; } @@ -140,17 +342,15 @@ h1 { #error { margin: 0 0 1em 0; padding: 1em; - border-radius: 4px; + border-radius: 8px; + color: #101010; background: #ffffff; - border: 1px solid #888; } #message { background: #a9dfff; - border-color: #599fdc; } #error { background: #ffa9b8; - border-color: #dc5959; } @@ -159,52 +359,54 @@ a.delete:not(.button) { color: #d22828; } -button, .button { +.button, button { padding: .5em .8em; font-family: inherit; font-size: inherit; - border-radius: 4px; - border: 1px solid #a0a0a0; - background: #f0f0f0; + color: inherit; + background: var(--bg-2); + border: none; + border-radius: 10em; + box-shadow: var(--shadow-sm); + font-weight: 500; + transition: background .1s ease-out, color .1s ease-out; + + cursor: pointer; + user-select: none; } button:hover, .button:hover { background: #fff; - border-color: #d0d0d0; } button:active, .button:active { background: #d0d0d0; - border-color: #808080; } -.button, button { - color: inherit; -} .button.new, button.new { - background: #c4ff6a; - border-color: #84b141; + color: var(--col-on-new); + background: var(--col-new); } .button.save, button.save { - background: #6fd7ff; - border-color: #6f9eb0; + color: var(--col-on-save); + background: var(--col-save); } .button.delete, button.delete { - background: #ff7171; - border-color: #7d3535; + color: var(--col-on-delete); + background: var(--col-delete); } .button:hover, button:hover { - background: #fff; - border-color: #d0d0d0; + color: var(--bg-3); + background: var(--fg-3); } .button:active, button:active { - background: #d0d0d0; - border-color: #808080; + color: var(--bg-2); + background: var(--fg-0); } .button[disabled], button[disabled] { - background: #d0d0d0 !important; - border-color: #808080 !important; + color: var(--fg-0) !important; + background: var(--bg-3) !important; opacity: .5; - cursor: not-allowed !important; + cursor: default !important; } @@ -212,24 +414,79 @@ button:active, .button:active { form { width: 100%; display: block; + color: var(--fg-0); } form label { width: 100%; margin: 1rem 0 .5rem 0; display: block; - color: #10101080; } -form input { - margin: .5rem 0; - padding: .3rem .5rem; +form input[type="text"], +form input[type="password"] { + width: 16em; + max-width: 100%; + margin: .5em 0; + padding: .3em .5em; display: block; border-radius: 4px; border: 1px solid #808080; font-size: inherit; font-family: inherit; color: inherit; + background-color: var(--bg-0); } input[disabled] { opacity: .5; cursor: not-allowed; } + +@media screen and (max-width: 720px) { + main { + padding-top: 0; + } + + body { + width: 100%; + margin: 0; + font-size: 16px; + } + + nav { + width: 100%; + left: -100%; + font-size: 24px; + z-index: 1; + transition: transform .2s cubic-bezier(.2,0,.5,1); + } + nav.open { + transform: translateX(100%); + } + + #toggle-nav { + display: flex; + } + + main > h1:first-of-type { + margin-left: 68px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + main > header { + margin-left: 68px; + } + main > header h1 { + display: flex; + flex-direction: column; + font-size: 1.5em; + } + + .card { + flex-basis: 100%; + max-width: calc(100vw - 4em); + } + + .artists-group { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/admin/static/admin.js b/admin/static/admin.js index 0763ab7..8bf2480 100644 --- a/admin/static/admin.js +++ b/admin/static/admin.js @@ -69,3 +69,28 @@ export function makeMagicList(container, itemSelector, callback) { if (callback) callback(); }); } + +export function hijackClickEvent(container, link) { + container.addEventListener('click', event => { + if (event.target.tagName.toLowerCase() === 'a') return; + event.preventDefault(); + link.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + button: event.button, + })); + }); +} + +document.addEventListener("readystatechange", () => { + const navbar = document.getElementById("navbar"); + document.getElementById("toggle-nav").addEventListener("click", event => { + event.preventDefault(); + navbar.classList.toggle("open"); + }) +}); diff --git a/admin/static/artists.css b/admin/static/artists.css new file mode 100644 index 0000000..516a998 --- /dev/null +++ b/admin/static/artists.css @@ -0,0 +1,27 @@ +.artist { + padding: .5em; + + color: var(--fg-3); + background: var(--bg-2); + box-shadow: var(--shadow-md); + border-radius: 16px; + text-align: center; + + cursor: pointer; + transition: background .1s ease-out, color .1s ease-out; +} + +.artist:hover { + background: var(--bg-1); + text-decoration: hover; +} + +.artist .artist-avatar { + width: 100%; + object-fit: cover; + border-radius: 8px; +} + +.artist .artist-name { + display: block; +} diff --git a/admin/static/artists.js b/admin/static/artists.js new file mode 100644 index 0000000..29eab22 --- /dev/null +++ b/admin/static/artists.js @@ -0,0 +1,7 @@ +import { hijackClickEvent } from "./admin.js"; + +document.addEventListener("readystatechange", () => { + document.querySelectorAll(".artists-group .artist").forEach(el => { + hijackClickEvent(el, el.querySelector("a.artist-name")) + }); +}); diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index 7a4d34a..c43d6e9 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -11,8 +11,11 @@ label { align-items: center; color: inherit; } -input { - width: min(20rem, calc(100% - 1rem)); +form#change-password input, +form#delete-account input { + width: 20em; + min-width: auto; + max-width: calc(100% - 1em - 2px); margin: .5rem 0; padding: .3rem .5rem; display: block; @@ -25,12 +28,14 @@ input { .mfa-device { padding: .75em; - background: #f8f8f8f8; - border: 1px solid #808080; - border-radius: 8px; margin-bottom: .5em; display: flex; justify-content: space-between; + + color: var(--fg-3); + background: var(--bg-2); + box-shadow: var(--shadow-md); + border-radius: 16px; } .mfa-device div { @@ -46,3 +51,14 @@ input { .mfa-device .mfa-device-name { font-weight: bold; } + +.mfa-device form input { + display: none !important; +} + +.mfa-actions { + display: flex; + flex-direction: row; + gap: .5em; + flex-wrap: wrap; +} diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 5627e64..7bf146b 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -1,7 +1,3 @@ -h1 { - margin: 0 0 1em 0; -} - #artist { margin-bottom: 1em; padding: 1.5em; @@ -9,9 +5,9 @@ h1 { flex-direction: row; gap: 1.2em; - border-radius: 8px; - background: #f8f8f8f8; - border: 1px solid #808080; + border-radius: 16px; + background: var(--bg-2); + box-shadow: var(--shadow-md); } .artist-avatar { @@ -27,7 +23,8 @@ h1 { cursor: pointer; } .artist-avatar #remove-avatar { - padding: .3em .4em; + margin-top: .5em; + padding: .3em .6em; } .artist-info { @@ -53,8 +50,8 @@ input[type="text"] { font-family: inherit; font-weight: inherit; color: inherit; - background: #ffffff; - border: 1px solid transparent; + background: var(--bg-0); + border: none; border-radius: 4px; outline: none; } @@ -74,7 +71,7 @@ input[type="text"]:focus { justify-content: right; } -.card-title a.button { +.card-header a.button { text-decoration: none; } @@ -85,9 +82,17 @@ input[type="text"]:focus { flex-direction: row; gap: 1em; align-items: center; - background: #f8f8f8; - border-radius: 8px; - border: 1px solid #808080; + + border-radius: 16px; + background: var(--bg-2); + box-shadow: var(--shadow-md); + + cursor: pointer; + transition: background .1s; +} + +.credit:hover { + background: var(--bg-1); } .release-artwork { @@ -96,8 +101,14 @@ input[type="text"]:focus { border-radius: 4px; } +.credit-info { + overflow: hidden; +} .credit-info h3, .credit-info p { margin: 0; font-size: .9em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } diff --git a/admin/static/edit-artist.js b/admin/static/edit-artist.js index 8f1bb2b..069c25d 100644 --- a/admin/static/edit-artist.js +++ b/admin/static/edit-artist.js @@ -1,3 +1,5 @@ +import { hijackClickEvent } from "./admin.js"; + const artistID = document.getElementById("artist").dataset.id; const nameInput = document.getElementById("name"); const avatarImg = document.getElementById("avatar"); @@ -77,3 +79,9 @@ removeAvatarBtn.addEventListener("click", () => { avatarImg.src = "/img/default-avatar.png" saveBtn.disabled = false; }); + +document.addEventListener('readystatechange', () => { + document.querySelectorAll('#releases .credit').forEach(el => { + hijackClickEvent(el, el.querySelector('.credit-name a')); + }); +}); diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index aa70e34..434b487 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -12,8 +12,10 @@ input[type="text"] { gap: 1.2em; border-radius: 8px; - background: #f8f8f8f8; - border: 1px solid #808080; + background: var(--bg-2); + box-shadow: var(--shadow-md); + + transition: background .1s ease-out, color .1s ease-out; } .release-artwork { @@ -29,7 +31,9 @@ input[type="text"] { cursor: pointer; } .release-artwork #remove-artwork { - padding: .3em .4em; + margin-top: .5em; + padding: .3em .6em; + background: var(--bg-3); } .release-info { @@ -54,17 +58,17 @@ input[type="text"] { background: transparent; outline: none; cursor: pointer; + transition: background .1s ease-out, border-color .1s ease-out; } #title:hover { - background: #ffffff; - border-color: #80808080; + background: var(--bg-3); + border-color: var(--fg-0); } #title:active, #title:focus { - background: #ffffff; - border-color: #808080; + background: var(--bg-3); } .release-title small { @@ -75,19 +79,21 @@ input[type="text"] { width: 100%; margin: .5em 0; border-collapse: collapse; + color: var(--fg-2); } .release-info table td { padding: .2em; - border-bottom: 1px solid #d0d0d0; + border-bottom: 1px solid color-mix(in srgb, var(--fg-0) 25%, transparent); + transition: background .1s ease-out, border-color .1s ease-out; } .release-info table tr td:first-child { vertical-align: top; - opacity: .66; + opacity: .5; } .release-info table tr td:not(:first-child) select:hover, .release-info table tr td:not(:first-child) input:hover, .release-info table tr td:not(:first-child) textarea:hover { - background: #e8e8e8; + background: var(--bg-3); cursor: pointer; } .release-info table td select, @@ -115,13 +121,25 @@ input[type="text"] { gap: .5em; flex-direction: row; justify-content: right; + color: var(--fg-3); +} + +.release-actions button, +.release-actions .button { + color: var(--fg-2); + background: var(--bg-3); } dialog { width: min(720px, calc(100% - 2em)); padding: 2em; - border: 1px solid #101010; - border-radius: 8px; + border: none; + border-radius: 16px; + color: var(--fg-0); + background-color: var(--bg-0); + box-shadow: var(--shadow-lg); + + transition: color .1s ease-out, background-color .1s ease-out; } dialog header { @@ -144,7 +162,7 @@ dialog div.dialog-actions { gap: .5em; } -.card-title a.button { +.card-header a.button { text-decoration: none; } @@ -152,7 +170,7 @@ dialog div.dialog-actions { * RELEASE CREDITS */ -.card.credits .credit { +#credits .credit { margin-bottom: .5em; padding: .5em; display: flex; @@ -160,28 +178,47 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: 8px; - background: #f8f8f8f8; - border: 1px solid #808080; + border-radius: 16px; + background-color: var(--bg-2); + box-shadow: var(--shadow-md); + + cursor: pointer; + transition: background .1s ease-out; +} +#credits .credit:hover { + background-color: var(--bg-1); } -.card.credits .credit p { +#credits .credit p { margin: 0; } -.card.credits .credit .artist-avatar { - border-radius: 8px; +#credits .credit .artist-avatar { + border-radius: 12px; } -.card.credits .credit .artist-name { +#credits .credit .artist-name { + color: var(--fg-3); font-weight: bold; } -.card.credits .credit .artist-role small { +#credits .credit .artist-role small { font-size: inherit; opacity: .66; } +#credits .credit .credit-info { + overflow: hidden; +} + +#credits .credit .credit-info :is(h3, p) { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + + + #editcredits ul { margin: 0; padding: 0; @@ -197,8 +234,8 @@ dialog div.dialog-actions { gap: 1em; border-radius: 8px; - background: #f8f8f8f8; - border: 1px solid #808080; + background: var(--bg-2); + box-shadow: var(--shadow-md); } #editcredits .credit { @@ -232,6 +269,7 @@ dialog div.dialog-actions { margin: 0; display: flex; align-items: center; + color: inherit; } #editcredits .credit .credit-info .credit-attribute input[type="text"] { @@ -239,15 +277,17 @@ dialog div.dialog-actions { padding: .2em .4em; flex-grow: 1; font-family: inherit; - border: 1px solid #8888; + border: none; border-radius: 4px; - color: inherit; + color: var(--fg-2); + background: var(--bg-0); } #editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { margin: 0 .3em; } #editcredits .credit .artist-name { + color: var(--fg-2); font-weight: bold; } @@ -256,8 +296,12 @@ dialog div.dialog-actions { opacity: .66; } -#editcredits .credit button.delete { - margin-left: auto; +#editcredits .credit .delete { + margin-right: .5em; + cursor: pointer; +} +#editcredits .credit .delete:hover { + text-decoration: underline; } #addcredit ul { @@ -289,24 +333,39 @@ dialog div.dialog-actions { * RELEASE LINKS */ -.card.links { +#links ul { + padding: 0; display: flex; gap: .2em; } -.card.links a.button[data-name="spotify"] { +#links a img.icon { + -webkit-filter: none; + filter: none; +} + +#links a.button:hover { + color: var(--bg-3) !important; + background-color: var(--fg-3) !important; +} + +#links a.button[data-name="spotify"] { + color: #101010; background-color: #8cff83 } -.card.links a.button[data-name="apple music"] { +#links a.button[data-name="apple music"] { + color: #101010; background-color: #8cd9ff } -.card.links a.button[data-name="soundcloud"] { +#links a.button[data-name="soundcloud"] { + color: #101010; background-color: #fdaa6d } -.card.links a.button[data-name="youtube"] { +#links a.button[data-name="youtube"] { + color: #101010; background-color: #ff6e6e } @@ -389,62 +448,6 @@ dialog div.dialog-actions { outline: 1px solid #808080; } -/* - * RELEASE TRACKS - */ - -.card.tracks .track { - margin-bottom: 1em; - padding: 1em; - display: flex; - flex-direction: column; - gap: .5em; - - border-radius: 8px; - background: #f8f8f8f8; - border: 1px solid #808080; -} - -.card.tracks .track h3, -.card.tracks .track p { - margin: 0; -} - -.card.tracks h2.track-title { - margin: 0; - display: flex; - gap: .5em; -} - -.card.tracks h2.track-title .track-number { - opacity: .5; -} - -.card.tracks .track-album { - margin-left: auto; - font-style: italic; - font-size: .75em; - opacity: .5; -} - -.card.tracks .track-album.empty { - color: #ff2020; - opacity: 1; -} - -.card.tracks .track-description { - font-style: italic; -} - -.card.tracks .track-lyrics { - max-height: 10em; - overflow-y: scroll; -} - -.card.tracks .track .empty { - opacity: 0.75; -} - #edittracks ul { padding: 0; list-style: none; @@ -496,15 +499,17 @@ dialog div.dialog-actions { padding: .5em; display: flex; gap: .5em; + background-color: var(--bg-0); cursor: pointer; + transition: background-color .1s ease-out, color .1s ease-out; } #addtrack ul li.new-track:nth-child(even) { - background: #f0f0f0; + background: color-mix(in srgb, var(--bg-0) 95%, #fff); } #addtrack ul li.new-track:hover { - background: #e0e0e0; + background: color-mix(in srgb, var(--bg-0) 90%, #fff); } @media only screen and (max-width: 1105px) { diff --git a/admin/static/edit-release.js b/admin/static/edit-release.js index 11d21f0..ca2754f 100644 --- a/admin/static/edit-release.js +++ b/admin/static/edit-release.js @@ -1,3 +1,5 @@ +import { hijackClickEvent } from "./admin.js"; + const releaseID = document.getElementById("release").dataset.id; const titleInput = document.getElementById("title"); const artworkImg = document.getElementById("artwork"); @@ -96,3 +98,9 @@ removeArtworkBtn.addEventListener("click", () => { artworkData = ""; saveBtn.disabled = false; }); + +document.addEventListener("readystatechange", () => { + document.querySelectorAll("#credits .credit").forEach(el => { + hijackClickEvent(el, el.querySelector(".artist-name a")); + }); +}); diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index 600b680..f292ca5 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -1,9 +1,5 @@ @import url("/admin/static/release-list-item.css"); -h1 { - margin: 0 0 .5em 0; -} - #track { margin-bottom: 1em; padding: .5em 1.5em 1.5em 1.5em; @@ -11,9 +7,9 @@ h1 { flex-direction: row; gap: 1.2em; - border-radius: 8px; - background: #f8f8f8f8; - border: 1px solid #808080; + border-radius: 16px; + background: var(--bg-2); + box-shadow: var(--shadow-md); } .track-info { @@ -49,7 +45,8 @@ h1 { font-weight: inherit; font-family: inherit; font-size: inherit; - border: 1px solid transparent; + background: var(--bg-0); + border: none; border-radius: 4px; outline: none; color: inherit; diff --git a/admin/static/index.js b/admin/static/index.js index e69de29..60bdfd0 100644 --- a/admin/static/index.js +++ b/admin/static/index.js @@ -0,0 +1,74 @@ +const newReleaseBtn = document.getElementById("create-release"); +const newArtistBtn = document.getElementById("create-artist"); +const newTrackBtn = document.getElementById("create-track"); + +newReleaseBtn.addEventListener("click", event => { + event.preventDefault(); + const id = prompt("Enter an ID for this release:"); + if (id == null || id == "") return; + + fetch("/api/v1/music", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({id}) + }).then(res => { + if (res.ok) location = "/admin/releases/" + id; + else { + res.text().then(err => { + alert("Request failed: " + err); + console.error(err); + }); + } + }).catch(err => { + alert("Failed to create release. Check the console for details."); + console.error(err); + }); +}); + +newArtistBtn.addEventListener("click", event => { + event.preventDefault(); + const id = prompt("Enter an ID for this artist:"); + if (id == null || id == "") return; + + fetch("/api/v1/artist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({id}) + }).then(res => { + res.text().then(text => { + if (res.ok) { + location = "/admin/artists/" + id; + } else { + alert("Request failed: " + text); + console.error(text); + } + }) + }).catch(err => { + alert("Failed to create artist. Check the console for details."); + console.error(err); + }); +}); + +newTrackBtn.addEventListener("click", event => { + event.preventDefault(); + const title = prompt("Enter an title for this track:"); + if (title == null || title == "") return; + + fetch("/api/v1/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({title}) + }).then(res => { + res.text().then(text => { + if (res.ok) { + location = "/admin/tracks/" + text; + } else { + alert("Request failed: " + text); + console.error(text); + } + }) + }).catch(err => { + alert("Failed to create track. Check the console for details."); + console.error(err); + }); +}); diff --git a/admin/static/logs.css b/admin/static/logs.css index f0df299..8da60d0 100644 --- a/admin/static/logs.css +++ b/admin/static/logs.css @@ -2,8 +2,14 @@ main { width: min(1080px, calc(100% - 2em))!important } -form { +form#search-form { + width: calc(100% - 2em); margin: 1em 0; + padding: 1em; + border-radius: 16px; + color: var(--fg-0); + background: var(--bg-2); + box-shadow: var(--shadow-md); } div#search { @@ -12,24 +18,25 @@ div#search { #search input { margin: 0; + padding: .3em .8em; flex-grow: 1; - - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; + border: none; + border-radius: 16px; + color: var(--fg-1); + background: var(--bg-0); + box-shadow: var(--shadow-sm); } #search button { - padding: 0 .5em; - - border-top-left-radius: 0; - border-bottom-left-radius: 0; + margin-left: .5em; + padding: 0 .8em; } form #filters p { margin: .5em 0 0 0; } form #filters label { + color: inherit; display: inline; } form #filters input { @@ -39,6 +46,15 @@ form #filters input { #logs { width: 100%; + overflow: scroll; +} +@media screen and (max-width: 720px) { + #logs { + font-size: 12px; + } +} + +#logs table{ border-collapse: collapse; } @@ -57,6 +73,10 @@ form #filters input { padding: .4em .8em; } +#logs .log { + color: var(--fg-2); +} + td, th { width: 1%; text-align: left; @@ -74,13 +94,14 @@ td.log-content { white-space: collapse; } -.log:hover { - background: #fff8; +#logs .log:hover { + background: color-mix(in srgb, var(--fg-3) 10%, transparent); } -.log.warn { - background: #ffe86a; +#logs .log.warn { + color: var(--col-on-warn); + background: var(--col-warn); } -.log.warn:hover { - background: #ffec81; +#logs .log.warn:hover { + background: var(--col-warn-hover); } diff --git a/admin/static/music.js b/admin/static/music.js index 6e4a7a9..8bb192f 100644 --- a/admin/static/music.js +++ b/admin/static/music.js @@ -12,7 +12,7 @@ newReleaseBtn.addEventListener("click", event => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({id}) }).then(res => { - if (res.ok) location = "/admin/music/release/" + id; + if (res.ok) location = "/admin/music/releases/" + id; else { res.text().then(err => { alert("Request failed: " + err); @@ -37,7 +37,7 @@ newArtistBtn.addEventListener("click", event => { }).then(res => { res.text().then(text => { if (res.ok) { - location = "/admin/music/artist/" + id; + location = "/admin/music/artists/" + id; } else { alert("Request failed: " + text); console.error(text); @@ -61,7 +61,7 @@ newTrackBtn.addEventListener("click", event => { }).then(res => { res.text().then(text => { if (res.ok) { - location = "/admin/music/track/" + text; + location = "/admin/music/tracks/" + text; } else { alert("Request failed: " + text); console.error(text); diff --git a/admin/static/release-list-item.css b/admin/static/release-list-item.css deleted file mode 100644 index 638eac0..0000000 --- a/admin/static/release-list-item.css +++ /dev/null @@ -1,87 +0,0 @@ -.release { - margin-bottom: 1em; - padding: 1em; - display: flex; - flex-direction: row; - gap: 1em; - - border-radius: 8px; - background: #f8f8f8f8; - border: 1px solid #808080; -} - -.release h3, -.release p { - margin: 0; -} - -.release-artwork { - width: 96px; - - display: flex; - justify-content: center; - align-items: center; -} - -.release-artwork img { - width: 100%; - aspect-ratio: 1; -} - -.release-title small { - opacity: .75; -} - -.release-links { - margin: .5em 0; - padding: 0; - display: flex; - flex-direction: row; - list-style: none; - flex-wrap: wrap; - gap: .5em; -} - -.release-links li { - flex-grow: 1; -} - -.release-links a { - padding: .5em; - display: block; - - border-radius: 8px; - text-decoration: none; - color: #f0f0f0; - background: #303030; - text-align: center; - - transition: color .1s, background .1s; -} - -.release-links a:hover { - color: #303030; - background: #f0f0f0; -} - -.release-actions { - margin-top: .5em; -} - -.release-actions a { - margin-right: .3em; - padding: .3em .5em; - display: inline-block; - - border-radius: 4px; - background: #e0e0e0; - - transition: color .1s, background .1s; -} - -.release-actions a:hover { - color: #303030; - background: #f0f0f0; - - text-decoration: none; -} diff --git a/admin/static/releases.css b/admin/static/releases.css new file mode 100644 index 0000000..0694875 --- /dev/null +++ b/admin/static/releases.css @@ -0,0 +1,80 @@ +.release { + margin-bottom: 1em; + padding: 1em; + display: flex; + flex-direction: row; + gap: 1em; + + border-radius: 16px; + background: var(--bg-2); + box-shadow: var(--shadow-md); + + transition: background .1s ease-out, color .1s ease-out; +} + +.release h3, +.release p { + margin: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.release .release-artwork { + margin: auto 0; + width: 96px; + + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.release .release-artwork img { + width: 100%; + aspect-ratio: 1; +} + +.release .release-info { + max-width: calc(100% - 96px - 1em); +} + +.release .release-title small { + opacity: .75; +} + +.release .release-links { + margin: .5em 0; + padding: 0; + display: flex; + flex-direction: row; + list-style: none; + flex-wrap: wrap; + gap: .5em; +} + +.release .release-actions { + margin-top: .5em; + user-select: none; + color: var(--fg-3); +} + +.release .release-actions a { + margin-right: .3em; + padding: .3em .5em; + display: inline-block; + + border-radius: 4px; + background: var(--bg-3); + box-shadow: var(--shadow-sm); + + transition: color .1s ease-out, background .1s ease-out; +} + +.release .release-actions a:hover { + background: var(--bg-0); + color: var(--fg-3); + text-decoration: none; +} diff --git a/admin/static/tracks.css b/admin/static/tracks.css new file mode 100644 index 0000000..c36c1b1 --- /dev/null +++ b/admin/static/tracks.css @@ -0,0 +1,127 @@ +#tracks h2.track-title { + margin: 0; + display: flex; + gap: .5em; +} + +#tracks .track { + margin-bottom: 1em; + padding: 1em; + display: flex; + flex-direction: column; + gap: .5em; + + border-radius: 16px; + background: var(--bg-2); + box-shadow: var(--shadow-md); + + transition: background .1s ease-out, color .1s ease-out; +} + +#tracks .track h3, +#tracks .track p { + margin: 0; +} + +#tracks h2.track-title { + margin: 0; + display: flex; + gap: .5em; +} + +#tracks h2.track-title .track-number { + opacity: .5; +} + +#tracks a:hover { + text-decoration: underline; +} + +#tracks .track-album { + margin-left: auto; + font-style: italic; + font-size: .75em; + opacity: .5; +} + +#tracks .track-album.empty { + color: #ff2020; + opacity: 1; +} + +#tracks .track-description { + font-style: italic; +} + +#tracks .track-lyrics { + max-height: 10em; + overflow-y: scroll; +} + +#tracks .track .empty { + opacity: 0.75; +} + + +.card h2.track-title { + margin: 0; + display: flex; + flex-direction: row; + /* + justify-content: space-between; + */ +} + +/* +.track { + margin-bottom: 1em; + padding: 1em; + display: flex; + flex-direction: column; + gap: .5em; + + border-radius: 8px; + background-color: var(--bg-2); + box-shadow: var(--shadow-md); + + transition: color .1s ease-out, background-color .1s ease-out; +} + +.track p { + margin: 0; +} + +.track-id { + width: fit-content; + font-family: "Monaspace Argon", monospace; + font-size: .8em; + font-style: italic; + line-height: 1em; + user-select: all; +} + +.track-album { + margin-left: auto; + font-style: italic; + font-size: .75em; + opacity: .5; +} + +.track-album.empty { + color: #ff2020; + opacity: 1; +} + +.track-description { + font-style: italic; +} + +.track-lyrics { + max-height: 10em; + overflow-y: scroll; +} + +.track .empty { + opacity: 0.75; +} +*/ diff --git a/admin/templates/html/artists.html b/admin/templates/html/artists.html new file mode 100644 index 0000000..f4a6432 --- /dev/null +++ b/admin/templates/html/artists.html @@ -0,0 +1,26 @@ +{{define "head"}} +
There are no artists.
+ {{end}} +{{.PrintArtists true true}}
{{.ReleaseType}} - ({{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}})
+ ({{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}})