Merge branch 'dev'

This commit is contained in:
ari melody 2025-10-21 23:54:52 +01:00
commit 3be5f79d87
Signed by: ari
GPG key ID: CF99829C92678188
59 changed files with 1551 additions and 768 deletions

View file

@ -7,14 +7,14 @@ tmp_dir = "tmp"
bin = "./tmp/main" bin = "./tmp/main"
cmd = "go build -o ./tmp/main ." cmd = "go build -o ./tmp/main ."
delay = 1000 delay = 1000
exclude_dir = ["admin/static", "admin\\static", "public", "uploads", "test", "db", "res"] exclude_dir = ["uploads", "test", "db", "res"]
exclude_file = [] exclude_file = []
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
exclude_unchanged = false exclude_unchanged = false
follow_symlink = false follow_symlink = false
full_bin = "" full_bin = ""
include_dir = [] include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"] include_ext = ["go", "tpl", "tmpl", "html", "css"]
include_file = [] include_file = []
kill_delay = "0s" kill_delay = "0s"
log = "build-errors.log" log = "build-errors.log"

View file

@ -6,7 +6,7 @@ build:
GOOS=linux GOARCH=amd64 go build -o $(EXEC) GOOS=linux GOARCH=amd64 go build -o $(EXEC)
bundle: build bundle: build
tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ tar czf $(EXEC).tar.gz --exclude ".DS_Store" $(EXEC) admin/static/ public/
clean: clean:
rm $(EXEC) $(EXEC).tar.gz rm $(EXEC) $(EXEC).tar.gz

View file

