diff --git a/.air.toml b/.air.toml
index c6d499b..070166a 100644
--- a/.air.toml
+++ b/.air.toml
@@ -7,14 +7,14 @@ tmp_dir = "tmp"
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
- exclude_dir = ["uploads", "test", "db", "res"]
+ exclude_dir = ["admin/static", "admin\\static", "public", "uploads", "test", "db", "res"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
- include_ext = ["go", "tpl", "tmpl", "html", "css"]
+ include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
diff --git a/.forgejo/workflows/push-prod.yaml b/.forgejo/workflows/push-prod.yaml
deleted file mode 100644
index 7a9fd05..0000000
--- a/.forgejo/workflows/push-prod.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-on:
- push:
- branches:
- - main
-
-env:
- EXEC: arimelody-web
- REMOTE: ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}
- PORT: ${{ secrets.SSH_PORT }}
-
-jobs:
- deploy:
- runs-on: docker
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Set up Go
- uses: actions/setup-go@v4
- with:
- go-version: '^1.25.1'
-
- - name: Run tests
- run: go test -v ./model
-
- - name: Build binary
- run: make build
-
- - name: Bundle tarball
- run: make bundle
-
- - name: Set up SSH keys
- uses: webfactory/ssh-agent@v0.9.0
- with:
- ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
-
- - name: Copy to production server
- run: |
- ssh-keyscan -p $PORT ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- scp -P $PORT ./$EXEC.tar.gz $REMOTE:~/
-
- - name: Restart production
- run: |
- ssh -o StrictHostKeyChecking=no $REMOTE -p $PORT << EOT
- cd ${{ secrets.DEPLOY_DIR }}
- tar xzf ~/$EXEC.tar.gz
- /bin/bash ~/restart.sh
- rm ~/$EXEC.tar.gz
- EOT
-
diff --git a/Makefile b/Makefile
index 98a4ea1..11e565a 100644
--- a/Makefile
+++ b/Makefile
@@ -2,11 +2,11 @@ EXEC = arimelody-web
.PHONY: $(EXEC)
-build:
+$(EXEC):
GOOS=linux GOARCH=amd64 go build -o $(EXEC)
-bundle: build
- tar czf $(EXEC).tar.gz --exclude ".DS_Store" $(EXEC) admin/static/ public/
+bundle: $(EXEC)
+ tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/
clean:
rm $(EXEC) $(EXEC).tar.gz
diff --git a/admin/accounthttp.go b/admin/account/accounthttp.go
similarity index 80%
rename from admin/accounthttp.go
rename to admin/account/accounthttp.go
index 113a17a..5601a2e 100644
--- a/admin/accounthttp.go
+++ b/admin/account/accounthttp.go
@@ -1,4 +1,4 @@
-package admin
+package account
import (
"database/sql"
@@ -15,15 +15,17 @@ import (
"golang.org/x/crypto/bcrypt"
)
-func accountHandler(app *model.AppState) http.Handler {
+func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
- mux.Handle("/account/totp-setup", totpSetupHandler(app))
- mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
- mux.Handle("/account/totp-delete", totpDeleteHandler(app))
+ mux.Handle("/", accountIndexHandler(app))
- mux.Handle("/account/password", changePasswordHandler(app))
- mux.Handle("/account/delete", deleteAccountHandler(app))
+ mux.Handle("/totp-setup", totpSetupHandler(app))
+ mux.Handle("/totp-confirm", totpConfirmHandler(app))
+ mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
+
+ mux.Handle("/password", changePasswordHandler(app))
+ mux.Handle("/delete", deleteAccountHandler(app))
return mux
}
@@ -45,7 +47,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
}
accountResponse struct {
- adminPageData
+ Session *model.Session
TOTPs []TOTP
}
)
@@ -66,7 +68,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
session.Error = sessionError
err = templates.AccountTemplate.Execute(w, accountResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ Session: session,
TOTPs: totps,
})
if err != nil {
@@ -93,7 +95,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
currentPassword := r.Form.Get("current-password")
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
controller.SetSessionError(app.DB, session, "Incorrect password.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -103,7 +105,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -112,7 +114,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -120,7 +122,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}
@@ -148,7 +150,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Incorrect password.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -156,7 +158,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -170,7 +172,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
}
type totpConfirmData struct {
- adminPageData
+ Session *model.Session
TOTP *model.TOTP
NameEscaped string
QRBase64Image string
@@ -179,9 +181,13 @@ type totpConfirmData struct {
func totpSetupHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
+ type totpSetupData struct {
+ Session *model.Session
+ }
+
session := r.Context().Value("session").(*model.Session)
- err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session })
+ err := templates.TotpSetupTemplate.Execute(w, totpSetupData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -218,9 +224,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- err := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
- })
+ err := templates.TotpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -234,8 +238,8 @@ func totpSetupHandler(app *model.AppState) http.Handler {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
}
- err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{
+ Session: session,
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
QRBase64Image: qrBase64Image,
@@ -266,12 +270,17 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
+ code := r.FormValue("totp")
+ if len(code) != controller.TOTP_CODE_LENGTH {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
if totp == nil {
@@ -285,29 +294,30 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
}
- code := r.FormValue("totp")
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
- confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
- if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) {
- session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
- err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
- TOTP: totp,
- NameEscaped: url.PathEscape(totp.Name),
- QRBase64Image: qrBase64Image,
- })
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ if code != confirmCode {
+ confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
+ if code != confirmCodeOffset {
+ session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
+ err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{
+ Session: session,
+ TOTP: totp,
+ NameEscaped: url.PathEscape(totp.Name),
+ QRBase64Image: qrBase64Image,
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ return
}
- return
}
err = controller.ConfirmTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -315,35 +325,30 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}
func totpDeleteHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
+ if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
- session := r.Context().Value("session").(*model.Session)
+ if len(r.URL.Path) < 2 {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+ name := r.URL.Path[1:]
- err := r.ParseForm()
- if err != nil {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
- name := r.FormValue("totp-name")
- if len(name) == 0 {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
+ session := r.Context().Value("session").(*model.Session)
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
if totp == nil {
@@ -355,7 +360,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@@ -363,6 +368,6 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
- http.Redirect(w, r, "/admin/account", http.StatusFound)
+ http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}
diff --git a/admin/artisthttp.go b/admin/artisthttp.go
deleted file mode 100644
index f151ddd..0000000
--- a/admin/artisthttp.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package admin
-
-import (
- "fmt"
- "net/http"
- "strings"
-
- "arimelody-web/admin/templates"
- "arimelody-web/controller"
- "arimelody-web/model"
-)
-
-func serveArtists(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
-
- slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/artists")[1:], "/")
- artistID := slices[0]
-
- if len(artistID) > 0 {
- serveArtist(app, artistID).ServeHTTP(w, r)
- return
- }
-
- artists, err := controller.GetAllArtists(app.DB)
- if err != nil {
- fmt.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 artist == nil {
- http.NotFound(w, r)
- return
- }
- fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
- if err != nil {
- fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- type ArtistResponse struct {
- adminPageData
- Artist *model.Artist
- Credits []*model.Credit
- }
-
- err = templates.EditArtistTemplate.Execute(w, ArtistResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
- Artist: artist,
- Credits: credits,
- })
- if err != nil {
- fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
- })
-}
diff --git a/admin/auth/authhttp.go b/admin/auth/authhttp.go
new file mode 100644
index 0000000..aba4074
--- /dev/null
+++ b/admin/auth/authhttp.go
@@ -0,0 +1,386 @@
+package auth
+
+import (
+ "arimelody-web/admin/templates"
+ "arimelody-web/controller"
+ "arimelody-web/log"
+ "arimelody-web/model"
+ "database/sql"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+func RegisterAccountHandler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
+ if session.Account != nil {
+ // user is already logged in
+ http.Redirect(w, r, "/admin", http.StatusFound)
+ return
+ }
+
+ type registerData struct {
+ Session *model.Session
+ }
+
+ render := func() {
+ err := templates.RegisterTemplate.Execute(w, registerData{ Session: session })
+ if err != nil {
+ fmt.Printf("WARN: Error rendering create account page: %s\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ }
+
+ if r.Method == http.MethodGet {
+ render()
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ type RegisterRequest struct {
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+ Invite string `json:"invite"`
+ }
+ credentials := RegisterRequest{
+ Username: r.Form.Get("username"),
+ Email: r.Form.Get("email"),
+ Password: r.Form.Get("password"),
+ Invite: r.Form.Get("invite"),
+ }
+
+ // make sure invite code exists in DB
+ invite, err := controller.GetInvite(app.DB, credentials.Invite)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+ if invite == nil || time.Now().After(invite.ExpiresAt) {
+ if invite != nil {
+ err := controller.DeleteInvite(app.DB, invite.Code)
+ if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
+ }
+ controller.SetSessionError(app.DB, session, "Invalid invite code.")
+ render()
+ return
+ }
+
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+
+ account := model.Account{
+ Username: credentials.Username,
+ Password: string(hashedPassword),
+ Email: sql.NullString{ String: credentials.Email, Valid: true },
+ AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
+ }
+ err = controller.CreateAccount(app.DB, &account)
+ if err != nil {
+ if strings.HasPrefix(err.Error(), "pq: duplicate key") {
+ controller.SetSessionError(app.DB, session, "An account with that username already exists.")
+ render()
+ return
+ }
+ fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+
+ app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
+
+ err = controller.DeleteInvite(app.DB, invite.Code)
+ if err != nil {
+ app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
+ }
+
+ // registration success!
+ controller.SetSessionAccount(app.DB, session, &account)
+ controller.SetSessionMessage(app.DB, session, "")
+ controller.SetSessionError(app.DB, session, "")
+ http.Redirect(w, r, "/admin", http.StatusFound)
+ })
+}
+
+func LoginHandler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+
+ session := r.Context().Value("session").(*model.Session)
+
+ type loginData struct {
+ Session *model.Session
+ }
+
+ render := func() {
+ err := templates.LoginTemplate.Execute(w, loginData{ Session: session })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ if r.Method == http.MethodGet {
+ if session.Account != nil {
+ // user is already logged in
+ http.Redirect(w, r, "/admin", http.StatusFound)
+ return
+ }
+ render()
+ return
+ }
+
+ err := r.ParseForm()
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ if !r.Form.Has("username") || !r.Form.Has("password") {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ account, err := controller.GetAccountByUsername(app.DB, username)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Invalid username or password.")
+ render()
+ return
+ }
+ if account == nil {
+ controller.SetSessionError(app.DB, session, "Invalid username or password.")
+ render()
+ return
+ }
+ if account.Locked {
+ controller.SetSessionError(app.DB, session, "This account is locked.")
+ render()
+ return
+ }
+
+ err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
+ if err != nil {
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
+ if locked := handleFailedLogin(app, account, r); locked {
+ controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
+ } else {
+ controller.SetSessionError(app.DB, session, "Invalid username or password.")
+ }
+ render()
+ return
+ }
+
+ totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+
+ if len(totps) > 0 {
+ err = controller.SetSessionAttemptAccount(app.DB, session, account)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+ controller.SetSessionMessage(app.DB, session, "")
+ controller.SetSessionError(app.DB, session, "")
+ http.Redirect(w, r, "/admin/totp", http.StatusFound)
+ return
+ }
+
+ // login success!
+ // TODO: log login activity to user
+ app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
+
+ err = controller.SetSessionAccount(app.DB, session, account)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+ controller.SetSessionMessage(app.DB, session, "")
+ controller.SetSessionError(app.DB, session, "")
+ http.Redirect(w, r, "/admin", http.StatusFound)
+ })
+}
+
+func LoginTOTPHandler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
+ if session.AttemptAccount == nil {
+ http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ return
+ }
+
+ type loginTOTPData struct {
+ Session *model.Session
+ }
+
+ render := func() {
+ err := templates.LoginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ if r.Method == http.MethodGet {
+ render()
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+
+ r.ParseForm()
+
+ if !r.Form.Has("totp") {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ totpCode := r.FormValue("totp")
+
+ if len(totpCode) != controller.TOTP_CODE_LENGTH {
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
+ controller.SetSessionError(app.DB, session, "Invalid TOTP.")
+ render()
+ return
+ }
+
+ totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+ if totpMethod == nil {
+ app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
+ if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
+ controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
+ controller.SetSessionAttemptAccount(app.DB, session, nil)
+ http.Redirect(w, r, "/admin", http.StatusFound)
+ } else {
+ controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
+ }
+ render()
+ return
+ }
+
+ app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
+
+ err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
+ controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
+ render()
+ return
+ }
+ err = controller.SetSessionAttemptAccount(app.DB, session, nil)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
+ }
+ controller.SetSessionMessage(app.DB, session, "")
+ controller.SetSessionError(app.DB, session, "")
+ http.Redirect(w, r, "/admin", http.StatusFound)
+ })
+}
+
+func LogoutHandler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return
+ }
+
+ session := r.Context().Value("session").(*model.Session)
+ err := controller.DeleteSession(app.DB, session.Token)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: model.COOKIE_TOKEN,
+ Expires: time.Now(),
+ Path: "/",
+ })
+
+ err = templates.LogoutTemplate.Execute(w, nil)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ })
+}
+
+func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
+ locked, err := controller.IncrementAccountFails(app.DB, account.ID)
+ if err != nil {
+ fmt.Fprintf(
+ os.Stderr,
+ "WARN: Failed to increment login failures for \"%s\": %v\n",
+ account.Username,
+ err,
+ )
+ app.Log.Warn(
+ log.TYPE_ACCOUNT,
+ "Failed to increment login failures for \"%s\"",
+ account.Username,
+ )
+ }
+ if locked {
+ app.Log.Warn(
+ log.TYPE_ACCOUNT,
+ "Account \"%s\" was locked: %d failed login attempts (IP: %s)",
+ account.Username,
+ model.MAX_LOGIN_FAIL_ATTEMPTS,
+ controller.ResolveIP(app, r),
+ )
+ }
+ return locked
+}
diff --git a/admin/templates/html/components/credit/addcredit.html b/admin/components/credits/addcredit.html
similarity index 94%
rename from admin/templates/html/components/credit/addcredit.html
rename to admin/components/credits/addcredit.html
index 082ba17..debc660 100644
--- a/admin/templates/html/components/credit/addcredit.html
+++ b/admin/components/credits/addcredit.html
@@ -7,7 +7,7 @@
{{range $Artist := .Artists}}
diff --git a/admin/templates/html/components/credit/editcredits.html b/admin/components/credits/editcredits.html
similarity index 97%
rename from admin/templates/html/components/credit/editcredits.html
rename to admin/components/credits/editcredits.html
index 38132e2..0c50fac 100644
--- a/admin/templates/html/components/credit/editcredits.html
+++ b/admin/components/credits/editcredits.html
@@ -3,8 +3,8 @@
Editing: Credits
Add
diff --git a/admin/templates/html/components/credit/newcredit.html b/admin/components/credits/newcredit.html
similarity index 100%
rename from admin/templates/html/components/credit/newcredit.html
rename to admin/components/credits/newcredit.html
diff --git a/admin/templates/html/components/link/editlinks.html b/admin/components/links/editlinks.html
similarity index 100%
rename from admin/templates/html/components/link/editlinks.html
rename to admin/components/links/editlinks.html
diff --git a/admin/templates/html/components/release/release.html b/admin/components/release/release-list-item.html
similarity index 74%
rename from admin/templates/html/components/release/release.html
rename to admin/components/release/release-list-item.html
index 7a5059d..142c26a 100644
--- a/admin/templates/html/components/release/release.html
+++ b/admin/components/release/release-list-item.html
@@ -5,7 +5,7 @@
diff --git a/admin/templates/html/components/track/addtrack.html b/admin/components/tracks/addtrack.html
similarity index 93%
rename from admin/templates/html/components/track/addtrack.html
rename to admin/components/tracks/addtrack.html
index 6a2360b..f62f369 100644
--- a/admin/templates/html/components/track/addtrack.html
+++ b/admin/components/tracks/addtrack.html
@@ -8,7 +8,7 @@
diff --git a/admin/templates/html/components/track/edittracks.html b/admin/components/tracks/edittracks.html
similarity index 96%
rename from admin/templates/html/components/track/edittracks.html
rename to admin/components/tracks/edittracks.html
index c06f0c3..3b8368d 100644
--- a/admin/templates/html/components/track/edittracks.html
+++ b/admin/components/tracks/edittracks.html
@@ -3,8 +3,8 @@
Editing: Tracks
Add
diff --git a/admin/templates/html/components/track/newtrack.html b/admin/components/tracks/newtrack.html
similarity index 100%
rename from admin/templates/html/components/track/newtrack.html
rename to admin/components/tracks/newtrack.html
diff --git a/admin/core/funcs.go b/admin/core/funcs.go
new file mode 100644
index 0000000..c471f8a
--- /dev/null
+++ b/admin/core/funcs.go
@@ -0,0 +1,56 @@
+package core
+
+import (
+ "arimelody-web/controller"
+ "arimelody-web/model"
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+)
+
+func RequireAccount(next http.Handler) http.HandlerFunc {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+ if session.Account == nil {
+ // TODO: include context in redirect
+ http.Redirect(w, r, "/admin/login", http.StatusFound)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func EnforceSession(app *model.AppState, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session, err := controller.GetSessionFromRequest(app, r)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ if session == nil {
+ // create a new session
+ session, err = controller.CreateSession(app.DB, r.UserAgent())
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: model.COOKIE_TOKEN,
+ Value: session.Token,
+ Expires: session.ExpiresAt,
+ Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
+ HttpOnly: true,
+ Path: "/",
+ })
+ }
+
+ ctx := context.WithValue(r.Context(), "session", session)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
diff --git a/admin/http.go b/admin/http.go
index 2a6b4ae..e04c636 100644
--- a/admin/http.go
+++ b/admin/http.go
@@ -1,78 +1,38 @@
package admin
import (
- "context"
- "database/sql"
"fmt"
"net/http"
"os"
- "strings"
- "time"
+ "path/filepath"
+ "arimelody-web/admin/account"
+ "arimelody-web/admin/auth"
+ "arimelody-web/admin/core"
+ "arimelody-web/admin/logs"
+ "arimelody-web/admin/music"
"arimelody-web/admin/templates"
"arimelody-web/controller"
- "arimelody-web/log"
"arimelody-web/model"
- "arimelody-web/view"
-
- "golang.org/x/crypto/bcrypt"
)
-type adminPageData struct {
- Path string
- Session *model.Session
-}
-
func Handler(app *model.AppState) http.Handler {
mux := http.NewServeMux()
- mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
+ mux.Handle("/register", auth.RegisterAccountHandler(app))
+ mux.Handle("/login", auth.LoginHandler(app))
+ mux.Handle("/totp", auth.LoginTOTPHandler(app))
+ mux.Handle("/logout", core.RequireAccount(auth.LogoutHandler(app)))
- w.Write([]byte(" "))
- }))
+ mux.Handle("/music/", core.RequireAccount(http.StripPrefix("/music", music.Handler(app))))
+ mux.Handle("/logs", core.RequireAccount(logs.Handler(app)))
+ mux.Handle("/account/", core.RequireAccount(http.StripPrefix("/account", account.Handler(app))))
- mux.Handle("/login", loginHandler(app))
- mux.Handle("/totp", loginTOTPHandler(app))
- mux.Handle("/logout", requireAccount(logoutHandler(app)))
-
- mux.Handle("/register", registerAccountHandler(app))
-
- mux.Handle("/account", requireAccount(accountIndexHandler(app)))
- mux.Handle("/account/", requireAccount(accountHandler(app)))
-
- mux.Handle("/logs", requireAccount(logsHandler(app)))
-
- mux.Handle("/releases", requireAccount(serveReleases(app)))
- mux.Handle("/releases/", requireAccount(serveReleases(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.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("/static/", http.StripPrefix("/static", staticHandler()))
+ mux.Handle("/", core.RequireAccount(AdminIndexHandler(app)))
// response wrapper to make sure a session cookie exists
- return enforceSession(app, mux)
+ return core.EnforceSession(app, mux)
}
func AdminIndexHandler(app *model.AppState) http.Handler {
@@ -84,63 +44,21 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
- releases, err := controller.GetAllReleases(app.DB, false, 3, true)
+ latestRelease, err := controller.GetLatestRelease(app.DB)
if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- releaseCount, err := controller.GetReleaseCount(app.DB, false)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- artists, err := controller.GetAllArtists(app.DB)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- artistCount, err := controller.GetArtistCount(app.DB)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- tracks, err := controller.GetOrphanTracks(app.DB)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- trackCount, err := controller.GetTrackCount(app.DB)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err)
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull latest release: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type IndexData struct {
- adminPageData
- Releases []*model.Release
- ReleaseCount int
- Artists []*model.Artist
- ArtistCount int
- Tracks []*model.Track
- TrackCount int
+ Session *model.Session
+ LatestRelease *model.Release
}
err = templates.IndexTemplate.Execute(w, IndexData{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
- Releases: releases,
- ReleaseCount: releaseCount,
- Artists: artists,
- ArtistCount: artistCount,
- Tracks: tracks,
- TrackCount: trackCount,
+ Session: session,
+ LatestRelease: latestRelease,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
@@ -150,426 +68,23 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
})
}
-func registerAccountHandler(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
-
- if session.Account != nil {
- // user is already logged in
- http.Redirect(w, r, "/admin", http.StatusFound)
- return
- }
-
- render := func() {
- err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
- if err != nil {
- fmt.Printf("WARN: Error rendering create account page: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
- }
-
- if r.Method == http.MethodGet {
- render()
- return
- }
-
- if r.Method != http.MethodPost {
- http.NotFound(w, r)
- return
- }
-
- err := r.ParseForm()
- if err != nil {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- type RegisterRequest struct {
- Username string `json:"username"`
- Email string `json:"email"`
- Password string `json:"password"`
- Invite string `json:"invite"`
- }
- credentials := RegisterRequest{
- Username: r.Form.Get("username"),
- Email: r.Form.Get("email"),
- Password: r.Form.Get("password"),
- Invite: r.Form.Get("invite"),
- }
-
- // make sure invite code exists in DB
- invite, err := controller.GetInvite(app.DB, credentials.Invite)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- if invite == nil || time.Now().After(invite.ExpiresAt) {
- if invite != nil {
- err := controller.DeleteInvite(app.DB, invite.Code)
- if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
- }
- controller.SetSessionError(app.DB, session, "Invalid invite code.")
- render()
- return
- }
-
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
-
- account := model.Account{
- Username: credentials.Username,
- Password: string(hashedPassword),
- Email: sql.NullString{ String: credentials.Email, Valid: true },
- AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
- }
- err = controller.CreateAccount(app.DB, &account)
- if err != nil {
- if strings.HasPrefix(err.Error(), "pq: duplicate key") {
- controller.SetSessionError(app.DB, session, "An account with that username already exists.")
- render()
- return
- }
- fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
-
- app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
-
- err = controller.DeleteInvite(app.DB, invite.Code)
- if err != nil {
- app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
- }
-
- // registration success!
- controller.SetSessionAccount(app.DB, session, &account)
- controller.SetSessionMessage(app.DB, session, "")
- controller.SetSessionError(app.DB, session, "")
- http.Redirect(w, r, "/admin", http.StatusFound)
- })
-}
-
-func loginHandler(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet && r.Method != http.MethodPost {
- http.NotFound(w, r)
- return
- }
-
- session := r.Context().Value("session").(*model.Session)
-
- render := func() {
- err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- }
-
- if r.Method == http.MethodGet {
- if session.Account != nil {
- // user is already logged in
- http.Redirect(w, r, "/admin", http.StatusFound)
- return
- }
- render()
- return
- }
-
- err := r.ParseForm()
- if err != nil {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- if !r.Form.Has("username") || !r.Form.Has("password") {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- username := r.FormValue("username")
- password := r.FormValue("password")
-
- account, err := controller.GetAccountByUsername(app.DB, username)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
- controller.SetSessionError(app.DB, session, "Invalid username or password.")
- render()
- return
- }
- if account == nil {
- controller.SetSessionError(app.DB, session, "Invalid username or password.")
- render()
- return
- }
- if account.Locked {
- controller.SetSessionError(app.DB, session, "This account is locked.")
- render()
- return
- }
-
- err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
- if err != nil {
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
- if locked := handleFailedLogin(app, account, r); locked {
- controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
- } else {
- controller.SetSessionError(app.DB, session, "Invalid username or password.")
- }
- render()
- return
- }
-
- totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
-
- if len(totps) > 0 {
- err = controller.SetSessionAttemptAccount(app.DB, session, account)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- controller.SetSessionMessage(app.DB, session, "")
- controller.SetSessionError(app.DB, session, "")
- http.Redirect(w, r, "/admin/totp", http.StatusFound)
- return
- }
-
- // login success!
- // TODO: log login activity to user
- app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
-
- err = controller.SetSessionAccount(app.DB, session, account)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- controller.SetSessionMessage(app.DB, session, "")
- controller.SetSessionError(app.DB, session, "")
- http.Redirect(w, r, "/admin", http.StatusFound)
- })
-}
-
-func loginTOTPHandler(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
-
- if session.AttemptAccount == nil {
- http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
- return
- }
-
- render := func() {
- err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- }
-
- if r.Method == http.MethodGet {
- render()
- return
- }
-
- if r.Method != http.MethodPost {
- http.NotFound(w, r)
- return
- }
-
- r.ParseForm()
-
- if !r.Form.Has("totp") {
- http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
- return
- }
-
- totpCode := r.FormValue("totp")
-
- if len(totpCode) != controller.TOTP_CODE_LENGTH {
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
- controller.SetSessionError(app.DB, session, "Invalid TOTP.")
- render()
- return
- }
-
- totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- if totpMethod == nil {
- app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
- if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
- controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
- controller.SetSessionAttemptAccount(app.DB, session, nil)
- http.Redirect(w, r, "/admin", http.StatusFound)
- } else {
- controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
- }
- render()
- return
- }
-
- app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
-
- err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
- controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
- render()
- return
- }
- err = controller.SetSessionAttemptAccount(app.DB, session, nil)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
- }
- controller.SetSessionMessage(app.DB, session, "")
- controller.SetSessionError(app.DB, session, "")
- http.Redirect(w, r, "/admin", http.StatusFound)
- })
-}
-
-func logoutHandler(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- http.NotFound(w, r)
- return
- }
-
- session := r.Context().Value("session").(*model.Session)
- err := controller.DeleteSession(app.DB, session.Token)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- http.SetCookie(w, &http.Cookie{
- Name: model.COOKIE_TOKEN,
- Expires: time.Now(),
- Path: "/",
- })
-
- err = templates.LogoutTemplate.Execute(w, nil)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
- })
-}
-
-func requireAccount(next http.Handler) http.HandlerFunc {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
- if session.Account == nil {
- // TODO: include context in redirect
- http.Redirect(w, r, "/admin/login", http.StatusFound)
- return
- }
- next.ServeHTTP(w, r)
- })
-}
-
-/*
-//go:embed "static"
-var staticFS embed.FS
-
func staticHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- uri := strings.TrimPrefix(r.URL.Path, "/static")
- file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri)))
+ info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
+ // does the file exist?
if err != nil {
+ if os.IsNotExist(err) {
+ http.NotFound(w, r)
+ return
+ }
+ }
+
+ // is thjs a directory? (forbidden)
+ if info.IsDir() {
http.NotFound(w, r)
return
}
- w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
- w.WriteHeader(http.StatusOK)
-
- w.Write(file)
+ http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
})
}
-*/
-
-func enforceSession(app *model.AppState, next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session, err := controller.GetSessionFromRequest(app, r)
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- if session == nil {
- // create a new session
- session, err = controller.CreateSession(app.DB, r.UserAgent())
- if err != nil {
- fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- http.SetCookie(w, &http.Cookie{
- Name: model.COOKIE_TOKEN,
- Value: session.Token,
- Expires: session.ExpiresAt,
- Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
- HttpOnly: true,
- Path: "/",
- })
- }
-
- ctx := context.WithValue(r.Context(), "session", session)
- next.ServeHTTP(w, r.WithContext(ctx))
- })
-}
-
-func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
- locked, err := controller.IncrementAccountFails(app.DB, account.ID)
- if err != nil {
- fmt.Fprintf(
- os.Stderr,
- "WARN: Failed to increment login failures for \"%s\": %v\n",
- account.Username,
- err,
- )
- app.Log.Warn(
- log.TYPE_ACCOUNT,
- "Failed to increment login failures for \"%s\"",
- account.Username,
- )
- }
- if locked {
- app.Log.Warn(
- log.TYPE_ACCOUNT,
- "Account \"%s\" was locked: %d failed login attempts (IP: %s)",
- account.Username,
- model.MAX_LOGIN_FAIL_ATTEMPTS,
- controller.ResolveIP(app, r),
- )
- }
- return locked
-}
diff --git a/admin/logshttp.go b/admin/logs/logshttp.go
similarity index 91%
rename from admin/logshttp.go
rename to admin/logs/logshttp.go
index a6d8e40..5c5c5c9 100644
--- a/admin/logshttp.go
+++ b/admin/logs/logshttp.go
@@ -1,4 +1,4 @@
-package admin
+package logs
import (
"arimelody-web/admin/templates"
@@ -10,7 +10,7 @@ import (
"strings"
)
-func logsHandler(app *model.AppState) http.Handler {
+func Handler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
@@ -51,12 +51,12 @@ func logsHandler(app *model.AppState) http.Handler {
}
type LogsResponse struct {
- adminPageData
+ Session *model.Session
Logs []*log.Log
}
err = templates.LogsTemplate.Execute(w, LogsResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ Session: session,
Logs: logs,
})
if err != nil {
diff --git a/admin/music/artisthttp.go b/admin/music/artisthttp.go
new file mode 100644
index 0000000..a936d05
--- /dev/null
+++ b/admin/music/artisthttp.go
@@ -0,0 +1,54 @@
+package music
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "arimelody-web/admin/templates"
+ "arimelody-web/controller"
+ "arimelody-web/model"
+)
+
+func serveArtist(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ slices := strings.Split(r.URL.Path[1:], "/")
+ id := slices[0]
+ artist, err := controller.GetArtist(app.DB, id)
+ if err != nil {
+ if artist == nil {
+ http.NotFound(w, r)
+ return
+ }
+ fmt.Printf("Error rendering admin artist page for %s: %s\n", id, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
+ if err != nil {
+ fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ type ArtistResponse struct {
+ Session *model.Session
+ Artist *model.Artist
+ Credits []*model.Credit
+ }
+
+ session := r.Context().Value("session").(*model.Session)
+
+ err = templates.ArtistTemplate.Execute(w, ArtistResponse{
+ Session: session,
+ Artist: artist,
+ Credits: credits,
+ })
+ if err != nil {
+ fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ })
+}
+
diff --git a/admin/music/musichttp.go b/admin/music/musichttp.go
new file mode 100644
index 0000000..c212248
--- /dev/null
+++ b/admin/music/musichttp.go
@@ -0,0 +1,69 @@
+package music
+
+import (
+ "arimelody-web/admin/templates"
+ "arimelody-web/controller"
+ "arimelody-web/model"
+ "fmt"
+ "net/http"
+ "os"
+)
+
+func Handler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mux := http.NewServeMux()
+
+ mux.Handle("/release/", http.StripPrefix("/release", serveRelease(app)))
+ mux.Handle("/artist/", http.StripPrefix("/artist", serveArtist(app)))
+ mux.Handle("/track/", http.StripPrefix("/track", serveTrack(app)))
+ mux.Handle("/", musicHandler(app))
+
+ mux.ServeHTTP(w, r)
+ })
+}
+
+func musicHandler(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ session := r.Context().Value("session").(*model.Session)
+
+ releases, err := controller.GetAllReleases(app.DB, false, 0, true)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ artists, err := controller.GetAllArtists(app.DB)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ tracks, err := controller.GetOrphanTracks(app.DB)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ type MusicData struct {
+ Session *model.Session
+ Releases []*model.Release
+ Artists []*model.Artist
+ Tracks []*model.Track
+ }
+
+ err = templates.MusicTemplate.Execute(w, MusicData{
+ Session: session,
+ Releases: releases,
+ Artists: artists,
+ Tracks: tracks,
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ })
+}
diff --git a/admin/releasehttp.go b/admin/music/releasehttp.go
similarity index 67%
rename from admin/releasehttp.go
rename to admin/music/releasehttp.go
index 7cca841..6716b1a 100644
--- a/admin/releasehttp.go
+++ b/admin/music/releasehttp.go
@@ -1,9 +1,8 @@
-package admin
+package music
import (
"fmt"
"net/http"
- "os"
"strings"
"arimelody-web/admin/templates"
@@ -11,52 +10,11 @@ import (
"arimelody-web/model"
)
-func serveReleases(app *model.AppState) http.Handler {
+func serveRelease(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
-
- slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/")
+ slices := strings.Split(r.URL.Path[1:], "/")
releaseID := slices[0]
- var action string = ""
- if len(slices) > 1 {
- action = slices[1]
- }
-
- if len(releaseID) > 0 {
- serveRelease(app, releaseID, action).ServeHTTP(w, r)
- return
- }
-
- type ReleasesData struct {
- 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)
release, err := controller.GetRelease(app.DB, releaseID, true)
@@ -65,13 +23,13 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
http.NotFound(w, r)
return
}
- fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
+ fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
- if len(action) > 0 {
- switch action {
+ if len(slices) > 1 {
+ switch slices[1] {
case "editcredits":
serveEditCredits(release).ServeHTTP(w, r)
return
@@ -99,20 +57,16 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
}
type ReleaseResponse struct {
- adminPageData
+ Session *model.Session
Release *model.Release
}
- for i, track := range release.Tracks {
- track.Number = i + 1
- }
-
- err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
+ err = templates.ReleaseTemplate.Execute(w, ReleaseResponse{
+ Session: session,
Release: release,
})
if err != nil {
- fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err)
+ fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -123,7 +77,7 @@ func serveEditCredits(release *model.Release) http.Handler {
w.Header().Set("Content-Type", "text/html")
err := templates.EditCreditsTemplate.Execute(w, release)
if err != nil {
- fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err)
+ fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -133,7 +87,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil {
- fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
+ fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -149,7 +103,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
Artists: artists,
})
if err != nil {
- fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err)
+ fmt.Printf("Error rendering add credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -160,7 +114,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
- fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
+ fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -172,7 +126,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
w.Header().Set("Content-Type", "text/html")
err = templates.NewCreditTemplate.Execute(w, artist)
if err != nil {
- fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err)
+ fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -183,7 +137,7 @@ func serveEditLinks(release *model.Release) http.Handler {
w.Header().Set("Content-Type", "text/html")
err := templates.EditLinksTemplate.Execute(w, release)
if err != nil {
- fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err)
+ fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -193,11 +147,17 @@ func serveEditTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
- type editTracksData struct { Release *model.Release }
+ type editTracksData struct {
+ Release *model.Release
+ Add func(a int, b int) int
+ }
- err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release })
+ err := templates.EditTracksTemplate.Execute(w, editTracksData{
+ Release: release,
+ Add: func(a, b int) int { return a + b },
+ })
if err != nil {
- fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err)
+ fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@@ -207,7 +167,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil {
- fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
+ fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -223,9 +183,10 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
Tracks: tracks,
})
if err != nil {
- fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err)
+ fmt.Printf("Error rendering add tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+ return
})
}
@@ -234,7 +195,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
trackID := strings.Split(r.URL.Path, "/")[3]
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
- fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
+ fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -246,8 +207,9 @@ func serveNewTrack(app *model.AppState) http.Handler {
w.Header().Set("Content-Type", "text/html")
err = templates.NewTrackTemplate.Execute(w, track)
if err != nil {
- fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err)
+ fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
+ return
})
}
diff --git a/admin/music/trackhttp.go b/admin/music/trackhttp.go
new file mode 100644
index 0000000..5f67a83
--- /dev/null
+++ b/admin/music/trackhttp.go
@@ -0,0 +1,54 @@
+package music
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "arimelody-web/admin/templates"
+ "arimelody-web/controller"
+ "arimelody-web/model"
+)
+
+func serveTrack(app *model.AppState) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ slices := strings.Split(r.URL.Path[1:], "/")
+ id := slices[0]
+ track, err := controller.GetTrack(app.DB, id)
+ if err != nil {
+ fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ if track == nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
+ if err != nil {
+ fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ type TrackResponse struct {
+ Session *model.Session
+ Track *model.Track
+ Releases []*model.Release
+ }
+
+ session := r.Context().Value("session").(*model.Session)
+
+ err = templates.TrackTemplate.Execute(w, TrackResponse{
+ Session: session,
+ Track: track,
+ Releases: releases,
+ })
+ if err != nil {
+ fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ })
+}
+
diff --git a/admin/static/admin.css b/admin/static/admin.css
index 8f983c7..99dc276 100644
--- a/admin/static/admin.css
+++ b/admin/static/admin.css
@@ -1,266 +1,91 @@
@import url("/style/prideflag.css");
@import url("/font/inter/inter.css");
-:root {
- --bg-0: #101010;
- --bg-1: #181818;
- --bg-2: #282828;
- --bg-3: #404040;
-
- --fg-0: #b0b0b0;
- --fg-1: #c0c0c0;
- --fg-2: #d0d0d0;
- --fg-3: #e0e0e0;
-
- --col-shadow-0: #0002;
- --col-shadow-1: #0004;
- --col-shadow-2: #0006;
- --col-highlight-0: #ffffff08;
- --col-highlight-1: #fff1;
- --col-highlight-2: #fff2;
-
- --col-new: #b3ee5b;
- --col-on-new: #1b2013;
- --col-save: #6fd7ff;
- --col-on-save: #283f48;
- --col-delete: #ff7171;
- --col-on-delete: #371919;
-
- --col-warn: #ffe86a;
- --col-on-warn: var(--bg-0);
- --col-warn-hover: #ffec81;
-
- --shadow-sm:
- 0 1px 2px var(--col-shadow-2),
- inset 0 1px 1px var(--col-highlight-2);
- --shadow-md:
- 0 2px 4px var(--col-shadow-1),
- inset 0 2px 2px var(--col-highlight-1);
- --shadow-lg:
- 0 4px 8px var(--col-shadow-0),
- inset 0 4px 4px var(--col-highlight-0);
-}
-
-@media (prefers-color-scheme: light) {
- :root {
- --bg-0: #e8e8e8;
- --bg-1: #f0f0f0;
- --bg-2: #f8f8f8;
- --bg-3: #ffffff;
-
- --fg-0: #606060;
- --fg-1: #404040;
- --fg-2: #303030;
- --fg-3: #202020;
-
- --col-shadow-0: #0002;
- --col-shadow-1: #0004;
- --col-shadow-2: #0008;
- --col-highlight-0: #fff2;
- --col-highlight-1: #fff4;
- --col-highlight-2: #fff8;
-
- --col-warn: #ffe86a;
- --col-on-warn: var(--fg-3);
- --col-warn-hover: #ffec81;
- }
-}
-
-@media (prefers-color-scheme: green) {
- :root {
- --bg-0: #d0d9c7;
- --bg-1: #e2e5de;
- --bg-2: #f1f1f1;
- --bg-3: #ffffff;
- --fg-0: #626f54;
- --fg-1: #505c43;
- --fg-2: #49523e;
- --fg-3: #2c3522;
- }
-}
-
-@media (prefers-color-scheme: purple) {
- :root {
- --bg-0: #15121c;
- --bg-1: #1e1a27;
- --bg-2: #302a3d;
- --bg-3: #4a4358;
- --fg-0: #9e8fbf;
- --fg-1: #a29bb3;
- --fg-2: #b9b0cd;
- --fg-3: #dcd5ec;
- }
-}
-
-@media (prefers-color-scheme: dark) {
- img.icon {
- -webkit-filter: invert(.9);
- filter: invert(.9);
- }
-}
-
body {
- width: calc(100% - 180px);
+ width: 100%;
height: calc(100vh - 1em);
- margin: 0 0 0 180px;
+ margin: 0;
padding: 0;
- display: flex;
- flex-direction: row;
font-family: "Inter", sans-serif;
font-size: 16px;
- color: var(--fg-0);
- background: var(--bg-0);
- transition: background .1s ease-out, color .1s ease-out;
+ color: #303030;
+ background: #f0f0f0;
}
-h1, h2, h3, h4, h5, h6 {
- color: var(--fg-3);
-}
-
-header {
- display: flex;
- 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;
+ width: min(720px, calc(100% - 2em));
+ height: 2em;
+ margin: 1em auto;
display: flex;
- flex-direction: column;
+ flex-direction: row;
justify-content: left;
- background-color: var(--bg-1);
- box-shadow: var(--shadow-md);
- transition: background .1s ease-out, color .1s ease-out;
-
- user-select: none;
+ background: #f8f8f8;
+ border-radius: 4px;
+ border: 1px solid #808080;
}
-nav .icon {
- width: fit-content;
- height: fit-content;
+.nav-item.icon {
padding: 0;
- margin: 0 auto 1em auto;
+}
+.nav-item.icon img {
+ height: 100%;
+}
+nav .title {
+ width: auto;
+ height: 100%;
+
+ margin: 0 1em 0 0;
+
display: flex;
- border-radius: 100%;
- box-shadow: var(--shadow-sm);
- overflow: clip;
-}
-nav .icon img {
- width: 3em;
- height: 3em;
+ line-height: 2em;
+ text-decoration: none;
+
+ color: inherit;
}
.nav-item {
+ width: auto;
+ height: 100%;
+
+ margin: 0px;
+ padding: 0 1em;
+
display: flex;
- color: var(--fg-2);
+
line-height: 2em;
- font-weight: 500;
- transition: color .1s ease-out, background-color .1s ease-out;
}
.nav-item:hover {
- color: var(--bg-2);
- background-color: var(--fg-2);
+ background: #00000010;
text-decoration: none;
}
-.nav-item.active {
- border-left: 4px solid var(--fg-2);
-}
-.nav-item.active a {
- padding-left: calc(1em - 3.5px);
-}
nav a {
- padding: .2em 1em;
text-decoration: none;
color: inherit;
- width: 100%;
}
-nav a.active {
- border-left: 5px solid var(--fg-0);
- padding-left: calc(1em - 5px);
-}
-nav hr {
- width: calc(100% - 2em);
- margin: .5em auto;
- border: none;
- border-bottom: 1px solid var(--fg-0);
-}
-nav .section-label {
- margin: .6em 0 .1em 1.6em;
- font-size: .6em;
- text-transform: uppercase;
- font-weight: 600;
-}
-#toggle-nav {
- position: fixed;
- top: 16px;
- left: 16px;
- padding: 8px;
- width: 48px;
- height: 48px;
- display: none;
- justify-content: center;
- align-items: center;
- z-index: 1;
-}
-#toggle-nav img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- transform: translate(1px, 1px);
-}
-#toggle-nav img:hover {
- -webkit-filter: invert(.9);
- filter: invert(.9);
-}
-@media (prefers-color-scheme: dark) {
- #toggle-nav img {
- -webkit-filter: invert(.9);
- filter: invert(.9);
- }
- #toggle-nav img:hover {
- -webkit-filter: none;
- filter: none;
- }
+nav #logout {
+ /* margin-left: auto; */
}
main {
- width: 720px;
- max-width: calc(100% - 2em);
- height: fit-content;
- min-height: calc(100vh - 2em);
+ width: min(720px, calc(100% - 2em));
margin: 0 auto;
padding: 1em;
}
-main.dashboard {
- width: 100%;
-}
a {
color: inherit;
text-decoration: none;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- transition: color .1s ease-out, background-color .1s ease-out;
}
-/*
a:hover {
text-decoration: underline;
}
-*/
-img.icon {
+a img.icon {
height: .8em;
- transition: filter .1s ease-out;
}
code {
@@ -270,26 +95,13 @@ code {
border-radius: 4px;
}
-
-
-.cards {
- width: 100%;
- height: fit-content;
- display: flex;
- gap: 2em;
- flex-wrap: wrap;
+h1 {
+ margin: 0 0 .5em 0;
}
+
+
.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;
}
@@ -297,7 +109,7 @@ main:not(.dashboard) .card {
margin: 0 0 .5em 0;
}
-.card-header {
+.card-title {
margin-bottom: 1em;
display: flex;
gap: 1em;
@@ -305,31 +117,21 @@ main:not(.dashboard) .card {
align-items: center;
justify-content: space-between;
}
-.card-header h1,
-.card-header h2,
-.card-header h3 {
- margin: 0;
-}
-.card-header a:hover {
- text-decoration: underline;
-}
-header :is(h1, h2, h3) small,
-.card-header :is(h1, h2, h3) small {
- display: inline-block;
- font-size: .6em;
- transform: translateY(-0.1em);
- color: var(--fg-0);
+.card-title h1,
+.card-title h2,
+.card-title h3 {
+ margin: 0;
}
.flex-fill {
flex-grow: 1;
}
-.artists-group {
- display: grid;
- grid-template-columns: repeat(5, 1fr);
- gap: 1em;
+@media screen and (max-width: 520px) {
+ body {
+ font-size: 12px;
+ }
}
@@ -338,15 +140,17 @@ header :is(h1, h2, h3) small,
#error {
margin: 0 0 1em 0;
padding: 1em;
- border-radius: 8px;
- color: #101010;
+ border-radius: 4px;
background: #ffffff;
+ border: 1px solid #888;
}
#message {
background: #a9dfff;
+ border-color: #599fdc;
}
#error {
background: #ffa9b8;
+ border-color: #dc5959;
}
@@ -355,54 +159,52 @@ a.delete:not(.button) {
color: #d22828;
}
-.button, button {
+button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
-
+ border-radius: 4px;
+ border: 1px solid #a0a0a0;
+ background: #f0f0f0;
color: inherit;
- background: var(--bg-2);
- border: none;
- border-radius: 10em;
- box-shadow: var(--shadow-sm);
- font-weight: 500;
- transition: background .1s ease-out, color .1s ease-out;
-
- cursor: pointer;
- user-select: none;
}
button:hover, .button:hover {
background: #fff;
+ border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
+ border-color: #808080;
}
+.button, button {
+ color: inherit;
+}
.button.new, button.new {
- color: var(--col-on-new);
- background: var(--col-new);
+ background: #c4ff6a;
+ border-color: #84b141;
}
.button.save, button.save {
- color: var(--col-on-save);
- background: var(--col-save);
+ background: #6fd7ff;
+ border-color: #6f9eb0;
}
.button.delete, button.delete {
- color: var(--col-on-delete);
- background: var(--col-delete);
+ background: #ff7171;
+ border-color: #7d3535;
}
.button:hover, button:hover {
- color: var(--bg-3);
- background: var(--fg-3);
+ background: #fff;
+ border-color: #d0d0d0;
}
.button:active, button:active {
- color: var(--bg-2);
- background: var(--fg-0);
+ background: #d0d0d0;
+ border-color: #808080;
}
.button[disabled], button[disabled] {
- color: var(--fg-0) !important;
- background: var(--bg-3) !important;
+ background: #d0d0d0 !important;
+ border-color: #808080 !important;
opacity: .5;
- cursor: default !important;
+ cursor: not-allowed !important;
}
@@ -410,79 +212,24 @@ button:active, .button:active {
form {
width: 100%;
display: block;
- color: var(--fg-0);
}
form label {
width: 100%;
margin: 1rem 0 .5rem 0;
display: block;
+ color: #10101080;
}
-form input[type="text"],
-form input[type="password"] {
- width: 16em;
- max-width: 100%;
- margin: .5em 0;
- padding: .3em .5em;
+form input {
+ margin: .5rem 0;
+ padding: .3rem .5rem;
display: block;
border-radius: 4px;
border: 1px solid #808080;
font-size: inherit;
font-family: inherit;
color: inherit;
- background-color: var(--bg-0);
}
input[disabled] {
opacity: .5;
cursor: not-allowed;
}
-
-@media screen and (max-width: 720px) {
- main {
- padding-top: 0;
- }
-
- body {
- width: 100%;
- margin: 0;
- font-size: 16px;
- }
-
- nav {
- width: 100%;
- left: -100%;
- font-size: 24px;
- z-index: 1;
- transition: transform .2s cubic-bezier(.2,0,.5,1);
- }
- nav.open {
- transform: translateX(100%);
- }
-
- #toggle-nav {
- display: flex;
- }
-
- main > h1:first-of-type {
- margin-left: 68px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- main > header {
- margin-left: 68px;
- }
- main > header h1 {
- display: flex;
- flex-direction: column;
- font-size: 1.5em;
- }
-
- .card {
- flex-basis: 100%;
- max-width: calc(100vw - 4em);
- }
-
- .artists-group {
- grid-template-columns: repeat(3, 1fr);
- }
-}
diff --git a/admin/static/admin.js b/admin/static/admin.js
index 8bf2480..0763ab7 100644
--- a/admin/static/admin.js
+++ b/admin/static/admin.js
@@ -69,28 +69,3 @@ export function makeMagicList(container, itemSelector, callback) {
if (callback) callback();
});
}
-
-export function hijackClickEvent(container, link) {
- container.addEventListener('click', event => {
- if (event.target.tagName.toLowerCase() === 'a') return;
- event.preventDefault();
- link.dispatchEvent(new MouseEvent('click', {
- bubbles: true,
- cancelable: true,
- view: window,
- ctrlKey: event.ctrlKey,
- metaKey: event.metaKey,
- shiftKey: event.shiftKey,
- altKey: event.altKey,
- button: event.button,
- }));
- });
-}
-
-document.addEventListener("readystatechange", () => {
- const navbar = document.getElementById("navbar");
- document.getElementById("toggle-nav").addEventListener("click", event => {
- event.preventDefault();
- navbar.classList.toggle("open");
- })
-});
diff --git a/admin/static/artists.css b/admin/static/artists.css
deleted file mode 100644
index 516a998..0000000
--- a/admin/static/artists.css
+++ /dev/null
@@ -1,27 +0,0 @@
-.artist {
- padding: .5em;
-
- color: var(--fg-3);
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
- border-radius: 16px;
- text-align: center;
-
- cursor: pointer;
- transition: background .1s ease-out, color .1s ease-out;
-}
-
-.artist:hover {
- background: var(--bg-1);
- text-decoration: hover;
-}
-
-.artist .artist-avatar {
- width: 100%;
- object-fit: cover;
- border-radius: 8px;
-}
-
-.artist .artist-name {
- display: block;
-}
diff --git a/admin/static/artists.js b/admin/static/artists.js
deleted file mode 100644
index 29eab22..0000000
--- a/admin/static/artists.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { hijackClickEvent } from "./admin.js";
-
-document.addEventListener("readystatechange", () => {
- document.querySelectorAll(".artists-group .artist").forEach(el => {
- hijackClickEvent(el, el.querySelector("a.artist-name"))
- });
-});
diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css
index c43d6e9..7a4d34a 100644
--- a/admin/static/edit-account.css
+++ b/admin/static/edit-account.css
@@ -11,11 +11,8 @@ label {
align-items: center;
color: inherit;
}
-form#change-password input,
-form#delete-account input {
- width: 20em;
- min-width: auto;
- max-width: calc(100% - 1em - 2px);
+input {
+ width: min(20rem, calc(100% - 1rem));
margin: .5rem 0;
padding: .3rem .5rem;
display: block;
@@ -28,14 +25,12 @@ form#delete-account input {
.mfa-device {
padding: .75em;
+ background: #f8f8f8f8;
+ border: 1px solid #808080;
+ border-radius: 8px;
margin-bottom: .5em;
display: flex;
justify-content: space-between;
-
- color: var(--fg-3);
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
- border-radius: 16px;
}
.mfa-device div {
@@ -51,14 +46,3 @@ form#delete-account input {
.mfa-device .mfa-device-name {
font-weight: bold;
}
-
-.mfa-device form input {
- display: none !important;
-}
-
-.mfa-actions {
- display: flex;
- flex-direction: row;
- gap: .5em;
- flex-wrap: wrap;
-}
diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css
index 7bf146b..5627e64 100644
--- a/admin/static/edit-artist.css
+++ b/admin/static/edit-artist.css
@@ -1,3 +1,7 @@
+h1 {
+ margin: 0 0 1em 0;
+}
+
#artist {
margin-bottom: 1em;
padding: 1.5em;
@@ -5,9 +9,9 @@
flex-direction: row;
gap: 1.2em;
- border-radius: 16px;
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
+ border-radius: 8px;
+ background: #f8f8f8f8;
+ border: 1px solid #808080;
}
.artist-avatar {
@@ -23,8 +27,7 @@
cursor: pointer;
}
.artist-avatar #remove-avatar {
- margin-top: .5em;
- padding: .3em .6em;
+ padding: .3em .4em;
}
.artist-info {
@@ -50,8 +53,8 @@ input[type="text"] {
font-family: inherit;
font-weight: inherit;
color: inherit;
- background: var(--bg-0);
- border: none;
+ background: #ffffff;
+ border: 1px solid transparent;
border-radius: 4px;
outline: none;
}
@@ -71,7 +74,7 @@ input[type="text"]:focus {
justify-content: right;
}
-.card-header a.button {
+.card-title a.button {
text-decoration: none;
}
@@ -82,17 +85,9 @@ input[type="text"]:focus {
flex-direction: row;
gap: 1em;
align-items: center;
-
- border-radius: 16px;
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
-
- cursor: pointer;
- transition: background .1s;
-}
-
-.credit:hover {
- background: var(--bg-1);
+ background: #f8f8f8;
+ border-radius: 8px;
+ border: 1px solid #808080;
}
.release-artwork {
@@ -101,14 +96,8 @@ input[type="text"]:focus {
border-radius: 4px;
}
-.credit-info {
- overflow: hidden;
-}
.credit-info h3,
.credit-info p {
margin: 0;
font-size: .9em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
}
diff --git a/admin/static/edit-artist.js b/admin/static/edit-artist.js
index 069c25d..8f1bb2b 100644
--- a/admin/static/edit-artist.js
+++ b/admin/static/edit-artist.js
@@ -1,5 +1,3 @@
-import { hijackClickEvent } from "./admin.js";
-
const artistID = document.getElementById("artist").dataset.id;
const nameInput = document.getElementById("name");
const avatarImg = document.getElementById("avatar");
@@ -79,9 +77,3 @@ removeAvatarBtn.addEventListener("click", () => {
avatarImg.src = "/img/default-avatar.png"
saveBtn.disabled = false;
});
-
-document.addEventListener('readystatechange', () => {
- document.querySelectorAll('#releases .credit').forEach(el => {
- hijackClickEvent(el, el.querySelector('.credit-name a'));
- });
-});
diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css
index 434b487..aa70e34 100644
--- a/admin/static/edit-release.css
+++ b/admin/static/edit-release.css
@@ -12,10 +12,8 @@ input[type="text"] {
gap: 1.2em;
border-radius: 8px;
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
-
- transition: background .1s ease-out, color .1s ease-out;
+ background: #f8f8f8f8;
+ border: 1px solid #808080;
}
.release-artwork {
@@ -31,9 +29,7 @@ input[type="text"] {
cursor: pointer;
}
.release-artwork #remove-artwork {
- margin-top: .5em;
- padding: .3em .6em;
- background: var(--bg-3);
+ padding: .3em .4em;
}
.release-info {
@@ -58,17 +54,17 @@ input[type="text"] {
background: transparent;
outline: none;
cursor: pointer;
- transition: background .1s ease-out, border-color .1s ease-out;
}
#title:hover {
- background: var(--bg-3);
- border-color: var(--fg-0);
+ background: #ffffff;
+ border-color: #80808080;
}
#title:active,
#title:focus {
- background: var(--bg-3);
+ background: #ffffff;
+ border-color: #808080;
}
.release-title small {
@@ -79,21 +75,19 @@ input[type="text"] {
width: 100%;
margin: .5em 0;
border-collapse: collapse;
- color: var(--fg-2);
}
.release-info table td {
padding: .2em;
- border-bottom: 1px solid color-mix(in srgb, var(--fg-0) 25%, transparent);
- transition: background .1s ease-out, border-color .1s ease-out;
+ border-bottom: 1px solid #d0d0d0;
}
.release-info table tr td:first-child {
vertical-align: top;
- opacity: .5;
+ opacity: .66;
}
.release-info table tr td:not(:first-child) select:hover,
.release-info table tr td:not(:first-child) input:hover,
.release-info table tr td:not(:first-child) textarea:hover {
- background: var(--bg-3);
+ background: #e8e8e8;
cursor: pointer;
}
.release-info table td select,
@@ -121,25 +115,13 @@ input[type="text"] {
gap: .5em;
flex-direction: row;
justify-content: right;
- color: var(--fg-3);
-}
-
-.release-actions button,
-.release-actions .button {
- color: var(--fg-2);
- background: var(--bg-3);
}
dialog {
width: min(720px, calc(100% - 2em));
padding: 2em;
- border: none;
- border-radius: 16px;
- color: var(--fg-0);
- background-color: var(--bg-0);
- box-shadow: var(--shadow-lg);
-
- transition: color .1s ease-out, background-color .1s ease-out;
+ border: 1px solid #101010;
+ border-radius: 8px;
}
dialog header {
@@ -162,7 +144,7 @@ dialog div.dialog-actions {
gap: .5em;
}
-.card-header a.button {
+.card-title a.button {
text-decoration: none;
}
@@ -170,7 +152,7 @@ dialog div.dialog-actions {
* RELEASE CREDITS
*/
-#credits .credit {
+.card.credits .credit {
margin-bottom: .5em;
padding: .5em;
display: flex;
@@ -178,47 +160,28 @@ dialog div.dialog-actions {
align-items: center;
gap: 1em;
- border-radius: 16px;
- background-color: var(--bg-2);
- box-shadow: var(--shadow-md);
-
- cursor: pointer;
- transition: background .1s ease-out;
-}
-#credits .credit:hover {
- background-color: var(--bg-1);
+ border-radius: 8px;
+ background: #f8f8f8f8;
+ border: 1px solid #808080;
}
-#credits .credit p {
+.card.credits .credit p {
margin: 0;
}
-#credits .credit .artist-avatar {
- border-radius: 12px;
+.card.credits .credit .artist-avatar {
+ border-radius: 8px;
}
-#credits .credit .artist-name {
- color: var(--fg-3);
+.card.credits .credit .artist-name {
font-weight: bold;
}
-#credits .credit .artist-role small {
+.card.credits .credit .artist-role small {
font-size: inherit;
opacity: .66;
}
-#credits .credit .credit-info {
- overflow: hidden;
-}
-
-#credits .credit .credit-info :is(h3, p) {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-
-
#editcredits ul {
margin: 0;
padding: 0;
@@ -234,8 +197,8 @@ dialog div.dialog-actions {
gap: 1em;
border-radius: 8px;
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
+ background: #f8f8f8f8;
+ border: 1px solid #808080;
}
#editcredits .credit {
@@ -269,7 +232,6 @@ dialog div.dialog-actions {
margin: 0;
display: flex;
align-items: center;
- color: inherit;
}
#editcredits .credit .credit-info .credit-attribute input[type="text"] {
@@ -277,17 +239,15 @@ dialog div.dialog-actions {
padding: .2em .4em;
flex-grow: 1;
font-family: inherit;
- border: none;
+ border: 1px solid #8888;
border-radius: 4px;
- color: var(--fg-2);
- background: var(--bg-0);
+ color: inherit;
}
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
margin: 0 .3em;
}
#editcredits .credit .artist-name {
- color: var(--fg-2);
font-weight: bold;
}
@@ -296,12 +256,8 @@ dialog div.dialog-actions {
opacity: .66;
}
-#editcredits .credit .delete {
- margin-right: .5em;
- cursor: pointer;
-}
-#editcredits .credit .delete:hover {
- text-decoration: underline;
+#editcredits .credit button.delete {
+ margin-left: auto;
}
#addcredit ul {
@@ -333,39 +289,24 @@ dialog div.dialog-actions {
* RELEASE LINKS
*/
-#links ul {
- padding: 0;
+.card.links {
display: flex;
gap: .2em;
}
-#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;
+.card.links a.button[data-name="spotify"] {
background-color: #8cff83
}
-#links a.button[data-name="apple music"] {
- color: #101010;
+.card.links a.button[data-name="apple music"] {
background-color: #8cd9ff
}
-#links a.button[data-name="soundcloud"] {
- color: #101010;
+.card.links a.button[data-name="soundcloud"] {
background-color: #fdaa6d
}
-#links a.button[data-name="youtube"] {
- color: #101010;
+.card.links a.button[data-name="youtube"] {
background-color: #ff6e6e
}
@@ -448,6 +389,62 @@ dialog div.dialog-actions {
outline: 1px solid #808080;
}
+/*
+ * RELEASE TRACKS
+ */
+
+.card.tracks .track {
+ margin-bottom: 1em;
+ padding: 1em;
+ display: flex;
+ flex-direction: column;
+ gap: .5em;
+
+ border-radius: 8px;
+ background: #f8f8f8f8;
+ border: 1px solid #808080;
+}
+
+.card.tracks .track h3,
+.card.tracks .track p {
+ margin: 0;
+}
+
+.card.tracks h2.track-title {
+ margin: 0;
+ display: flex;
+ gap: .5em;
+}
+
+.card.tracks h2.track-title .track-number {
+ opacity: .5;
+}
+
+.card.tracks .track-album {
+ margin-left: auto;
+ font-style: italic;
+ font-size: .75em;
+ opacity: .5;
+}
+
+.card.tracks .track-album.empty {
+ color: #ff2020;
+ opacity: 1;
+}
+
+.card.tracks .track-description {
+ font-style: italic;
+}
+
+.card.tracks .track-lyrics {
+ max-height: 10em;
+ overflow-y: scroll;
+}
+
+.card.tracks .track .empty {
+ opacity: 0.75;
+}
+
#edittracks ul {
padding: 0;
list-style: none;
@@ -499,17 +496,15 @@ dialog div.dialog-actions {
padding: .5em;
display: flex;
gap: .5em;
- background-color: var(--bg-0);
cursor: pointer;
- transition: background-color .1s ease-out, color .1s ease-out;
}
#addtrack ul li.new-track:nth-child(even) {
- background: color-mix(in srgb, var(--bg-0) 95%, #fff);
+ background: #f0f0f0;
}
#addtrack ul li.new-track:hover {
- background: color-mix(in srgb, var(--bg-0) 90%, #fff);
+ background: #e0e0e0;
}
@media only screen and (max-width: 1105px) {
diff --git a/admin/static/edit-release.js b/admin/static/edit-release.js
index ca2754f..11d21f0 100644
--- a/admin/static/edit-release.js
+++ b/admin/static/edit-release.js
@@ -1,5 +1,3 @@
-import { hijackClickEvent } from "./admin.js";
-
const releaseID = document.getElementById("release").dataset.id;
const titleInput = document.getElementById("title");
const artworkImg = document.getElementById("artwork");
@@ -98,9 +96,3 @@ removeArtworkBtn.addEventListener("click", () => {
artworkData = "";
saveBtn.disabled = false;
});
-
-document.addEventListener("readystatechange", () => {
- document.querySelectorAll("#credits .credit").forEach(el => {
- hijackClickEvent(el, el.querySelector(".artist-name a"));
- });
-});
diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css
index f292ca5..600b680 100644
--- a/admin/static/edit-track.css
+++ b/admin/static/edit-track.css
@@ -1,5 +1,9 @@
@import url("/admin/static/release-list-item.css");
+h1 {
+ margin: 0 0 .5em 0;
+}
+
#track {
margin-bottom: 1em;
padding: .5em 1.5em 1.5em 1.5em;
@@ -7,9 +11,9 @@
flex-direction: row;
gap: 1.2em;
- border-radius: 16px;
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
+ border-radius: 8px;
+ background: #f8f8f8f8;
+ border: 1px solid #808080;
}
.track-info {
@@ -45,8 +49,7 @@
font-weight: inherit;
font-family: inherit;
font-size: inherit;
- background: var(--bg-0);
- border: none;
+ border: 1px solid transparent;
border-radius: 4px;
outline: none;
color: inherit;
diff --git a/admin/static/index.css b/admin/static/index.css
new file mode 100644
index 0000000..0e5121f
--- /dev/null
+++ b/admin/static/index.css
@@ -0,0 +1 @@
+@import url("/admin/static/release-list-item.css");
diff --git a/admin/static/index.js b/admin/static/index.js
index 60bdfd0..e69de29 100644
--- a/admin/static/index.js
+++ b/admin/static/index.js
@@ -1,74 +0,0 @@
-const newReleaseBtn = document.getElementById("create-release");
-const newArtistBtn = document.getElementById("create-artist");
-const newTrackBtn = document.getElementById("create-track");
-
-newReleaseBtn.addEventListener("click", event => {
- event.preventDefault();
- const id = prompt("Enter an ID for this release:");
- if (id == null || id == "") return;
-
- fetch("/api/v1/music", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({id})
- }).then(res => {
- if (res.ok) location = "/admin/releases/" + id;
- else {
- res.text().then(err => {
- alert("Request failed: " + err);
- console.error(err);
- });
- }
- }).catch(err => {
- alert("Failed to create release. Check the console for details.");
- console.error(err);
- });
-});
-
-newArtistBtn.addEventListener("click", event => {
- event.preventDefault();
- const id = prompt("Enter an ID for this artist:");
- if (id == null || id == "") return;
-
- fetch("/api/v1/artist", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({id})
- }).then(res => {
- res.text().then(text => {
- if (res.ok) {
- location = "/admin/artists/" + id;
- } else {
- alert("Request failed: " + text);
- console.error(text);
- }
- })
- }).catch(err => {
- alert("Failed to create artist. Check the console for details.");
- console.error(err);
- });
-});
-
-newTrackBtn.addEventListener("click", event => {
- event.preventDefault();
- const title = prompt("Enter an title for this track:");
- if (title == null || title == "") return;
-
- fetch("/api/v1/track", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({title})
- }).then(res => {
- res.text().then(text => {
- if (res.ok) {
- location = "/admin/tracks/" + text;
- } else {
- alert("Request failed: " + text);
- console.error(text);
- }
- })
- }).catch(err => {
- alert("Failed to create track. Check the console for details.");
- console.error(err);
- });
-});
diff --git a/admin/static/logs.css b/admin/static/logs.css
index 8da60d0..f0df299 100644
--- a/admin/static/logs.css
+++ b/admin/static/logs.css
@@ -2,14 +2,8 @@ main {
width: min(1080px, calc(100% - 2em))!important
}
-form#search-form {
- width: calc(100% - 2em);
+form {
margin: 1em 0;
- padding: 1em;
- border-radius: 16px;
- color: var(--fg-0);
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
}
div#search {
@@ -18,25 +12,24 @@ div#search {
#search input {
margin: 0;
- padding: .3em .8em;
flex-grow: 1;
- border: none;
- border-radius: 16px;
- color: var(--fg-1);
- background: var(--bg-0);
- box-shadow: var(--shadow-sm);
+
+ border-right: none;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
}
#search button {
- margin-left: .5em;
- padding: 0 .8em;
+ padding: 0 .5em;
+
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
}
form #filters p {
margin: .5em 0 0 0;
}
form #filters label {
- color: inherit;
display: inline;
}
form #filters input {
@@ -46,15 +39,6 @@ form #filters input {
#logs {
width: 100%;
- overflow: scroll;
-}
-@media screen and (max-width: 720px) {
- #logs {
- font-size: 12px;
- }
-}
-
-#logs table{
border-collapse: collapse;
}
@@ -73,10 +57,6 @@ form #filters input {
padding: .4em .8em;
}
-#logs .log {
- color: var(--fg-2);
-}
-
td, th {
width: 1%;
text-align: left;
@@ -94,14 +74,13 @@ td.log-content {
white-space: collapse;
}
-#logs .log:hover {
- background: color-mix(in srgb, var(--fg-3) 10%, transparent);
+.log:hover {
+ background: #fff8;
}
-#logs .log.warn {
- color: var(--col-on-warn);
- background: var(--col-warn);
+.log.warn {
+ background: #ffe86a;
}
-#logs .log.warn:hover {
- background: var(--col-warn-hover);
+.log.warn:hover {
+ background: #ffec81;
}
diff --git a/admin/static/music.css b/admin/static/music.css
new file mode 100644
index 0000000..9fcd731
--- /dev/null
+++ b/admin/static/music.css
@@ -0,0 +1,82 @@
+@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;
+}
diff --git a/admin/static/music.js b/admin/static/music.js
new file mode 100644
index 0000000..6e4a7a9
--- /dev/null
+++ b/admin/static/music.js
@@ -0,0 +1,74 @@
+const newReleaseBtn = document.getElementById("create-release");
+const newArtistBtn = document.getElementById("create-artist");
+const newTrackBtn = document.getElementById("create-track");
+
+newReleaseBtn.addEventListener("click", event => {
+ event.preventDefault();
+ const id = prompt("Enter an ID for this release:");
+ if (id == null || id == "") return;
+
+ fetch("/api/v1/music", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({id})
+ }).then(res => {
+ if (res.ok) location = "/admin/music/release/" + id;
+ else {
+ res.text().then(err => {
+ alert("Request failed: " + err);
+ console.error(err);
+ });
+ }
+ }).catch(err => {
+ alert("Failed to create release. Check the console for details.");
+ console.error(err);
+ });
+});
+
+newArtistBtn.addEventListener("click", event => {
+ event.preventDefault();
+ const id = prompt("Enter an ID for this artist:");
+ if (id == null || id == "") return;
+
+ fetch("/api/v1/artist", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({id})
+ }).then(res => {
+ res.text().then(text => {
+ if (res.ok) {
+ location = "/admin/music/artist/" + id;
+ } else {
+ alert("Request failed: " + text);
+ console.error(text);
+ }
+ })
+ }).catch(err => {
+ alert("Failed to create artist. Check the console for details.");
+ console.error(err);
+ });
+});
+
+newTrackBtn.addEventListener("click", event => {
+ event.preventDefault();
+ const title = prompt("Enter an title for this track:");
+ if (title == null || title == "") return;
+
+ fetch("/api/v1/track", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({title})
+ }).then(res => {
+ res.text().then(text => {
+ if (res.ok) {
+ location = "/admin/music/track/" + text;
+ } else {
+ alert("Request failed: " + text);
+ console.error(text);
+ }
+ })
+ }).catch(err => {
+ alert("Failed to create track. Check the console for details.");
+ console.error(err);
+ });
+});
diff --git a/admin/static/release-list-item.css b/admin/static/release-list-item.css
new file mode 100644
index 0000000..638eac0
--- /dev/null
+++ b/admin/static/release-list-item.css
@@ -0,0 +1,87 @@
+.release {
+ margin-bottom: 1em;
+ padding: 1em;
+ display: flex;
+ flex-direction: row;
+ gap: 1em;
+
+ border-radius: 8px;
+ background: #f8f8f8f8;
+ border: 1px solid #808080;
+}
+
+.release h3,
+.release p {
+ margin: 0;
+}
+
+.release-artwork {
+ width: 96px;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.release-artwork img {
+ width: 100%;
+ aspect-ratio: 1;
+}
+
+.release-title small {
+ opacity: .75;
+}
+
+.release-links {
+ margin: .5em 0;
+ padding: 0;
+ display: flex;
+ flex-direction: row;
+ list-style: none;
+ flex-wrap: wrap;
+ gap: .5em;
+}
+
+.release-links li {
+ flex-grow: 1;
+}
+
+.release-links a {
+ padding: .5em;
+ display: block;
+
+ border-radius: 8px;
+ text-decoration: none;
+ color: #f0f0f0;
+ background: #303030;
+ text-align: center;
+
+ transition: color .1s, background .1s;
+}
+
+.release-links a:hover {
+ color: #303030;
+ background: #f0f0f0;
+}
+
+.release-actions {
+ margin-top: .5em;
+}
+
+.release-actions a {
+ margin-right: .3em;
+ padding: .3em .5em;
+ display: inline-block;
+
+ border-radius: 4px;
+ background: #e0e0e0;
+
+ transition: color .1s, background .1s;
+}
+
+.release-actions a:hover {
+ color: #303030;
+ background: #f0f0f0;
+
+ text-decoration: none;
+}
diff --git a/admin/static/releases.css b/admin/static/releases.css
deleted file mode 100644
index 0694875..0000000
--- a/admin/static/releases.css
+++ /dev/null
@@ -1,80 +0,0 @@
-.release {
- margin-bottom: 1em;
- padding: 1em;
- display: flex;
- flex-direction: row;
- gap: 1em;
-
- border-radius: 16px;
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
-
- transition: background .1s ease-out, color .1s ease-out;
-}
-
-.release h3,
-.release p {
- margin: 0;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.release .release-artwork {
- margin: auto 0;
- width: 96px;
-
- display: flex;
- justify-content: center;
- align-items: center;
- border-radius: 4px;
- overflow: hidden;
- box-shadow: var(--shadow-sm);
-}
-
-.release .release-artwork img {
- width: 100%;
- aspect-ratio: 1;
-}
-
-.release .release-info {
- max-width: calc(100% - 96px - 1em);
-}
-
-.release .release-title small {
- opacity: .75;
-}
-
-.release .release-links {
- margin: .5em 0;
- padding: 0;
- display: flex;
- flex-direction: row;
- list-style: none;
- flex-wrap: wrap;
- gap: .5em;
-}
-
-.release .release-actions {
- margin-top: .5em;
- user-select: none;
- color: var(--fg-3);
-}
-
-.release .release-actions a {
- margin-right: .3em;
- padding: .3em .5em;
- display: inline-block;
-
- border-radius: 4px;
- background: var(--bg-3);
- box-shadow: var(--shadow-sm);
-
- transition: color .1s ease-out, background .1s ease-out;
-}
-
-.release .release-actions a:hover {
- background: var(--bg-0);
- color: var(--fg-3);
- text-decoration: none;
-}
diff --git a/admin/static/tracks.css b/admin/static/tracks.css
deleted file mode 100644
index c36c1b1..0000000
--- a/admin/static/tracks.css
+++ /dev/null
@@ -1,127 +0,0 @@
-#tracks h2.track-title {
- margin: 0;
- display: flex;
- gap: .5em;
-}
-
-#tracks .track {
- margin-bottom: 1em;
- padding: 1em;
- display: flex;
- flex-direction: column;
- gap: .5em;
-
- border-radius: 16px;
- background: var(--bg-2);
- box-shadow: var(--shadow-md);
-
- transition: background .1s ease-out, color .1s ease-out;
-}
-
-#tracks .track h3,
-#tracks .track p {
- margin: 0;
-}
-
-#tracks h2.track-title {
- margin: 0;
- display: flex;
- gap: .5em;
-}
-
-#tracks h2.track-title .track-number {
- opacity: .5;
-}
-
-#tracks a:hover {
- text-decoration: underline;
-}
-
-#tracks .track-album {
- margin-left: auto;
- font-style: italic;
- font-size: .75em;
- opacity: .5;
-}
-
-#tracks .track-album.empty {
- color: #ff2020;
- opacity: 1;
-}
-
-#tracks .track-description {
- font-style: italic;
-}
-
-#tracks .track-lyrics {
- max-height: 10em;
- overflow-y: scroll;
-}
-
-#tracks .track .empty {
- opacity: 0.75;
-}
-
-
-.card h2.track-title {
- margin: 0;
- display: flex;
- flex-direction: row;
- /*
- justify-content: space-between;
- */
-}
-
-/*
-.track {
- margin-bottom: 1em;
- padding: 1em;
- display: flex;
- flex-direction: column;
- gap: .5em;
-
- border-radius: 8px;
- background-color: var(--bg-2);
- box-shadow: var(--shadow-md);
-
- transition: color .1s ease-out, background-color .1s ease-out;
-}
-
-.track p {
- margin: 0;
-}
-
-.track-id {
- width: fit-content;
- font-family: "Monaspace Argon", monospace;
- font-size: .8em;
- font-style: italic;
- line-height: 1em;
- user-select: all;
-}
-
-.track-album {
- margin-left: auto;
- font-style: italic;
- font-size: .75em;
- opacity: .5;
-}
-
-.track-album.empty {
- color: #ff2020;
- opacity: 1;
-}
-
-.track-description {
- font-style: italic;
-}
-
-.track-lyrics {
- max-height: 10em;
- overflow-y: scroll;
-}
-
-.track .empty {
- opacity: 0.75;
-}
-*/
diff --git a/admin/templates/html/artists.html b/admin/templates/html/artists.html
deleted file mode 100644
index f4a6432..0000000
--- a/admin/templates/html/artists.html
+++ /dev/null
@@ -1,26 +0,0 @@
-{{define "head"}}
-Artists - ari melody 💫
-
-
-{{end}}
-
-{{define "content"}}
-
-
-
- {{if .Artists}}
-
- {{range .Artists}}
- {{block "artist" .}}{{end}}
- {{end}}
-
- {{else}}
- There are no artists.
- {{end}}
-
-
-
-{{end}}
diff --git a/admin/templates/html/components/artist/artist.html b/admin/templates/html/components/artist/artist.html
deleted file mode 100644
index 86ac4cc..0000000
--- a/admin/templates/html/components/artist/artist.html
+++ /dev/null
@@ -1,6 +0,0 @@
-{{define "artist"}}
-
-{{end}}
diff --git a/admin/templates/html/components/track/track.html b/admin/templates/html/components/track/track.html
deleted file mode 100644
index 4db20a3..0000000
--- a/admin/templates/html/components/track/track.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{{define "track"}}
-
-
- {{if .Number}}
- {{.Number}}
- {{end}}
- {{.Title}}
-
-
-
Description
- {{if .Description}}
-
{{.GetDescriptionHTML}}
- {{else}}
-
No description provided.
- {{end}}
-
-
Lyrics
- {{if .Lyrics}}
-
{{.GetLyricsHTML}}
- {{else}}
-
There are no lyrics.
- {{end}}
-
-{{end}}
diff --git a/admin/templates/html/index.html b/admin/templates/html/index.html
deleted file mode 100644
index afb9471..0000000
--- a/admin/templates/html/index.html
+++ /dev/null
@@ -1,61 +0,0 @@
-{{define "head"}}
-Admin - ari melody 💫
-
-
-
-
-{{end}}
-
-{{define "content"}}
-
- Dashboard
-
-
-
-
- {{if .Artists}}
- {{range .Releases}}
- {{block "release" .}}{{end}}
- {{end}}
- {{else}}
-
There are no releases.
- {{end}}
-
-
-
-
- {{if .Artists}}
-
- {{range .Artists}}
- {{block "artist" .}}{{end}}
- {{end}}
-
- {{else}}
-
There are no artists.
- {{end}}
-
-
-
-
-
"Orphaned" tracks that have not yet been bound to a release.
-
- {{range .Tracks}}
- {{block "track" .}}{{end}}
- {{end}}
-
-
-
-
-
-
-
-{{end}}
diff --git a/admin/templates/html/layout.html b/admin/templates/html/layout.html
deleted file mode 100644
index bf1b542..0000000
--- a/admin/templates/html/layout.html
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{block "head" .}}{{end}}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{if .Session.Account}}
-
-
- music
-
-
-
- {{end}}
-
-
-
- {{if .Session.Account}}
-
-
- {{else}}
-
-
- {{end}}
-
-
-
-
-
-
- {{block "content" .}}{{end}}
-
- {{template "prideflag"}}
-
-
-
diff --git a/admin/templates/html/releases.html b/admin/templates/html/releases.html
deleted file mode 100644
index ed5aec6..0000000
--- a/admin/templates/html/releases.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{{define "head"}}
-Releases - ari melody 💫
-
-
-{{end}}
-
-{{define "content"}}
-
-
- Releases ({{len .Releases}} total)
- Create New
-
-
- {{if .Releases}}
-
- {{range .Releases}}
- {{block "release" .}}{{end}}
- {{end}}
-
- {{else}}
- There are no releases.
- {{end}}
-
-{{end}}
diff --git a/admin/templates/html/tracks.html b/admin/templates/html/tracks.html
deleted file mode 100644
index 6bb6f18..0000000
--- a/admin/templates/html/tracks.html
+++ /dev/null
@@ -1,34 +0,0 @@
-{{define "head"}}
-Releases - ari melody 💫
-
-
-{{end}}
-
-{{define "content"}}
-
-
-
-
- {{range $Track := .Tracks}}
-
-
- {{if $Track.Description}}
-
{{$Track.GetDescriptionHTML}}
- {{else}}
-
No description provided.
- {{end}}
- {{if $Track.Lyrics}}
-
{{$Track.GetLyricsHTML}}
- {{else}}
-
There are no lyrics.
- {{end}}
-
- {{end}}
-
-
-{{end}}
diff --git a/admin/templates/index.go b/admin/templates/index.go
new file mode 100644
index 0000000..e66c6cd
--- /dev/null
+++ b/admin/templates/index.go
@@ -0,0 +1,13 @@
+package templates
+
+import (
+ "html/template"
+ "path/filepath"
+)
+
+var IndexTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "components", "release", "release-list-item.html"),
+ filepath.Join("admin", "views", "index.html"),
+))
diff --git a/admin/templates/login.go b/admin/templates/login.go
new file mode 100644
index 0000000..55e7a57
--- /dev/null
+++ b/admin/templates/login.go
@@ -0,0 +1,42 @@
+package templates
+
+import (
+ "html/template"
+ "path/filepath"
+)
+
+var LoginTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "login.html"),
+))
+var LoginTOTPTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "login-totp.html"),
+))
+var RegisterTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "register.html"),
+))
+var LogoutTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "logout.html"),
+))
+var AccountTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "edit-account.html"),
+))
+var TotpSetupTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "totp-setup.html"),
+))
+var TotpConfirmTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "totp-confirm.html"),
+))
diff --git a/admin/templates/logs.go b/admin/templates/logs.go
new file mode 100644
index 0000000..7d4eccb
--- /dev/null
+++ b/admin/templates/logs.go
@@ -0,0 +1,41 @@
+package templates
+
+import (
+ "arimelody-web/log"
+ "fmt"
+ "html/template"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+var LogsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{
+ "parseLevel": func(level log.LogLevel) string {
+ switch level {
+ case log.LEVEL_INFO:
+ return "INFO"
+ 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")
+ },
+}).ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "logs.html"),
+))
diff --git a/admin/templates/music.go b/admin/templates/music.go
new file mode 100644
index 0000000..3e9a772
--- /dev/null
+++ b/admin/templates/music.go
@@ -0,0 +1,54 @@
+package templates
+
+import (
+ "html/template"
+ "path/filepath"
+)
+
+var MusicTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "components", "release", "release-list-item.html"),
+ filepath.Join("admin", "views", "music.html"),
+))
+
+var ReleaseTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "edit-release.html"),
+))
+var ArtistTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "views", "edit-artist.html"),
+))
+var TrackTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "views", "layout.html"),
+ filepath.Join("view", "prideflag.html"),
+ filepath.Join("admin", "components", "release", "release-list-item.html"),
+ filepath.Join("admin", "views", "edit-track.html"),
+))
+
+var EditCreditsTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "credits", "editcredits.html"),
+))
+var AddCreditTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "credits", "addcredit.html"),
+))
+var NewCreditTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "credits", "newcredit.html"),
+))
+
+var EditLinksTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "links", "editlinks.html"),
+))
+
+var EditTracksTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "tracks", "edittracks.html"),
+))
+var AddTrackTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "tracks", "addtrack.html"),
+))
+var NewTrackTemplate = template.Must(template.ParseFiles(
+ filepath.Join("admin", "components", "tracks", "newtrack.html"),
+))
diff --git a/admin/templates/templates.go b/admin/templates/templates.go
deleted file mode 100644
index 51b1376..0000000
--- a/admin/templates/templates.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package templates
-
-import (
- "arimelody-web/log"
- _ "embed"
- "fmt"
- "html/template"
- "strings"
- "time"
-)
-
-//go:embed "html/layout.html"
-var layoutHTML string
-//go:embed "html/prideflag.html"
-var prideflagHTML string
-
-//go:embed "html/index.html"
-var indexHTML string
-
-//go:embed "html/register.html"
-var registerHTML string
-//go:embed "html/login.html"
-var loginHTML string
-//go:embed "html/login-totp.html"
-var loginTotpHTML string
-//go:embed "html/totp-confirm.html"
-var totpConfirmHTML string
-//go:embed "html/totp-setup.html"
-var totpSetupHTML string
-//go:embed "html/logout.html"
-var logoutHTML string
-
-//go:embed "html/logs.html"
-var logsHTML string
-
-//go:embed "html/edit-account.html"
-var editAccountHTML 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"
-var editReleaseHTML string
-//go:embed "html/edit-artist.html"
-var editArtistHTML string
-//go:embed "html/edit-track.html"
-var editTrackHTML string
-
-//go:embed "html/components/credit/newcredit.html"
-var componentNewCreditHTML string
-//go:embed "html/components/credit/addcredit.html"
-var componentAddCreditHTML string
-//go:embed "html/components/credit/editcredits.html"
-var componentEditCreditsHTML string
-
-//go:embed "html/components/link/editlinks.html"
-var componentEditLinksHTML string
-
-//go:embed "html/components/release/release.html"
-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/track/newtrack.html"
-var componentNewTrackHTML string
-//go:embed "html/components/track/addtrack.html"
-var componentAddTrackHTML string
-//go:embed "html/components/track/edittracks.html"
-var componentEditTracksHTML string
-
-var BaseTemplate = template.Must(
- 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(
- strings.Join([]string{
- indexHTML,
- componentReleaseHTML,
- componentArtistHTML,
- componentTrackHTML,
- }, "\n"),
-))
-
-
-
-var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML))
-var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML))
-var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML))
-var LogoutTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(logoutHTML))
-var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editAccountHTML))
-var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML))
-var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML))
-
-
-
-var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{
- "parseLevel": parseLevel,
- "titleCase": titleCase,
- "toLower": toLower,
- "prettyTime": prettyTime,
-}).Parse(logsHTML))
-
-
-
-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 EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
- strings.Join([]string{
- editTrackHTML,
- componentReleaseHTML,
- }, "\n"),
-))
-
-
-
-var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML))
-var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML))
-var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML))
-
-var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditLinksHTML))
-
-var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML))
-var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML))
-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")
-}
diff --git a/admin/trackhttp.go b/admin/trackhttp.go
deleted file mode 100644
index bcb5220..0000000
--- a/admin/trackhttp.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package admin
-
-import (
- "fmt"
- "net/http"
- "strings"
-
- "arimelody-web/admin/templates"
- "arimelody-web/controller"
- "arimelody-web/model"
-)
-
-func serveTracks(app *model.AppState) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- session := r.Context().Value("session").(*model.Session)
-
- slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/tracks")[1:], "/")
- trackID := slices[0]
-
- if len(trackID) > 0 {
- serveTrack(app, trackID).ServeHTTP(w, r)
- return
- }
-
- tracks, err := controller.GetAllTracks(app.DB)
- if err != nil {
- fmt.Printf("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)
- return
- }
- if track == nil {
- http.NotFound(w, r)
- return
- }
-
- releases, err := controller.GetTrackReleases(app.DB, track.ID, true)
- if err != nil {
- fmt.Printf("WARN: Failed to fetch releases for track %s: %s\n", trackID, err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- type TrackResponse struct {
- adminPageData
- Track *model.Track
- Releases []*model.Release
- }
-
- err = templates.EditTrackTemplate.Execute(w, TrackResponse{
- adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
- Track: track,
- Releases: releases,
- })
- 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)
- }
- })
-}
diff --git a/admin/templates/html/edit-account.html b/admin/views/edit-account.html
similarity index 80%
rename from admin/templates/html/edit-account.html
rename to admin/views/edit-account.html
index a63d37a..6c17088 100644
--- a/admin/templates/html/edit-account.html
+++ b/admin/views/edit-account.html
@@ -14,10 +14,10 @@
{{end}}
Account Settings ({{.Session.Account.Username}})
+
+
Change Password
+
-
+
+
MFA Devices
+
-
{{if .TOTPs}}
{{range .TOTPs}}
@@ -46,10 +44,7 @@
Added: {{.CreatedAtString}}
{{end}}
@@ -57,30 +52,28 @@
You have no MFA devices.
{{end}}
-
+
+
Danger Zone
+
-
Clicking the button below will delete your account.
This action is irreversible .
You will need to enter your password and TOTP below.
-
diff --git a/admin/templates/html/edit-artist.html b/admin/views/edit-artist.html
similarity index 84%
rename from admin/templates/html/edit-artist.html
rename to admin/views/edit-artist.html
index e9b829a..f0e03dc 100644
--- a/admin/templates/html/edit-artist.html
+++ b/admin/views/edit-artist.html
@@ -2,7 +2,6 @@
Editing {{.Artist.Name}} - ari melody 💫
-
{{end}}
{{define "content"}}
@@ -30,16 +29,16 @@
-
-
+
+
Featured in
+
+
{{if .Credits}}
{{range .Credits}}
-
+
{{.Release.PrintArtists true true}}
Role: {{.Role}}
@@ -55,10 +54,10 @@
{{end}}
-
-
+
+
Danger Zone
+
+
Clicking the button below will delete this artist.
This action is irreversible .
diff --git a/admin/templates/html/edit-release.html b/admin/views/edit-release.html
similarity index 70%
rename from admin/templates/html/edit-release.html
rename to admin/views/edit-release.html
index 6b47f75..34ee86e 100644
--- a/admin/templates/html/edit-release.html
+++ b/admin/views/edit-release.html
@@ -2,13 +2,10 @@
Editing {{.Release.Title}} - ari melody 💫
-
-
{{end}}
{{define "content"}}
- Editing {{.Release.Title}}
-
-
+
+
Credits ({{len .Release.Credits}})
+
Edit
+
+
{{range .Release.Credits}}
-
-
-
- {{range .Release.Links}}
- {{.Name}}
- {{end}}
-
+
+
Links ({{len .Release.Links}})
+
Edit
-
-
-
- {{range $i, $track := .Release.Tracks}}
- {{block "track" .}}{{end}}
+
+ {{range .Release.Links}}
+
{{.Name}}
{{end}}
-
-
+
+
Tracklist ({{len .Release.Tracks}})
+
Edit
+
+
+ {{range $i, $track := .Release.Tracks}}
+
+
+
+
Description
+ {{if $track.Description}}
+
{{$track.GetDescriptionHTML}}
+ {{else}}
+
No description provided.
+ {{end}}
+
+
Lyrics
+ {{if $track.Lyrics}}
+
{{$track.GetLyricsHTML}}
+ {{else}}
+
There are no lyrics.
+ {{end}}
+
+ {{end}}
+
+
+
+
Danger Zone
+
+
Clicking the button below will delete this release.
This action is irreversible .
diff --git a/admin/templates/html/edit-track.html b/admin/views/edit-track.html
similarity index 87%
rename from admin/templates/html/edit-track.html
rename to admin/views/edit-track.html
index 14f389b..56e0ae4 100644
--- a/admin/templates/html/edit-track.html
+++ b/admin/views/edit-track.html
@@ -2,8 +2,6 @@
Editing Track - ari melody 💫
-
-
{{end}}
{{define "content"}}
@@ -41,10 +39,10 @@
+
+
Featured in
+
-
{{if .Releases}}
{{range .Releases}}
{{block "release" .}}{{end}}
@@ -54,10 +52,10 @@
{{end}}
+
+
Danger Zone
+
-
Clicking the button below will delete this track.
This action is irreversible .
diff --git a/admin/views/index.html b/admin/views/index.html
new file mode 100644
index 0000000..6032849
--- /dev/null
+++ b/admin/views/index.html
@@ -0,0 +1,26 @@
+{{define "head"}}
+
Admin - ari melody 💫
+
+
+{{end}}
+
+{{define "content"}}
+
+ Admin Dashboard
+
+
+ {{if .LatestRelease}}
+
Latest Release
+ {{block "release" .LatestRelease}}{{end}}
+ {{else}}
+
There are no releases.
+ {{end}}
+
+
+
+
+
+{{end}}
diff --git a/admin/views/layout.html b/admin/views/layout.html
new file mode 100644
index 0000000..7c7bfce
--- /dev/null
+++ b/admin/views/layout.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+ {{block "head" .}}{{end}}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{if .Session.Account}}
+
+
+
+ {{end}}
+
+
+
+ {{if .Session.Account}}
+
+
+ {{else}}
+
+ {{end}}
+
+
+
+ {{block "content" .}}{{end}}
+
+ {{template "prideflag"}}
+
+
+
diff --git a/admin/templates/html/login-totp.html b/admin/views/login-totp.html
similarity index 84%
rename from admin/templates/html/login-totp.html
rename to admin/views/login-totp.html
index f03d11b..33e8c88 100644
--- a/admin/templates/html/login-totp.html
+++ b/admin/views/login-totp.html
@@ -1,6 +1,7 @@
{{define "head"}}
Login - ari melody 💫
+
{{end}}
diff --git a/admin/templates/html/login.html b/admin/views/login.html
similarity index 86%
rename from admin/templates/html/login.html
rename to admin/views/login.html
index aa77967..fbb7294 100644
--- a/admin/templates/html/login.html
+++ b/admin/views/login.html
@@ -1,14 +1,8 @@
{{define "head"}}
Login - ari melody 💫
+
{{end}}
diff --git a/admin/templates/html/logout.html b/admin/views/logout.html
similarity index 100%
rename from admin/templates/html/logout.html
rename to admin/views/logout.html
diff --git a/admin/templates/html/logs.html b/admin/views/logs.html
similarity index 67%
rename from admin/templates/html/logs.html
rename to admin/views/logs.html
index 4f24c72..e3a3ccb 100644
--- a/admin/templates/html/logs.html
+++ b/admin/views/logs.html
@@ -1,6 +1,7 @@
{{define "head"}}
Audit Logs - ari melody 💫
+
{{end}}
@@ -8,7 +9,7 @@
Audit Logs
-