@ -18,12 +18,12 @@ import (
func accountHandler(app *model.AppState) http.Handler { func accountHandler(app *model.AppState) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/totp-setup", totpSetupHandler(app)) mux.Handle("/account/totp-setup", totpSetupHandler(app))
mux.Handle("/totp-confirm", totpConfirmHandler(app)) mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) mux.Handle("/account/totp-delete", totpDeleteHandler(app))
mux.Handle("/password", changePasswordHandler(app)) mux.Handle("/account/password", changePasswordHandler(app))
mux.Handle("/delete", deleteAccountHandler(app)) mux.Handle("/account/delete", deleteAccountHandler(app))
return mux return mux
} }
@ -45,7 +45,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
} }
accountResponse struct { accountResponse struct {
Session *model.Session adminPageData
TOTPs []TOTP TOTPs []TOTP
} }
) )
@ -66,7 +66,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
session.Error = sessionError session.Error = sessionError
err = templates.AccountTemplate.Execute(w, accountResponse{ err = templates.AccountTemplate.Execute(w, accountResponse{
Session: session, adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
TOTPs: totps, TOTPs: totps,
}) })
if err != nil { if err != nil {
@ -170,7 +170,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
} }
type totpConfirmData struct { type totpConfirmData struct {
Session *model.Session adminPageData
TOTP *model.TOTP TOTP *model.TOTP
NameEscaped string NameEscaped string
QRBase64Image string QRBase64Image string
@ -179,13 +179,9 @@ type totpConfirmData struct {
func totpSetupHandler(app *model.AppState) http.Handler { func totpSetupHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
type totpSetupData struct {
Session *model.Session
}
session := r.Context().Value("session").(*model.Session) session := r.Context().Value("session").(*model.Session)
err := templates.TOTPSetupTemplate.Execute(w, totpSetupData{ Session: session }) err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session })
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -222,7 +218,9 @@ func totpSetupHandler(app *model.AppState) http.Handler {
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err) fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 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: adminPageData{ Path: r.URL.Path, Session: session },
})
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -237,7 +235,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
} }
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
Session: session, adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
TOTP: &totp, TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name), NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image, QRBase64Image: qrBase64Image,
@ -268,11 +266,6 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return 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) totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil { if err != nil {
@ -292,23 +285,22 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
} }
code := r.FormValue("totp")
confirmCode := controller.GenerateTOTP(totp.Secret, 0) confirmCode := controller.GenerateTOTP(totp.Secret, 0)
if code != confirmCode { confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) {
if code != confirmCodeOffset { session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Session: session, TOTP: totp,
TOTP: totp, NameEscaped: url.PathEscape(totp.Name),
NameEscaped: url.PathEscape(totp.Name), QRBase64Image: qrBase64Image,
QRBase64Image: qrBase64Image, })
}) if err != nil {
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
} }
return
} }
err = controller.ConfirmTOTP(app.DB, session.Account.ID, name) err = controller.ConfirmTOTP(app.DB, session.Account.ID, name)
@ -329,18 +321,23 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
func totpDeleteHandler(app *model.AppState) http.Handler { func totpDeleteHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodPost {
http.NotFound(w, r) http.NotFound(w, r)
return 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) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return return
} }
name := r.URL.Path[1:]
session := r.Context().Value("session").(*model.Session)
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil { if err != nil {

View file

@ -10,45 +10,77 @@ import (
"arimelody-web/model" "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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slices := strings.Split(r.URL.Path[1:], "/") session := r.Context().Value("session").(*model.Session)
id := slices[0]
artist, err := controller.GetArtist(app.DB, id) 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.Printf("WARN: Failed to fetch artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type ArtistsResponse struct {
adminPageData
Artists []*model.Artist
}
err = templates.ArtistsTemplate.Execute(w, ArtistsResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Artists: artists,
})
if err != nil {
fmt.Printf("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 err != nil {
if artist == nil { if artist == nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
fmt.Printf("Error rendering admin artist page for %s: %s\n", id, err) fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
if err != nil { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
type ArtistResponse struct { type ArtistResponse struct {
Session *model.Session adminPageData
Artist *model.Artist Artist *model.Artist
Credits []*model.Credit Credits []*model.Credit
} }
session := r.Context().Value("session").(*model.Session)
err = templates.EditArtistTemplate.Execute(w, ArtistResponse{ err = templates.EditArtistTemplate.Execute(w, ArtistResponse{
Session: session, adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Artist: artist, Artist: artist,
Credits: credits, Credits: credits,
}) })
if err != nil { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
} }

View file

@ -3,13 +3,9 @@ package admin
import ( import (
"context" "context"
"database/sql" "database/sql"
"embed"
"fmt" "fmt"
"mime"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath"
"strings" "strings"
"time" "time"
@ -17,10 +13,16 @@ import (
"arimelody-web/controller" "arimelody-web/controller"
"arimelody-web/log" "arimelody-web/log"
"arimelody-web/model" "arimelody-web/model"
"arimelody-web/view"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
type adminPageData struct {
Path string
Session *model.Session
}
func Handler(app *model.AppState) http.Handler { func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
@ -42,15 +44,30 @@ func Handler(app *model.AppState) http.Handler {
mux.Handle("/register", registerAccountHandler(app)) mux.Handle("/register", registerAccountHandler(app))
mux.Handle("/account", requireAccount(accountIndexHandler(app))) mux.Handle("/account", requireAccount(accountIndexHandler(app)))
mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app)))) mux.Handle("/account/", requireAccount(accountHandler(app)))
mux.Handle("/logs", requireAccount(logsHandler(app))) mux.Handle("/logs", requireAccount(logsHandler(app)))
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app)))) mux.Handle("/releases", requireAccount(serveReleases(app)))
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app)))) mux.Handle("/releases/", requireAccount(serveReleases(app)))
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app)))) mux.Handle("/artists", requireAccount(serveArtists(app)))
mux.Handle("/artists/", requireAccount(serveArtists(app)))
mux.Handle("/tracks", requireAccount(serveTracks(app)))
mux.Handle("/tracks/", requireAccount(serveTracks(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
}
requireAccount(
http.StripPrefix("/static",
view.ServeFiles("./admin/static"))).ServeHTTP(w, r)
}))
mux.Handle("/", requireAccount(AdminIndexHandler(app))) mux.Handle("/", requireAccount(AdminIndexHandler(app)))
@ -67,12 +84,18 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session) session := r.Context().Value("session").(*model.Session)
releases, err := controller.GetAllReleases(app.DB, false, 0, true) releases, err := controller.GetAllReleases(app.DB, false, 3, true)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return 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) artists, err := controller.GetAllArtists(app.DB)
if err != nil { if err != nil {
@ -80,6 +103,12 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return 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) tracks, err := controller.GetOrphanTracks(app.DB)
if err != nil { if err != nil {
@ -87,19 +116,31 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return 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 { type IndexData struct {
Session *model.Session adminPageData
Releases []*model.Release Releases []*model.Release
Artists []*model.Artist ReleaseCount int
Tracks []*model.Track Artists []*model.Artist
ArtistCount int
Tracks []*model.Track
TrackCount int
} }
err = templates.IndexTemplate.Execute(w, IndexData{ err = templates.IndexTemplate.Execute(w, IndexData{
Session: session, adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Releases: releases, Releases: releases,
ReleaseCount: releaseCount,
Artists: artists, Artists: artists,
ArtistCount: artistCount,
Tracks: tracks, Tracks: tracks,
TrackCount: trackCount,
}) })
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
@ -119,12 +160,8 @@ func registerAccountHandler(app *model.AppState) http.Handler {
return return
} }
type registerData struct {
Session *model.Session
}
render := func() { render := func() {
err := templates.RegisterTemplate.Execute(w, registerData{ Session: session }) err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
if err != nil { if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err) fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -229,12 +266,8 @@ func loginHandler(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session) session := r.Context().Value("session").(*model.Session)
type loginData struct {
Session *model.Session
}
render := func() { render := func() {
err := templates.LoginTemplate.Execute(w, loginData{ Session: session }) err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -345,12 +378,8 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
return return
} }
type loginTOTPData struct {
Session *model.Session
}
render := func() { render := func() {
err := templates.LoginTOTPTemplate.Execute(w, loginTOTPData{ Session: session }) err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -464,12 +493,14 @@ func requireAccount(next http.Handler) http.HandlerFunc {
}) })
} }
/*
//go:embed "static" //go:embed "static"
var staticFS embed.FS var staticFS embed.FS
func staticHandler() http.Handler { func staticHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(r.URL.Path))) uri := strings.TrimPrefix(r.URL.Path, "/static")
file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri)))
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -481,6 +512,7 @@ func staticHandler() http.Handler {
w.Write(file) w.Write(file)
}) })
} }
*/
func enforceSession(app *model.AppState, next http.Handler) http.Handler { func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -51,12 +51,12 @@ func logsHandler(app *model.AppState) http.Handler {
} }
type LogsResponse struct { type LogsResponse struct {
Session *model.Session adminPageData
Logs []*log.Log Logs []*log.Log
} }
err = templates.LogsTemplate.Execute(w, LogsResponse{ err = templates.LogsTemplate.Execute(w, LogsResponse{
Session: session, adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Logs: logs, Logs: logs,
}) })
if err != nil { if err != nil {

View file

@ -3,6 +3,7 @@ package admin
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os"
"strings" "strings"
"arimelody-web/admin/templates" "arimelody-web/admin/templates"
@ -10,11 +11,52 @@ import (
"arimelody-web/model" "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) { 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] 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 {
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: 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) session := r.Context().Value("session").(*model.Session)
release, err := controller.GetRelease(app.DB, releaseID, true) release, err := controller.GetRelease(app.DB, releaseID, true)
@ -23,13 +65,13 @@ func serveRelease(app *model.AppState) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err) fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
if len(slices) > 1 { if len(action) > 0 {
switch slices[1] { switch action {
case "editcredits": case "editcredits":
serveEditCredits(release).ServeHTTP(w, r) serveEditCredits(release).ServeHTTP(w, r)
return return
@ -57,16 +99,20 @@ func serveRelease(app *model.AppState) http.Handler {
} }
type ReleaseResponse struct { type ReleaseResponse struct {
Session *model.Session adminPageData
Release *model.Release Release *model.Release
} }
for i, track := range release.Tracks {
track.Number = i + 1
}
err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{ err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
Session: session, adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Release: release, Release: release,
}) })
if err != nil { if err != nil {
fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err) fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -77,7 +123,7 @@ func serveEditCredits(release *model.Release) http.Handler {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := templates.EditCreditsTemplate.Execute(w, release) err := templates.EditCreditsTemplate.Execute(w, release)
if err != nil { if err != nil {
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -87,7 +133,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err) fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -103,7 +149,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
Artists: artists, Artists: artists,
}) })
if err != nil { if err != nil {
fmt.Printf("Error rendering add credits component for %s: %s\n", release.ID, err) fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -114,7 +160,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
artistID := strings.Split(r.URL.Path, "/")[3] artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(app.DB, artistID) artist, err := controller.GetArtist(app.DB, artistID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err) fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -126,7 +172,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err = templates.NewCreditTemplate.Execute(w, artist) err = templates.NewCreditTemplate.Execute(w, artist)
if err != nil { if err != nil {
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -135,9 +181,9 @@ func serveNewCredit(app *model.AppState) http.Handler {
func serveEditLinks(release *model.Release) http.Handler { func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := templates.EditCreditsTemplate.Execute(w, release) err := templates.EditLinksTemplate.Execute(w, release)
if err != nil { if err != nil {
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -147,17 +193,11 @@ func serveEditTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
type editTracksData struct { type editTracksData struct { Release *model.Release }
Release *model.Release
Add func(a int, b int) int
}
err := templates.EditTracksTemplate.Execute(w, editTracksData{ err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release })
Release: release,
Add: func(a, b int) int { return a + b },
})
if err != nil { if err != nil {
fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err) fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -167,7 +207,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err) fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -183,7 +223,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
Tracks: tracks, Tracks: tracks,
}) })
if err != nil { if err != nil {
fmt.Printf("Error rendering add tracks component for %s: %s\n", release.ID, err) fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -194,7 +234,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
trackID := strings.Split(r.URL.Path, "/")[3] trackID := strings.Split(r.URL.Path, "/")[3]
track, err := controller.GetTrack(app.DB, trackID) track, err := controller.GetTrack(app.DB, trackID)
if err != nil { if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -206,7 +246,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err = templates.NewTrackTemplate.Execute(w, track) err = templates.NewTrackTemplate.Execute(w, track)
if err != nil { if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })

View file

@ -1,88 +1,266 @@
@import url("/style/prideflag.css"); @import url("/style/prideflag.css");
@import url("/font/inter/inter.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 { body {
width: 100%; width: calc(100% - 180px);
height: calc(100vh - 1em); height: calc(100vh - 1em);
margin: 0; margin: 0 0 0 180px;
padding: 0; padding: 0;
display: flex;
flex-direction: row;
font-family: "Inter", sans-serif; font-family: "Inter", sans-serif;
font-size: 16px; font-size: 16px;
color: var(--fg-0);
background: var(--bg-0);
color: #303030; transition: background .1s ease-out, color .1s ease-out;
background: #f0f0f0;
} }
nav { h1, h2, h3, h4, h5, h6 {
width: min(720px, calc(100% - 2em)); color: var(--fg-3);
height: 2em; }
margin: 1em auto;
header {
display: flex; 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; justify-content: left;
background: #f8f8f8; background-color: var(--bg-1);
border-radius: 4px; box-shadow: var(--shadow-md);
border: 1px solid #808080; transition: background .1s ease-out, color .1s ease-out;
user-select: none;
} }
nav .icon { nav .icon {
height: 100%; width: fit-content;
} height: fit-content;
nav .title { padding: 0;
width: auto; margin: 0 auto 1em auto;
height: 100%;
margin: 0 1em 0 0;
display: flex; display: flex;
line-height: 2em; border-radius: 100%;
text-decoration: none; box-shadow: var(--shadow-sm);
overflow: clip;
color: inherit; }
nav .icon img {
width: 3em;
height: 3em;
} }
.nav-item { .nav-item {
width: auto;
height: 100%;
margin: 0px;
padding: 0 1em;
display: flex; display: flex;
color: var(--fg-2);
line-height: 2em; line-height: 2em;
font-weight: 500;
transition: color .1s ease-out, background-color .1s ease-out;
} }
.nav-item:hover { .nav-item:hover {
background: #00000010; color: var(--bg-2);
background-color: var(--fg-2);
text-decoration: none; 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 { nav a {
padding: .2em 1em;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
width: 100%;
} }
nav #logout { nav a.active {
/* margin-left: auto; */ 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 { 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; margin: 0 auto;
padding: 1em; padding: 1em;
} }
main.dashboard {
width: 100%;
}
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color .1s ease-out, background-color .1s ease-out;
} }
/*
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
*/
a img.icon { img.icon {
height: .8em; height: .8em;
transition: filter .1s ease-out;
} }
code { code {
@ -94,7 +272,24 @@ code {
.cards {
width: 100%;
height: fit-content;
display: flex;
gap: 2em;
flex-wrap: wrap;
}
.card { .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; margin-bottom: 1em;
} }
@ -102,7 +297,7 @@ code {
margin: 0 0 .5em 0; margin: 0 0 .5em 0;
} }
.card-title { .card-header {
margin-bottom: 1em; margin-bottom: 1em;
display: flex; display: flex;
gap: 1em; gap: 1em;
@ -110,21 +305,31 @@ code {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.card-header h1,
.card-title h1, .card-header h2,
.card-title h2, .card-header h3 {
.card-title h3 {
margin: 0; 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-fill {
flex-grow: 1; flex-grow: 1;
} }
@media screen and (max-width: 520px) { .artists-group {
body { display: grid;
font-size: 12px; grid-template-columns: repeat(5, 1fr);
} gap: 1em;
} }
@ -133,17 +338,15 @@ code {
#error { #error {
margin: 0 0 1em 0; margin: 0 0 1em 0;
padding: 1em; padding: 1em;
border-radius: 4px; border-radius: 8px;
color: #101010;
background: #ffffff; background: #ffffff;
border: 1px solid #888;
} }
#message { #message {
background: #a9dfff; background: #a9dfff;
border-color: #599fdc;
} }
#error { #error {
background: #ffa9b8; background: #ffa9b8;
border-color: #dc5959;
} }
@ -152,52 +355,54 @@ a.delete:not(.button) {
color: #d22828; color: #d22828;
} }
button, .button { .button, button {
padding: .5em .8em; padding: .5em .8em;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
border-radius: 4px;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit; 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 { button:hover, .button:hover {
background: #fff; background: #fff;
border-color: #d0d0d0;
} }
button:active, .button:active { button:active, .button:active {
background: #d0d0d0; background: #d0d0d0;
border-color: #808080;
} }
.button, button {
color: inherit;
}
.button.new, button.new { .button.new, button.new {
background: #c4ff6a; color: var(--col-on-new);
border-color: #84b141; background: var(--col-new);
} }
.button.save, button.save { .button.save, button.save {
background: #6fd7ff; color: var(--col-on-save);
border-color: #6f9eb0; background: var(--col-save);
} }
.button.delete, button.delete { .button.delete, button.delete {
background: #ff7171; color: var(--col-on-delete);
border-color: #7d3535; background: var(--col-delete);
} }
.button:hover, button:hover { .button:hover, button:hover {
background: #fff; color: var(--bg-3);
border-color: #d0d0d0; background: var(--fg-3);
} }
.button:active, button:active { .button:active, button:active {
background: #d0d0d0; color: var(--bg-2);
border-color: #808080; background: var(--fg-0);
} }
.button[disabled], button[disabled] { .button[disabled], button[disabled] {
background: #d0d0d0 !important; color: var(--fg-0) !important;
border-color: #808080 !important; background: var(--bg-3) !important;
opacity: .5; opacity: .5;
cursor: not-allowed !important; cursor: default !important;
} }
@ -205,24 +410,79 @@ button:active, .button:active {
form { form {
width: 100%; width: 100%;
display: block; display: block;
color: var(--fg-0);
} }
form label { form label {
width: 100%; width: 100%;
margin: 1rem 0 .5rem 0; margin: 1rem 0 .5rem 0;
display: block; display: block;
color: #10101080;
} }
form input { form input[type="text"],
margin: .5rem 0; form input[type="password"] {
padding: .3rem .5rem; width: 16em;
max-width: 100%;
margin: .5em 0;
padding: .3em .5em;
display: block; display: block;
border-radius: 4px; border-radius: 4px;
border: 1px solid #808080; border: 1px solid #808080;
font-size: inherit; font-size: inherit;
font-family: inherit; font-family: inherit;
color: inherit; color: inherit;
background-color: var(--bg-0);
} }
input[disabled] { input[disabled] {
opacity: .5; opacity: .5;
cursor: not-allowed; 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);
}
}

View file

@ -69,3 +69,28 @@ export function makeMagicList(container, itemSelector, callback) {
if (callback) 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");
})
});

27
admin/static/artists.css Normal file
View file

@ -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;
}

7
admin/static/artists.js Normal file
View file

@ -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"))
});
});

View file

@ -11,8 +11,11 @@ label {
align-items: center; align-items: center;
color: inherit; color: inherit;
} }
input { form#change-password input,
width: min(20rem, calc(100% - 1rem)); form#delete-account input {
width: 20em;
min-width: auto;
max-width: calc(100% - 1em - 2px);
margin: .5rem 0; margin: .5rem 0;
padding: .3rem .5rem; padding: .3rem .5rem;
display: block; display: block;
@ -25,12 +28,14 @@ input {
.mfa-device { .mfa-device {
padding: .75em; padding: .75em;
background: #f8f8f8f8;
border: 1px solid #808080;
border-radius: 8px;
margin-bottom: .5em; margin-bottom: .5em;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
color: var(--fg-3);
background: var(--bg-2);
box-shadow: var(--shadow-md);
border-radius: 16px;
} }
.mfa-device div { .mfa-device div {
@ -46,3 +51,14 @@ input {
.mfa-device .mfa-device-name { .mfa-device .mfa-device-name {
font-weight: bold; font-weight: bold;
} }
.mfa-device form input {
display: none !important;
}
.mfa-actions {
display: flex;
flex-direction: row;
gap: .5em;
flex-wrap: wrap;
}

View file

@ -1,7 +1,3 @@
h1 {
margin: 0 0 1em 0;
}
#artist { #artist {
margin-bottom: 1em; margin-bottom: 1em;
padding: 1.5em; padding: 1.5em;
@ -9,9 +5,9 @@ h1 {
flex-direction: row; flex-direction: row;
gap: 1.2em; gap: 1.2em;
border-radius: 8px; border-radius: 16px;
background: #f8f8f8f8; background: var(--bg-2);
border: 1px solid #808080; box-shadow: var(--shadow-md);
} }
.artist-avatar { .artist-avatar {
@ -27,7 +23,8 @@ h1 {
cursor: pointer; cursor: pointer;
} }
.artist-avatar #remove-avatar { .artist-avatar #remove-avatar {
padding: .3em .4em; margin-top: .5em;
padding: .3em .6em;
} }
.artist-info { .artist-info {
@ -53,8 +50,8 @@ input[type="text"] {
font-family: inherit; font-family: inherit;
font-weight: inherit; font-weight: inherit;
color: inherit; color: inherit;
background: #ffffff; background: var(--bg-0);
border: 1px solid transparent; border: none;
border-radius: 4px; border-radius: 4px;
outline: none; outline: none;
} }
@ -74,7 +71,7 @@ input[type="text"]:focus {
justify-content: right; justify-content: right;
} }
.card-title a.button { .card-header a.button {
text-decoration: none; text-decoration: none;
} }
@ -85,9 +82,17 @@ input[type="text"]:focus {
flex-direction: row; flex-direction: row;
gap: 1em; gap: 1em;
align-items: center; align-items: center;
background: #f8f8f8;
border-radius: 8px; border-radius: 16px;
border: 1px solid #808080; background: var(--bg-2);
box-shadow: var(--shadow-md);
cursor: pointer;
transition: background .1s;
}
.credit:hover {
background: var(--bg-1);
} }
.release-artwork { .release-artwork {
@ -96,8 +101,14 @@ input[type="text"]:focus {
border-radius: 4px; border-radius: 4px;
} }
.credit-info {
overflow: hidden;
}
.credit-info h3, .credit-info h3,
.credit-info p { .credit-info p {
margin: 0; margin: 0;
font-size: .9em; font-size: .9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }

View file

@ -1,3 +1,5 @@
import { hijackClickEvent } from "./admin.js";
const artistID = document.getElementById("artist").dataset.id; const artistID = document.getElementById("artist").dataset.id;
const nameInput = document.getElementById("name"); const nameInput = document.getElementById("name");
const avatarImg = document.getElementById("avatar"); const avatarImg = document.getElementById("avatar");
@ -77,3 +79,9 @@ removeAvatarBtn.addEventListener("click", () => {
avatarImg.src = "/img/default-avatar.png" avatarImg.src = "/img/default-avatar.png"
saveBtn.disabled = false; saveBtn.disabled = false;
}); });
document.addEventListener('readystatechange', () => {
document.querySelectorAll('#releases .credit').forEach(el => {
hijackClickEvent(el, el.querySelector('.credit-name a'));
});
});

View file

@ -12,8 +12,10 @@ input[type="text"] {
gap: 1.2em; gap: 1.2em;
border-radius: 8px; border-radius: 8px;
background: #f8f8f8f8; background: var(--bg-2);
border: 1px solid #808080; box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out;
} }
.release-artwork { .release-artwork {
@ -29,7 +31,9 @@ input[type="text"] {
cursor: pointer; cursor: pointer;
} }
.release-artwork #remove-artwork { .release-artwork #remove-artwork {
padding: .3em .4em; margin-top: .5em;
padding: .3em .6em;
background: var(--bg-3);
} }
.release-info { .release-info {
@ -54,17 +58,17 @@ input[type="text"] {
background: transparent; background: transparent;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
transition: background .1s ease-out, border-color .1s ease-out;
} }
#title:hover { #title:hover {
background: #ffffff; background: var(--bg-3);
border-color: #80808080; border-color: var(--fg-0);
} }
#title:active, #title:active,
#title:focus { #title:focus {
background: #ffffff; background: var(--bg-3);
border-color: #808080;
} }
.release-title small { .release-title small {
@ -75,19 +79,21 @@ input[type="text"] {
width: 100%; width: 100%;
margin: .5em 0; margin: .5em 0;
border-collapse: collapse; border-collapse: collapse;
color: var(--fg-2);
} }
.release-info table td { .release-info table td {
padding: .2em; 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 { .release-info table tr td:first-child {
vertical-align: top; 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) select:hover,
.release-info table tr td:not(:first-child) input:hover, .release-info table tr td:not(:first-child) input:hover,
.release-info table tr td:not(:first-child) textarea:hover { .release-info table tr td:not(:first-child) textarea:hover {
background: #e8e8e8; background: var(--bg-3);
cursor: pointer; cursor: pointer;
} }
.release-info table td select, .release-info table td select,
@ -115,13 +121,25 @@ input[type="text"] {
gap: .5em; gap: .5em;
flex-direction: row; flex-direction: row;
justify-content: right; justify-content: right;
color: var(--fg-3);
}
.release-actions button,
.release-actions .button {
color: var(--fg-2);
background: var(--bg-3);
} }
dialog { dialog {
width: min(720px, calc(100% - 2em)); width: min(720px, calc(100% - 2em));
padding: 2em; padding: 2em;
border: 1px solid #101010; border: none;
border-radius: 8px; 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 { dialog header {
@ -144,7 +162,7 @@ dialog div.dialog-actions {
gap: .5em; gap: .5em;
} }
.card-title a.button { .card-header a.button {
text-decoration: none; text-decoration: none;
} }
@ -152,7 +170,7 @@ dialog div.dialog-actions {
* RELEASE CREDITS * RELEASE CREDITS
*/ */
.card.credits .credit { #credits .credit {
margin-bottom: .5em; margin-bottom: .5em;
padding: .5em; padding: .5em;
display: flex; display: flex;
@ -160,28 +178,47 @@ dialog div.dialog-actions {
align-items: center; align-items: center;
gap: 1em; gap: 1em;
border-radius: 8px; border-radius: 16px;
background: #f8f8f8f8; background-color: var(--bg-2);
border: 1px solid #808080; 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; margin: 0;
} }
.card.credits .credit .artist-avatar { #credits .credit .artist-avatar {
border-radius: 8px; border-radius: 12px;
} }
.card.credits .credit .artist-name { #credits .credit .artist-name {
color: var(--fg-3);
font-weight: bold; font-weight: bold;
} }
.card.credits .credit .artist-role small { #credits .credit .artist-role small {
font-size: inherit; font-size: inherit;
opacity: .66; 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 { #editcredits ul {
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -197,8 +234,8 @@ dialog div.dialog-actions {
gap: 1em; gap: 1em;
border-radius: 8px; border-radius: 8px;
background: #f8f8f8f8; background: var(--bg-2);
border: 1px solid #808080; box-shadow: var(--shadow-md);
} }
#editcredits .credit { #editcredits .credit {
@ -232,6 +269,7 @@ dialog div.dialog-actions {
margin: 0; margin: 0;
display: flex; display: flex;
align-items: center; align-items: center;
color: inherit;
} }
#editcredits .credit .credit-info .credit-attribute input[type="text"] { #editcredits .credit .credit-info .credit-attribute input[type="text"] {
@ -239,15 +277,17 @@ dialog div.dialog-actions {
padding: .2em .4em; padding: .2em .4em;
flex-grow: 1; flex-grow: 1;
font-family: inherit; font-family: inherit;
border: 1px solid #8888; border: none;
border-radius: 4px; border-radius: 4px;
color: inherit; color: var(--fg-2);
background: var(--bg-0);
} }
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { #editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
margin: 0 .3em; margin: 0 .3em;
} }
#editcredits .credit .artist-name { #editcredits .credit .artist-name {
color: var(--fg-2);
font-weight: bold; font-weight: bold;
} }
@ -256,8 +296,12 @@ dialog div.dialog-actions {
opacity: .66; opacity: .66;
} }
#editcredits .credit button.delete { #editcredits .credit .delete {
margin-left: auto; margin-right: .5em;
cursor: pointer;
}
#editcredits .credit .delete:hover {
text-decoration: underline;
} }
#addcredit ul { #addcredit ul {
@ -289,24 +333,39 @@ dialog div.dialog-actions {
* RELEASE LINKS * RELEASE LINKS
*/ */
.card.links { #links ul {
padding: 0;
display: flex; display: flex;
gap: .2em; 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 background-color: #8cff83
} }
.card.links a.button[data-name="apple music"] { #links a.button[data-name="apple music"] {
color: #101010;
background-color: #8cd9ff background-color: #8cd9ff
} }
.card.links a.button[data-name="soundcloud"] { #links a.button[data-name="soundcloud"] {
color: #101010;
background-color: #fdaa6d background-color: #fdaa6d
} }
.card.links a.button[data-name="youtube"] { #links a.button[data-name="youtube"] {
color: #101010;
background-color: #ff6e6e background-color: #ff6e6e
} }
@ -389,62 +448,6 @@ dialog div.dialog-actions {
outline: 1px solid #808080; 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 { #edittracks ul {
padding: 0; padding: 0;
list-style: none; list-style: none;
@ -496,15 +499,17 @@ dialog div.dialog-actions {
padding: .5em; padding: .5em;
display: flex; display: flex;
gap: .5em; gap: .5em;
background-color: var(--bg-0);
cursor: pointer; cursor: pointer;
transition: background-color .1s ease-out, color .1s ease-out;
} }
#addtrack ul li.new-track:nth-child(even) { #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 { #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) { @media only screen and (max-width: 1105px) {

View file

@ -1,3 +1,5 @@
import { hijackClickEvent } from "./admin.js";
const releaseID = document.getElementById("release").dataset.id; const releaseID = document.getElementById("release").dataset.id;
const titleInput = document.getElementById("title"); const titleInput = document.getElementById("title");
const artworkImg = document.getElementById("artwork"); const artworkImg = document.getElementById("artwork");
@ -96,3 +98,9 @@ removeArtworkBtn.addEventListener("click", () => {
artworkData = ""; artworkData = "";
saveBtn.disabled = false; saveBtn.disabled = false;
}); });
document.addEventListener("readystatechange", () => {
document.querySelectorAll("#credits .credit").forEach(el => {
hijackClickEvent(el, el.querySelector(".artist-name a"));
});
});

View file

@ -1,9 +1,5 @@
@import url("/admin/static/release-list-item.css"); @import url("/admin/static/release-list-item.css");
h1 {
margin: 0 0 .5em 0;
}
#track { #track {
margin-bottom: 1em; margin-bottom: 1em;
padding: .5em 1.5em 1.5em 1.5em; padding: .5em 1.5em 1.5em 1.5em;
@ -11,9 +7,9 @@ h1 {
flex-direction: row; flex-direction: row;
gap: 1.2em; gap: 1.2em;
border-radius: 8px; border-radius: 16px;
background: #f8f8f8f8; background: var(--bg-2);
border: 1px solid #808080; box-shadow: var(--shadow-md);
} }
.track-info { .track-info {
@ -49,7 +45,8 @@ h1 {
font-weight: inherit; font-weight: inherit;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
border: 1px solid transparent; background: var(--bg-0);
border: none;
border-radius: 4px; border-radius: 4px;
outline: none; outline: none;
color: inherit; color: inherit;

View file

@ -1,82 +0,0 @@
@import url("/admin/static/release-list-item.css");
.artist {
margin-bottom: .5em;
padding: .5em;
display: flex;
flex-direction: row;
align-items: center;
gap: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.artist:hover {
text-decoration: hover;
}
.artist-avatar {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 100%;
}
.track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.track p {
margin: 0;
}
.card h2.track-title {
margin: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.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;
}

View file

@ -12,7 +12,7 @@ newReleaseBtn.addEventListener("click", event => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({id}) body: JSON.stringify({id})
}).then(res => { }).then(res => {
if (res.ok) location = "/admin/release/" + id; if (res.ok) location = "/admin/releases/" + id;
else { else {
res.text().then(err => { res.text().then(err => {
alert("Request failed: " + err); alert("Request failed: " + err);
@ -37,7 +37,7 @@ newArtistBtn.addEventListener("click", event => {
}).then(res => { }).then(res => {
res.text().then(text => { res.text().then(text => {
if (res.ok) { if (res.ok) {
location = "/admin/artist/" + id; location = "/admin/artists/" + id;
} else { } else {
alert("Request failed: " + text); alert("Request failed: " + text);
console.error(text); console.error(text);
@ -61,7 +61,7 @@ newTrackBtn.addEventListener("click", event => {
}).then(res => { }).then(res => {
res.text().then(text => { res.text().then(text => {
if (res.ok) { if (res.ok) {
location = "/admin/track/" + text; location = "/admin/tracks/" + text;
} else { } else {
alert("Request failed: " + text); alert("Request failed: " + text);
console.error(text); console.error(text);

View file

@ -2,8 +2,14 @@ main {
width: min(1080px, calc(100% - 2em))!important width: min(1080px, calc(100% - 2em))!important
} }
form { form#search-form {
width: calc(100% - 2em);
margin: 1em 0; margin: 1em 0;
padding: 1em;
border-radius: 16px;
color: var(--fg-0);
background: var(--bg-2);
box-shadow: var(--shadow-md);
} }
div#search { div#search {
@ -12,24 +18,25 @@ div#search {
#search input { #search input {
margin: 0; margin: 0;
padding: .3em .8em;
flex-grow: 1; flex-grow: 1;
border: none;
border-right: none; border-radius: 16px;
border-top-right-radius: 0; color: var(--fg-1);
border-bottom-right-radius: 0; background: var(--bg-0);
box-shadow: var(--shadow-sm);
} }
#search button { #search button {
padding: 0 .5em; margin-left: .5em;
padding: 0 .8em;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
} }
form #filters p { form #filters p {
margin: .5em 0 0 0; margin: .5em 0 0 0;
} }
form #filters label { form #filters label {
color: inherit;
display: inline; display: inline;
} }
form #filters input { form #filters input {
@ -39,6 +46,15 @@ form #filters input {
#logs { #logs {
width: 100%; width: 100%;
overflow: scroll;
}
@media screen and (max-width: 720px) {
#logs {
font-size: 12px;
}
}
#logs table{
border-collapse: collapse; border-collapse: collapse;
} }
@ -57,6 +73,10 @@ form #filters input {
padding: .4em .8em; padding: .4em .8em;
} }
#logs .log {
color: var(--fg-2);
}
td, th { td, th {
width: 1%; width: 1%;
text-align: left; text-align: left;
@ -74,13 +94,14 @@ td.log-content {
white-space: collapse; white-space: collapse;
} }
.log:hover { #logs .log:hover {
background: #fff8; background: color-mix(in srgb, var(--fg-3) 10%, transparent);
} }
.log.warn { #logs .log.warn {
background: #ffe86a; color: var(--col-on-warn);
background: var(--col-warn);
} }
.log.warn:hover { #logs .log.warn:hover {
background: #ffec81; background: var(--col-warn-hover);
} }

View file

@ -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;
}

80
admin/static/releases.css Normal file
View file

@ -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;
}

127
admin/static/tracks.css Normal file
View file

@ -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;
}
*/

View file

@ -0,0 +1,26 @@
{{define "head"}}
<title>Artists - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/artists.css">
{{end}}
{{define "content"}}
<main>
<header>
<h1>Artists <small>({{len .Artists}} total)</small></h2>
<a class="button new" id="create-artist">Create New</a>
</header>
{{if .Artists}}
<div class="artists-group">
{{range .Artists}}
{{block "artist" .}}{{end}}
{{end}}
</div>
{{else}}
<p>There are no artists.</p>
{{end}}
</main>
<script type="module" src="/admin/static/artists.js"></script>
{{end}}

View file

@ -0,0 +1,6 @@
{{define "artist"}}
<div class="artist">
<img src="{{.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<a href="/admin/artists/{{.ID}}" class="artist-name">{{.Name}}</a>
</div>
{{end}}

View file

@ -7,7 +7,7 @@
{{range $Artist := .Artists}} {{range $Artist := .Artists}}
<li class="new-artist" <li class="new-artist"
data-id="{{$Artist.ID}}" data-id="{{$Artist.ID}}"
hx-get="/admin/release/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}" hx-get="/admin/releases/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
hx-target="#editcredits ul" hx-target="#editcredits ul"
hx-swap="beforeend" hx-swap="beforeend"
> >

View file

@ -3,8 +3,8 @@
<h2>Editing: Credits</h2> <h2>Editing: Credits</h2>
<a id="add-credit" <a id="add-credit"
class="button new" class="button new"
href="/admin/release/{{.ID}}/addcredit" href="/admin/releases/{{.ID}}/addcredit"
hx-get="/admin/release/{{.ID}}/addcredit" hx-get="/admin/releases/{{.ID}}/addcredit"
hx-target="body" hx-target="body"
hx-swap="beforeend" hx-swap="beforeend"
>Add</a> >Add</a>

View file

@ -5,7 +5,7 @@
</div> </div>
<div class="release-info"> <div class="release-info">
<h3 class="release-title"> <h3 class="release-title">
<a href="/admin/release/{{.ID}}">{{.Title}}</a> <a href="/admin/releases/{{.ID}}">{{.Title}}</a>
<small> <small>
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span> <span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
{{if not .Visible}}(hidden){{end}} {{if not .Visible}}(hidden){{end}}
@ -13,9 +13,9 @@
</h3> </h3>
<p class="release-artists">{{.PrintArtists true true}}</p> <p class="release-artists">{{.PrintArtists true true}}</p>
<p class="release-type-single">{{.ReleaseType}} <p class="release-type-single">{{.ReleaseType}}
(<a href="/admin/release/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p> (<a href="/admin/releases/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
<div class="release-actions"> <div class="release-actions">
<a href="/admin/release/{{.ID}}">Edit</a> <a href="/admin/releases/{{.ID}}">Edit</a>
<a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a> <a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
</div> </div>
</div> </div>

View file

@ -8,7 +8,7 @@
</li> </li>
<li class="new-track" <li class="new-track"
data-id="{{$Track.ID}}" data-id="{{$Track.ID}}"
hx-get="/admin/release/{{$.ReleaseID}}/newtrack/{{$Track.ID}}" hx-get="/admin/releases/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
hx-target="#edittracks ul" hx-target="#edittracks ul"
hx-swap="beforeend" hx-swap="beforeend"
> >

View file

@ -3,8 +3,8 @@
<h2>Editing: Tracks</h2> <h2>Editing: Tracks</h2>
<a id="add-track" <a id="add-track"
class="button new" class="button new"
href="/admin/release/{{.Release.ID}}/addtrack" href="/admin/releases/{{.Release.ID}}/addtrack"
hx-get="/admin/release/{{.Release.ID}}/addtrack" hx-get="/admin/releases/{{.Release.ID}}/addtrack"
hx-target="body" hx-target="body"
hx-swap="beforeend" hx-swap="beforeend"
>Add</a> >Add</a>

View file

@ -0,0 +1,24 @@
{{define "track"}}
<div class="track" data-id="{{.ID}}">
<h2 class="track-title">
{{if .Number}}
<span class="track-number">{{.Number}}</span>
{{end}}
<a href="/admin/tracks/{{.ID}}">{{.Title}}</a>
</h2>
<h3>Description</h3>
{{if .Description}}
<p class="track-description">{{.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
<h3>Lyrics</h3>
{{if .Lyrics}}
<p class="track-lyrics">{{.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}

View file

@ -14,10 +14,10 @@
{{end}} {{end}}
<h1>Account Settings ({{.Session.Account.Username}})</h1> <h1>Account Settings ({{.Session.Account.Username}})</h1>
<div class="card-title">
<h2>Change Password</h2>
</div>
<div class="card"> <div class="card">
<div class="card-header">
<h2>Change Password</h2>
</div>
<form action="/admin/account/password" method="POST" id="change-password"> <form action="/admin/account/password" method="POST" id="change-password">
<label for="current-password">Current Password</label> <label for="current-password">Current Password</label>
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required> <input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
@ -28,14 +28,16 @@
<label for="confirm-password">Confirm Password</label> <label for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" value="" autocomplete="new-password" required> <input type="password" id="confirm-password" value="" autocomplete="new-password" required>
<br>
<button type="submit" class="save">Change Password</button> <button type="submit" class="save">Change Password</button>
</form> </form>
</div> </div>
<div class="card-title">
<h2>MFA Devices</h2>
</div>
<div class="card mfa-devices"> <div class="card mfa-devices">
<div class="card-header">
<h2>MFA Devices</h2>
</div>
{{if .TOTPs}} {{if .TOTPs}}
{{range .TOTPs}} {{range .TOTPs}}
<div class="mfa-device"> <div class="mfa-device">
@ -44,7 +46,10 @@
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p> <p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
</div> </div>
<div> <div>
<a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a> <form method="POST" action="/admin/account/totp-delete">
<input type="text" name="totp-name" value="{{.TOTP.Name}}" hidden>
<button type="submit" class="delete">Delete</button>
</form>
</div> </div>
</div> </div>
{{end}} {{end}}
@ -52,28 +57,30 @@
<p>You have no MFA devices.</p> <p>You have no MFA devices.</p>
{{end}} {{end}}
<div> <div class="mfa-actions">
<button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button> <button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button>
<a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a> <a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a>
</div> </div>
</div> </div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger"> <div class="card danger">
<div class="card-header">
<h2>Danger Zone</h2>
</div>
<p> <p>
Clicking the button below will delete your account. Clicking the button below will delete your account.
This action is <strong>irreversible</strong>. This action is <strong>irreversible</strong>.
You will need to enter your password and TOTP below. You will need to enter your password and TOTP below.
</p> </p>
<form action="/admin/account/delete" method="POST"> <form action="/admin/account/delete" method="POST" id="delete-account">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required> <input type="password" name="password" value="" autocomplete="current-password" required>
<label for="totp">TOTP</label> <label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required> <input type="text" name="totp" value="" autocomplete="one-time-code" required>
<br>
<button type="submit" class="delete">Delete Account</button> <button type="submit" class="delete">Delete Account</button>
</form> </form>
</div> </div>

View file

@ -2,6 +2,7 @@
<title>Editing {{.Artist.Name}} - ari melody 💫</title> <title>Editing {{.Artist.Name}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon"> <link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-artist.css"> <link rel="stylesheet" href="/admin/static/edit-artist.css">
<link rel="stylesheet" href="/admin/static/artists.css">
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@ -29,16 +30,16 @@
</div> </div>
</div> </div>
<div class="card-title"> <div class="card" id="releases">
<h2>Featured in</h2> <div class="card-header">
</div> <h2>Featured in</h2>
<div class="card releases"> </div>
{{if .Credits}} {{if .Credits}}
{{range .Credits}} {{range .Credits}}
<div class="credit"> <div class="credit">
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork"> <img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<div class="credit-info"> <div class="credit-info">
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3> <h3 class="credit-name"><a href="/admin/releases/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<p class="credit-artists">{{.Release.PrintArtists true true}}</p> <p class="credit-artists">{{.Release.PrintArtists true true}}</p>
<p class="artist-role"> <p class="artist-role">
Role: {{.Role}} Role: {{.Role}}
@ -54,10 +55,10 @@
{{end}} {{end}}
</div> </div>
<div class="card-title"> <div class="card" id="danger">
<h2>Danger Zone</h2> <div class="card-header">
</div> <h2>Danger Zone</h2>
<div class="card danger"> </div>
<p> <p>
Clicking the button below will delete this artist. Clicking the button below will delete this artist.
This action is <strong>irreversible</strong>. This action is <strong>irreversible</strong>.

View file

@ -2,10 +2,13 @@
<title>Editing {{.Release.Title}} - ari melody 💫</title> <title>Editing {{.Release.Title}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon"> <link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-release.css"> <link rel="stylesheet" href="/admin/static/edit-release.css">
<link rel="stylesheet" href="/admin/static/releases.css">
<link rel="stylesheet" href="/admin/static/tracks.css">
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <main>
<h1>Editing {{.Release.Title}}</h1>
<div id="release" data-id="{{.Release.ID}}"> <div id="release" data-id="{{.Release.ID}}">
<div class="release-artwork"> <div class="release-artwork">
@ -97,21 +100,21 @@
</div> </div>
</div> </div>
<div class="card-title"> <div class="card" id="credits">
<h2>Credits ({{len .Release.Credits}})</h2> <div class="card-header">
<a class="button edit" <h2>Credits <small>({{len .Release.Credits}} total)</small></h2>
href="/admin/release/{{.Release.ID}}/editcredits" <a class="button edit"
hx-get="/admin/release/{{.Release.ID}}/editcredits" href="/admin/releases/{{.Release.ID}}/editcredits"
hx-target="body" hx-get="/admin/releases/{{.Release.ID}}/editcredits"
hx-swap="beforeend" hx-target="body"
>Edit</a> hx-swap="beforeend"
</div> >Edit</a>
<div class="card credits"> </div>
{{range .Release.Credits}} {{range .Release.Credits}}
<div class="credit"> <div class="credit">
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar"> <img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<div class="credit-info"> <div class="credit-info">
<p class="artist-name"><a href="/admin/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p> <p class="artist-name"><a href="/admin/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-role"> <p class="artist-role">
{{.Role}} {{.Role}}
{{if .Primary}} {{if .Primary}}
@ -126,59 +129,42 @@
{{end}} {{end}}
</div> </div>
<div class="card-title"> <div class="card" id="links">
<h2>Links ({{len .Release.Links}})</h2> <div class="card-header">
<a class="button edit" <h2>Links ({{len .Release.Links}})</h2>
href="/admin/release/{{.Release.ID}}/editlinks" <a class="button edit"
hx-get="/admin/release/{{.Release.ID}}/editlinks" href="/admin/releases/{{.Release.ID}}/editlinks"
hx-target="body" hx-get="/admin/releases/{{.Release.ID}}/editlinks"
hx-swap="beforeend" hx-target="body"
>Edit</a> hx-swap="beforeend"
</div> >Edit</a>
<div class="card links">
{{range .Release.Links}}
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
{{end}}
</div>
<div class="card-title" id="tracks">
<h2>Tracklist ({{len .Release.Tracks}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/edittracks"
hx-get="/admin/release/{{.Release.ID}}/edittracks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card tracks">
{{range $i, $track := .Release.Tracks}}
<div class="track" data-id="{{$track.ID}}">
<h2 class="track-title">
<span class="track-number">{{.Add $i 1}}</span>
<a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a>
</h2>
<h3>Description</h3>
{{if $track.Description}}
<p class="track-description">{{$track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
<h3>Lyrics</h3>
{{if $track.Lyrics}}
<p class="track-lyrics">{{$track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div> </div>
<ul>
{{range .Release.Links}}
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
{{end}}
</ul>
</div>
<div class="card" id="tracks">
<div class="card-header" id="tracks">
<h2>Tracklist ({{len .Release.Tracks}})</h2>
<a class="button edit"
href="/admin/releases/{{.Release.ID}}/edittracks"
hx-get="/admin/releases/{{.Release.ID}}/edittracks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
{{range $i, $track := .Release.Tracks}}
{{block "track" .}}{{end}}
{{end}} {{end}}
</div> </div>
<div class="card-title"> <div class="card" id="danger">
<h2>Danger Zone</h2> <div class="card-header">
</div> <h2>Danger Zone</h2>
<div class="card danger"> </div>
<p> <p>
Clicking the button below will delete this release. Clicking the button below will delete this release.
This action is <strong>irreversible</strong>. This action is <strong>irreversible</strong>.

View file

@ -2,6 +2,8 @@
<title>Editing Track - ari melody 💫</title> <title>Editing Track - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-track.css"> <link rel="stylesheet" href="/admin/static/edit-track.css">
<link rel="stylesheet" href="/admin/static/tracks.css">
<link rel="stylesheet" href="/admin/static/releases.css">
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@ -39,10 +41,10 @@
</div> </div>
</div> </div>
<div class="card-title">
<h2>Featured in</h2>
</div>
<div class="card releases"> <div class="card releases">
<div class="card-header">
<h2>Featured in</h2>
</div>
{{if .Releases}} {{if .Releases}}
{{range .Releases}} {{range .Releases}}
{{block "release" .}}{{end}} {{block "release" .}}{{end}}
@ -52,10 +54,10 @@
{{end}} {{end}}
</div> </div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger"> <div class="card danger">
<div class="card-header">
<h2>Danger Zone</h2>
</div>
<p> <p>
Clicking the button below will delete this track. Clicking the button below will delete this track.
This action is <strong>irreversible</strong>. This action is <strong>irreversible</strong>.

View file

@ -1,72 +1,61 @@
{{define "head"}} {{define "head"}}
<title>Admin - ari melody 💫</title> <title>Admin - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/index.css"> <link rel="stylesheet" href="/admin/static/releases.css">
<link rel="stylesheet" href="/admin/static/artists.css">
<link rel="stylesheet" href="/admin/static/tracks.css">
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <main class="dashboard">
<h1>Dashboard</h1>
<div class="card-title"> <div class="cards">
<h1>Releases</h1> <div class="card" id="releases">
<a class="button new" id="create-release">Create New</a> <div class="card-header">
</div> <h2><a href="/admin/releases/">Releases</a> <small>({{.ReleaseCount}} total)</small></h2>
<div class="card releases"> <a class="button new" id="create-release">Create New</a>
{{range .Releases}} </div>
{{block "release" .}}{{end}} {{if .Artists}}
{{end}} {{range .Releases}}
{{if not .Releases}} {{block "release" .}}{{end}}
<p>There are no releases.</p>
{{end}}
</div>
<div class="card-title">
<h1>Artists</h1>
<a class="button new" id="create-artist">Create New</a>
</div>
<div class="card artists">
{{range $Artist := .Artists}}
<div class="artist">
<img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<a href="/admin/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a>
</div>
{{end}}
{{if not .Artists}}
<p>There are no artists.</p>
{{end}}
</div>
<div class="card-title">
<h1>Tracks</h1>
<a class="button new" id="create-track">Create New</a>
</div>
<div class="card tracks">
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
<br>
{{range $Track := .Tracks}}
<div class="track">
<h2 class="track-title">
<a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a>
</h2>
{{if $Track.Description}}
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}} {{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}} {{else}}
<p class="track-lyrics empty">There are no lyrics.</p> <p>There are no releases.</p>
{{end}}
</div>
<div class="card" id="artists">
<div class="card-header">
<h2><a href="/admin/artists/">Artists</a> <small>({{.ArtistCount}} total)</small></h2>
<a class="button new" id="create-artist">Create New</a>
</div>
{{if .Artists}}
<div class="artists-group">
{{range .Artists}}
{{block "artist" .}}{{end}}
{{end}}
</div>
{{else}}
<p>There are no artists.</p>
{{end}}
</div>
<div class="card" id="tracks">
<div class="card-header">
<h2><a href="/admin/tracks/">Tracks</a> <small>({{.TrackCount}} total)</small></h2>
<a class="button new" id="create-track">Create New</a>
</div>
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
<br>
{{range .Tracks}}
{{block "track" .}}{{end}}
{{end}} {{end}}
</div> </div>
{{end}}
{{if not .Artists}}
<p>There are no artists.</p>
{{end}}
</div> </div>
</main> </main>
<script type="module" src="/admin/static/admin.js"></script> <script type="module" src="/admin/static/artists.js"></script>
<script type="module" src="/admin/static/index.js"></script> <script type="module" src="/admin/static/index.js"></script>
{{end}} {{end}}

View file

@ -10,40 +10,57 @@
{{block "head" .}}{{end}} {{block "head" .}}{{end}}
<link rel="stylesheet" href="/admin/static/admin.css"> <link rel="stylesheet" href="/admin/static/admin.css">
<script type="module" src="/admin/static/admin.js"></script>
<script type="module" src="/script/vendor/htmx.min.js"></script> <script type="module" src="/script/vendor/htmx.min.js"></script>
</head> </head>
<body> <body>
<header> <header>
<nav> <nav id="navbar">
<img src="/img/favicon.png" alt="" class="icon"> <a href="/" class="nav icon" aria-label="ari melody" title="Return to Home">
<div class="nav-item"> <img src="/img/favicon.png" alt="" width="64" height="64">
<a href="/">ari melody</a> </a>
</div> <div class="nav-item{{if eq .Path "/"}} active{{end}}">
<div class="nav-item">
<a href="/admin">home</a> <a href="/admin">home</a>
</div> </div>
{{if .Session.Account}} {{if .Session.Account}}
<div class="nav-item"> <div class="nav-item{{if eq .Path "/logs"}} active{{end}}">
<a href="/admin/logs">logs</a> <a href="/admin/logs">logs</a>
</div> </div>
<hr>
<p class="section-label">music</p>
<div class="nav-item{{if hasPrefix .Path "/releases"}} active{{end}}">
<a href="/admin/releases/">releases</a>
</div>
<div class="nav-item{{if hasPrefix .Path "/artists"}} active{{end}}">
<a href="/admin/artists/">artists</a>
</div>
<div class="nav-item{{if hasPrefix .Path "/tracks"}} active{{end}}">
<a href="/admin/tracks/">tracks</a>
</div>
{{end}} {{end}}
<div class="flex-fill"></div> <div class="flex-fill"></div>
{{if .Session.Account}} {{if .Session.Account}}
<div class="nav-item"> <div class="nav-item{{if eq .Path "/account"}} active{{end}}">
<a href="/admin/account">account ({{.Session.Account.Username}})</a> <a href="/admin/account">account ({{.Session.Account.Username}})</a>
</div> </div>
<div class="nav-item"> <div class="nav-item">
<a href="/admin/logout" id="logout">log out</a> <a href="/admin/logout" id="logout">log out</a>
</div> </div>
{{else}} {{else}}
<div class="nav-item"> <div class="nav-item{{if eq .Path "/login"}} active{{end}}">
<a href="/admin/login" id="login">log in</a>
</div>
<div class="nav-item{{if eq .Path "/register"}} active{{end}}">
<a href="/admin/register" id="register">create account</a> <a href="/admin/register" id="register">create account</a>
</div> </div>
{{end}} {{end}}
</nav> </nav>
<button type="button" id="toggle-nav" aria-label="Navigation toggle">
<img src="/img/hamburger.svg" alt="">
</button>
</header> </header>
{{block "content" .}}{{end}} {{block "content" .}}{{end}}

View file

@ -1,7 +1,6 @@
{{define "head"}} {{define "head"}}
<title>Login - ari melody 💫</title> <title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style> <style>
form#login-totp { form#login-totp {
width: 100%; width: 100%;
@ -18,8 +17,15 @@ form button {
margin-top: 1rem; margin-top: 1rem;
} }
input { form input {
width: calc(100% - 1rem - 2px); width: calc(100% - 1rem - 2px) !important;
}
@media screen and (max-width: 720px) {
h1 {
margin-top: 3em;
text-align: center;
}
} }
</style> </style>
{{end}} {{end}}

View file

@ -1,8 +1,14 @@
{{define "head"}} {{define "head"}}
<title>Login - ari melody 💫</title> <title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style> <style>
@media screen and (max-width: 720px) {
h1 {
margin-top: 3em;
text-align: center;
}
}
form#login { form#login {
width: 100%; width: 100%;
display: flex; display: flex;
@ -18,8 +24,8 @@ form button {
margin-top: 1rem; margin-top: 1rem;
} }
input { form input {
width: calc(100% - 1rem - 2px); width: calc(100% - 1rem - 2px) !important;
} }
</style> </style>
{{end}} {{end}}

View file

@ -1,7 +1,6 @@
{{define "head"}} {{define "head"}}
<title>Audit Logs - ari melody 💫</title> <title>Audit Logs - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<link rel="stylesheet" href="/admin/static/logs.css"> <link rel="stylesheet" href="/admin/static/logs.css">
{{end}} {{end}}
@ -9,7 +8,7 @@
<main> <main>
<h1>Audit Logs</h1> <h1>Audit Logs</h1>
<form action="/admin/logs" method="GET"> <form action="/admin/logs" method="GET" id="search-form">
<div id="search"> <div id="search">
<input type="text" name="q" value="" placeholder="Filter by message..."> <input type="text" name="q" value="" placeholder="Filter by message...">
<button type="submit" class="save">Search</button> <button type="submit" class="save">Search</button>
@ -44,25 +43,27 @@
<hr> <hr>
<table id="logs"> <div id="logs">
<thead> <table>
<tr> <thead>
<th class="log-time">Time</th> <tr>
<th class="log-level">Level</th> <th class="log-time">Time</th>
<th class="log-type">Type</th> <th class="log-level">Level</th>
<th class="log-content">Message</th> <th class="log-type">Type</th>
</tr> <th class="log-content">Message</th>
</thead> </tr>
<tbody> </thead>
{{range .Logs}} <tbody>
<tr class="log {{lower (parseLevel .Level)}}"> {{range .Logs}}
<td class="log-time">{{prettyTime .CreatedAt}}</td> <tr class="log {{toLower (parseLevel .Level)}}">
<td class="log-level">{{parseLevel .Level}}</td> <td class="log-time">{{prettyTime .CreatedAt}}</td>
<td class="log-type">{{titleCase .Type}}</td> <td class="log-level">{{parseLevel .Level}}</td>
<td class="log-content">{{.Content}}</td> <td class="log-type">{{titleCase .Type}}</td>
</tr> <td class="log-content">{{.Content}}</td>
{{end}} </tr>
</tbody> {{end}}
</table> </tbody>
</table>
</div>
</main> </main>
{{end}} {{end}}

View file

@ -1,7 +1,6 @@
{{define "head"}} {{define "head"}}
<title>Register - ari melody 💫</title> <title>Register - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style> <style>
p a { p a {
color: #2a67c8; color: #2a67c8;

View file

@ -0,0 +1,24 @@
{{define "head"}}
<title>Releases - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/releases.css">
{{end}}
{{define "content"}}
<main>
<header>
<h1>Releases <small>({{len .Releases}} total)</small></h1>
<a class="button new" id="create-release">Create New</a>
</header>
{{if .Releases}}
<div id="releases">
{{range .Releases}}
{{block "release" .}}{{end}}
{{end}}
</div>
{{else}}
<p>There are no releases.</p>
{{end}}
</main>
{{end}}

View file

@ -1,7 +1,6 @@
{{define "head"}} {{define "head"}}
<title>TOTP Confirmation - ari melody 💫</title> <title>TOTP Confirmation - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style> <style>
.qr-code { .qr-code {
border: 1px solid #8888; border: 1px solid #8888;
@ -9,11 +8,20 @@
code { code {
user-select: all; user-select: all;
} }
#totp-setup input {
width: 3.8em;
min-width: auto;
font-size: 32px;
font-family: 'Monaspace Argon', monospace;
text-align: center;
}
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <main>
<h1>Two-Factor Authentication</h1>
{{if .Session.Error.Valid}} {{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p> <p id="error">{{html .Session.Error.String}}</p>
{{end}} {{end}}
@ -40,7 +48,14 @@ code {
<p><code>{{.TOTP.Secret}}</code></p> <p><code>{{.TOTP.Secret}}</code></p>
<label for="totp">TOTP:</label> <label for="totp">TOTP:</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus> <input type="text"
name="totp"
value=""
minlength="6"
maxlength="6"
autocomplete="one-time-code"
required
autofocus>
<button type="submit" class="new">Create</button> <button type="submit" class="new">Create</button>
</form> </form>

View file

@ -1,11 +1,12 @@
{{define "head"}} {{define "head"}}
<title>TOTP Setup - ari melody 💫</title> <title>TOTP Setup - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <main>
<h1>Two-Factor Authentication</h1>
{{if .Session.Error.Valid}} {{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p> <p id="error">{{html .Session.Error.String}}</p>
{{end}} {{end}}

View file

@ -0,0 +1,34 @@
{{define "head"}}
<title>Releases - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/tracks.css">
{{end}}
{{define "content"}}
<main>
<header>
<h1>Tracks <small>({{len .Tracks}} total)</small></h1>
<a class="button new" id="create-track">Create New</a>
</header>
<div id="tracks">
{{range $Track := .Tracks}}
<div class="track">
<h2 class="track-title">
<a href="/admin/tracks/{{$Track.ID}}">{{$Track.Title}}</a>
</h2>
{{if $Track.Description}}
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}
</div>
</main>
{{end}}

View file

@ -1,12 +1,12 @@
package templates package templates
import ( import (
"arimelody-web/log" "arimelody-web/log"
"fmt" _ "embed"
"html/template" "fmt"
"strings" "html/template"
"time" "strings"
_ "embed" "time"
) )
//go:embed "html/layout.html" //go:embed "html/layout.html"
@ -35,44 +35,66 @@ var logsHTML string
//go:embed "html/edit-account.html" //go:embed "html/edit-account.html"
var editAccountHTML string var editAccountHTML string
//go:embed "html/edit-artist.html"
var editArtistHTML string //go:embed "html/releases.html"
var releasesHTML string
//go:embed "html/artists.html"
var artistsHTML string
//go:embed "html/tracks.html"
var tracksHTML string
//go:embed "html/edit-release.html" //go:embed "html/edit-release.html"
var editReleaseHTML string var editReleaseHTML string
//go:embed "html/edit-artist.html"
var editArtistHTML string
//go:embed "html/edit-track.html" //go:embed "html/edit-track.html"
var editTrackHTML string var editTrackHTML string
//go:embed "html/components/credits/newcredit.html" //go:embed "html/components/credit/newcredit.html"
var componentNewCreditHTML string var componentNewCreditHTML string
//go:embed "html/components/credits/addcredit.html" //go:embed "html/components/credit/addcredit.html"
var componentAddCreditHTML string var componentAddCreditHTML string
//go:embed "html/components/credits/editcredits.html" //go:embed "html/components/credit/editcredits.html"
var componentEditCreditsHTML string var componentEditCreditsHTML string
//go:embed "html/components/links/editlinks.html" //go:embed "html/components/link/editlinks.html"
var componentEditLinksHTML string var componentEditLinksHTML string
//go:embed "html/components/release/release-list-item.html" //go:embed "html/components/release/release.html"
var componentReleaseListItemHTML string var componentReleaseHTML string
//go:embed "html/components/artist/artist.html"
var componentArtistHTML string
//go:embed "html/components/track/track.html"
var componentTrackHTML string
//go:embed "html/components/tracks/newtrack.html" //go:embed "html/components/track/newtrack.html"
var componentNewTrackHTML string var componentNewTrackHTML string
//go:embed "html/components/tracks/addtrack.html" //go:embed "html/components/track/addtrack.html"
var componentAddTrackHTML string var componentAddTrackHTML string
//go:embed "html/components/tracks/edittracks.html" //go:embed "html/components/track/edittracks.html"
var componentEditTracksHTML string var componentEditTracksHTML string
var BaseTemplate = template.Must(template.New("base").Parse( var BaseTemplate = template.Must(
strings.Join([]string{ layoutHTML, prideflagHTML }, "\n"), template.New("base").Funcs(
)) template.FuncMap{
"hasPrefix": strings.HasPrefix,
},
).Parse(strings.Join([]string{
layoutHTML,
prideflagHTML,
}, "\n")))
var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{ strings.Join([]string{
indexHTML, indexHTML,
componentReleaseListItemHTML, componentReleaseHTML,
componentArtistHTML,
componentTrackHTML,
}, "\n"), }, "\n"),
)) ))
var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML)) var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML))
var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML)) var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML))
var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML)) var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML))
@ -81,42 +103,54 @@ var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(ed
var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML)) var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML))
var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML)) var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML))
var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{ var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{
"parseLevel": func(level log.LogLevel) string { "parseLevel": parseLevel,
switch level { "titleCase": titleCase,
case log.LEVEL_INFO: "toLower": toLower,
return "INFO" "prettyTime": prettyTime,
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)
},
"titleCase": func(logType string) string {
runes := []rune(logType)
for i, r := range runes {
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
runes[i] = r + ('A' - 'a')
}
}
return string(runes)
},
"lower": func(str string) string { return strings.ToLower(str) },
"prettyTime": func(t time.Time) string {
// return t.Format("2006-01-02 15:04:05")
// return t.Format("15:04:05, 2 Jan 2006")
return t.Format("02 Jan 2006, 15:04:05")
},
}).Parse(logsHTML)) }).Parse(logsHTML))
var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editReleaseHTML))
var ReleasesTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
releasesHTML,
componentReleaseHTML,
}, "\n"),
))
var ArtistsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
artistsHTML,
componentArtistHTML,
}, "\n"),
))
var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
tracksHTML,
componentTrackHTML,
}, "\n"),
))
var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
editReleaseHTML,
componentTrackHTML,
}, "\n"),
))
var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML)) var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML))
var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{ strings.Join([]string{
editTrackHTML, editTrackHTML,
componentReleaseListItemHTML, componentReleaseHTML,
}, "\n"), }, "\n"),
)) ))
var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML)) var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML))
var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML)) var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML))
var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML)) var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML))
@ -126,3 +160,32 @@ var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML)) var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML))
var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML)) var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML))
var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewTrackHTML)) var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewTrackHTML))
func parseLevel(level log.LogLevel) string {
switch level {
case log.LEVEL_INFO:
return "INFO"
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)
}
func titleCase(logType string) string {
runes := []rune(logType)
for i, r := range runes {
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
runes[i] = r + ('A' - 'a')
}
}
return string(runes)
}
func toLower(str string) string {
return strings.ToLower(str)
}
func prettyTime(t time.Time) string {
// return t.Format("2006-01-02 15:04:05")
// return t.Format("15:04:05, 2 Jan 2006")
return t.Format("02 Jan 2006, 15:04:05")
}

View file

@ -10,13 +10,48 @@ import (
"arimelody-web/model" "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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slices := strings.Split(r.URL.Path[1:], "/") session := r.Context().Value("session").(*model.Session)
id := slices[0]
track, err := controller.GetTrack(app.DB, id) 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 { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("WARN: Failed to fetch tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type TracksResponse struct {
adminPageData
Tracks []*model.Track
}
err = templates.TracksTemplate.Execute(w, TracksResponse{
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Tracks: tracks,
})
if err != nil {
fmt.Printf("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.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -27,28 +62,25 @@ func serveTrack(app *model.AppState) http.Handler {
releases, err := controller.GetTrackReleases(app.DB, track.ID, true) releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) fmt.Printf("WARN: Failed to fetch releases for track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
type TrackResponse struct { type TrackResponse struct {
Session *model.Session adminPageData
Track *model.Track Track *model.Track
Releases []*model.Release Releases []*model.Release
} }
session := r.Context().Value("session").(*model.Session)
err = templates.EditTrackTemplate.Execute(w, TrackResponse{ err = templates.EditTrackTemplate.Execute(w, TrackResponse{
Session: session, adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
Track: track, Track: track,
Releases: releases, Releases: releases,
}) })
if err != nil { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
} }

View file

@ -29,6 +29,11 @@ func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
return artists, nil return artists, nil
} }
func GetArtistCount(db *sqlx.DB) (int, error) {
var count int
err := db.Get(&count, "SELECT count(*) FROM artist")
return count, err
}
func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) { func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) {
var artists = []*model.Artist{} var artists = []*model.Artist{}

View file

@ -99,6 +99,17 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
return releases, nil return releases, nil
} }
func GetReleaseCount(db *sqlx.DB, onlyVisible bool) (int, error) {
query := "SELECT count(*) FROM musicrelease"
if onlyVisible {
query += " WHERE visible=true"
}
var count int
err := db.Get(&count, query)
return count, err
}
func CreateRelease(db *sqlx.DB, release *model.Release) error { func CreateRelease(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec( _, err := db.Exec(

View file

@ -29,6 +29,11 @@ func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
return tracks, nil return tracks, nil
} }
func GetTrackCount(db *sqlx.DB) (int, error) {
var count int
err := db.Get(&count, "SELECT count(*) FROM musictrack")
return count, err
}
func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) { func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
var tracks = []*model.Track{} var tracks = []*model.Track{}

View file

@ -39,7 +39,7 @@ const (
// GETTERS // GETTERS
func (release Release) GetDescriptionHTML() template.HTML { func (release Release) GetDescriptionHTML() template.HTML {
return template.HTML(strings.Replace(release.Description, "\n", "<br>", -1)) return template.HTML(strings.ReplaceAll(release.Description, "\n", "<br>"))
} }
func (release Release) TextReleaseDate() string { func (release Release) TextReleaseDate() string {

View file

@ -13,16 +13,16 @@ type (
Lyrics string `json:"lyrics" db:"lyrics"` Lyrics string `json:"lyrics" db:"lyrics"`
PreviewURL string `json:"previewURL" db:"preview_url"` PreviewURL string `json:"previewURL" db:"preview_url"`
Number int Number int `json:"-"`
} }
) )
func (track Track) GetDescriptionHTML() template.HTML { func (track Track) GetDescriptionHTML() template.HTML {
return template.HTML(strings.Replace(track.Description, "\n", "<br>", -1)) return template.HTML(strings.ReplaceAll(track.Description, "\n", "<br>"))
} }
func (track Track) GetLyricsHTML() template.HTML { func (track Track) GetLyricsHTML() template.HTML {
return template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)) return template.HTML(strings.ReplaceAll(track.Lyrics, "\n", "<br>"))
} }
// this function is stupid and i hate that i need it // this function is stupid and i hate that i need it

1
public/img/hamburger.svg Normal file
View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Artboard1" x="0" y="0" width="15.36" height="15.36" style="fill:none;"/><g id="Artboard11" serif:id="Artboard1"><path d="M13.44,7.68c0,0.53 -0.43,0.96 -0.96,0.96l-9.6,0c-0.53,0 -0.96,-0.43 -0.96,-0.96c-0,-0.53 0.43,-0.96 0.96,-0.96l9.6,-0c0.53,-0 0.96,0.43 0.96,0.96Z"/><path d="M13.44,11.52c0,0.53 -0.43,0.96 -0.96,0.96l-9.6,0c-0.53,0 -0.96,-0.43 -0.96,-0.96c-0,-0.53 0.43,-0.96 0.96,-0.96l9.6,0c0.53,0 0.96,0.43 0.96,0.96Z"/><path d="M13.44,3.84c0,0.53 -0.43,0.96 -0.96,0.96l-9.6,0c-0.53,0 -0.96,-0.43 -0.96,-0.96c-0,-0.53 0.43,-0.96 0.96,-0.96l9.6,0c0.53,0 0.96,0.43 0.96,0.96Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -20,7 +20,6 @@ function update_extras_buttons() {
const info_container = document.getElementById("info") const info_container = document.getElementById("info")
info_container.addEventListener("scroll", update_extras_buttons); info_container.addEventListener("scroll", update_extras_buttons);
const info_rect = info_container.getBoundingClientRect(); const info_rect = info_container.getBoundingClientRect();
const info_y = info_rect.y;
const font_size = parseFloat(getComputedStyle(document.documentElement).fontSize); const font_size = parseFloat(getComputedStyle(document.documentElement).fontSize);
let current = extras_pairs[0]; let current = extras_pairs[0];
extras_pairs.forEach(pair => { extras_pairs.forEach(pair => {
@ -53,7 +52,7 @@ function bind_go_back_btn() {
function bind_share_btn() { function bind_share_btn() {
const share_btn = document.getElementById("share"); const share_btn = document.getElementById("share");
if (navigator.clipboard === undefined) { if (navigator.clipboard === undefined) {
share_btn.onclick = event => { share_btn.onclick = () => {
console.error("clipboard is not supported by this browser!"); console.error("clipboard is not supported by this browser!");
}; };
return; return;

View file

@ -333,7 +333,7 @@ ul#links a {
background-color: #fff; background-color: #fff;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
transition: filter .1s,-webkit-filter .1s transition: filter .1s ease-out, -webkit-filter .1s ease-out;
} }
#buylink { #buylink {

View file

@ -16,21 +16,19 @@ func IndexHandler(app *model.AppState) http.Handler {
return return
} }
type IndexData struct {
TwitchStatus *model.TwitchStreamInfo
}
var err error
var twitchStatus *model.TwitchStreamInfo = nil
if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 {
twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err)
}
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" { if r.URL.Path == "/" || r.URL.Path == "/index.html" {
err := templates.IndexTemplate.Execute(w, IndexData{ type IndexData struct {
TwitchStatus *model.TwitchStreamInfo
}
var err error
var twitchStatus *model.TwitchStreamInfo = nil
if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 {
twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err)
}
}
err = templates.IndexTemplate.Execute(w, IndexData{
TwitchStatus: twitchStatus, TwitchStatus: twitchStatus,
}) })
if err != nil { if err != nil {
@ -40,6 +38,7 @@ func IndexHandler(app *model.AppState) http.Handler {
return return
} }
ServeEmbedFS(app.PublicFS, "public").ServeHTTP(w, r) http.FileServer(http.Dir("./public")).ServeHTTP(w, r)
//ServeEmbedFS(app.PublicFS, "public").ServeHTTP(w, r)
}) })
} }