Compare commits
23 commits
main
...
feature/bl
| Author | SHA1 | Date | |
|---|---|---|---|
| bf9289400e | |||
| 55b0513f67 | |||
| 823fe2e356 | |||
| 94774352b3 | |||
| 31a02a12a5 | |||
| bd2dc806d5 | |||
| ddbf3444eb | |||
| faf6095d16 | |||
| 3d64333b4f | |||
| 3da0249555 | |||
| 053faeb493 | |||
| 0596edc4b2 | |||
| fece0f5da6 | |||
| 486c9ae641 | |||
| 82a4cde8c9 | |||
| bc90015a33 | |||
| dd8e503b61 | |||
| 99e5eb290f | |||
| 0796ea8fde | |||
| 5aa241e4d6 | |||
| 835dd344ca | |||
| 1a8dc4d9ce | |||
| 8eb432539c |
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
6
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
386
admin/auth/authhttp.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
{{range $Artist := .Artists}}
|
||||
<li class="new-artist"
|
||||
data-id="{{$Artist.ID}}"
|
||||
hx-get="/admin/releases/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
|
||||
hx-get="/admin/music/release/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
|
||||
hx-target="#editcredits ul"
|
||||
hx-swap="beforeend"
|
||||
>
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
<h2>Editing: Credits</h2>
|
||||
<a id="add-credit"
|
||||
class="button new"
|
||||
href="/admin/releases/{{.ID}}/addcredit"
|
||||
hx-get="/admin/releases/{{.ID}}/addcredit"
|
||||
href="/admin/music/release/{{.ID}}/addcredit"
|
||||
hx-get="/admin/music/release/{{.ID}}/addcredit"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Add</a>
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
</div>
|
||||
<div class="release-info">
|
||||
<h3 class="release-title">
|
||||
<a href="/admin/releases/{{.ID}}">{{.Title}}</a>
|
||||
<a href="/admin/music/release/{{.ID}}">{{.Title}}</a>
|
||||
<small>
|
||||
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
|
||||
{{if not .Visible}}(hidden){{end}}
|
||||
|
|
@ -13,9 +13,9 @@
|
|||
</h3>
|
||||
<p class="release-artists">{{.PrintArtists true true}}</p>
|
||||
<p class="release-type-single">{{.ReleaseType}}
|
||||
(<a href="/admin/releases/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
|
||||
(<a href="/admin/music/release/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
|
||||
<div class="release-actions">
|
||||
<a href="/admin/releases/{{.ID}}">Edit</a>
|
||||
<a href="/admin/music/release/{{.ID}}">Edit</a>
|
||||
<a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
</li>
|
||||
<li class="new-track"
|
||||
data-id="{{$Track.ID}}"
|
||||
hx-get="/admin/releases/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
|
||||
hx-get="/admin/music/release/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
|
||||
hx-target="#edittracks ul"
|
||||
hx-swap="beforeend"
|
||||
>
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
<h2>Editing: Tracks</h2>
|
||||
<a id="add-track"
|
||||
class="button new"
|
||||
href="/admin/releases/{{.Release.ID}}/addtrack"
|
||||
hx-get="/admin/releases/{{.Release.ID}}/addtrack"
|
||||
href="/admin/music/release/{{.Release.ID}}/addtrack"
|
||||
hx-get="/admin/music/release/{{.Release.ID}}/addtrack"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Add</a>
|
||||
56
admin/core/funcs.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
551
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("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
|
||||
}))
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
54
admin/music/artisthttp.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
69
admin/music/musichttp.go
Normal file
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
54
admin/music/trackhttp.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"))
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
1
admin/static/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import url("/admin/static/release-list-item.css");
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
82
admin/static/music.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
74
admin/static/music.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
87
admin/static/release-list-item.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
*/
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{{define "head"}}
|
||||
<title>Artists - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/artists.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<header>
|
||||
<h1>Artists <small>({{len .Artists}} total)</small></h2>
|
||||
<a class="button new" id="create-artist">Create New</a>
|
||||
</header>
|
||||
|
||||
{{if .Artists}}
|
||||
<div class="artists-group">
|
||||
{{range .Artists}}
|
||||
{{block "artist" .}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p>There are no artists.</p>
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/artists.js"></script>
|
||||
{{end}}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{{define "artist"}}
|
||||
<div class="artist">
|
||||
<img src="{{.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
||||
<a href="/admin/artists/{{.ID}}" class="artist-name">{{.Name}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{{define "track"}}
|
||||
<div class="track" data-id="{{.ID}}">
|
||||
<h2 class="track-title">
|
||||
{{if .Number}}
|
||||
<span class="track-number">{{.Number}}</span>
|
||||
{{end}}
|
||||
<a href="/admin/tracks/{{.ID}}">{{.Title}}</a>
|
||||
</h2>
|
||||
|
||||
<h3>Description</h3>
|
||||
{{if .Description}}
|
||||
<p class="track-description">{{.GetDescriptionHTML}}</p>
|
||||
{{else}}
|
||||
<p class="track-description empty">No description provided.</p>
|
||||
{{end}}
|
||||
|
||||
<h3>Lyrics</h3>
|
||||
{{if .Lyrics}}
|
||||
<p class="track-lyrics">{{.GetLyricsHTML}}</p>
|
||||
{{else}}
|
||||
<p class="track-lyrics empty">There are no lyrics.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
{{define "head"}}
|
||||
<title>Admin - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/releases.css">
|
||||
<link rel="stylesheet" href="/admin/static/artists.css">
|
||||
<link rel="stylesheet" href="/admin/static/tracks.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main class="dashboard">
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card" id="releases">
|
||||
<div class="card-header">
|
||||
<h2><a href="/admin/releases/">Releases</a> <small>({{.ReleaseCount}} total)</small></h2>
|
||||
<a class="button new" id="create-release">Create New</a>
|
||||
</div>
|
||||
{{if .Artists}}
|
||||
{{range .Releases}}
|
||||
{{block "release" .}}{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p>There are no releases.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="artists">
|
||||
<div class="card-header">
|
||||
<h2><a href="/admin/artists/">Artists</a> <small>({{.ArtistCount}} total)</small></h2>
|
||||
<a class="button new" id="create-artist">Create New</a>
|
||||
</div>
|
||||
{{if .Artists}}
|
||||
<div class="artists-group">
|
||||
{{range .Artists}}
|
||||
{{block "artist" .}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p>There are no artists.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="tracks">
|
||||
<div class="card-header">
|
||||
<h2><a href="/admin/tracks/">Tracks</a> <small>({{.TrackCount}} total)</small></h2>
|
||||
<a class="button new" id="create-track">Create New</a>
|
||||
</div>
|
||||
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
|
||||
<br>
|
||||
{{range .Tracks}}
|
||||
{{block "track" .}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/artists.js"></script>
|
||||
<script type="module" src="/admin/static/index.js"></script>
|
||||
{{end}}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
{{block "head" .}}{{end}}
|
||||
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<script type="module" src="/admin/static/admin.js"></script>
|
||||
<script type="module" src="/script/vendor/htmx.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<nav id="navbar">
|
||||
<a href="/" class="nav icon" aria-label="ari melody" title="Return to Home">
|
||||
<img src="/img/favicon.png" alt="" width="64" height="64">
|
||||
</a>
|
||||
<div class="nav-item{{if eq .Path "/"}} active{{end}}">
|
||||
<a href="/admin">home</a>
|
||||
</div>
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item{{if eq .Path "/logs"}} active{{end}}">
|
||||
<a href="/admin/logs">logs</a>
|
||||
</div>
|
||||
<hr>
|
||||
<p class="section-label">music</p>
|
||||
<div class="nav-item{{if hasPrefix .Path "/releases"}} active{{end}}">
|
||||
<a href="/admin/releases/">releases</a>
|
||||
</div>
|
||||
<div class="nav-item{{if hasPrefix .Path "/artists"}} active{{end}}">
|
||||
<a href="/admin/artists/">artists</a>
|
||||
</div>
|
||||
<div class="nav-item{{if hasPrefix .Path "/tracks"}} active{{end}}">
|
||||
<a href="/admin/tracks/">tracks</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="flex-fill"></div>
|
||||
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item{{if eq .Path "/account"}} active{{end}}">
|
||||
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin/logout" id="logout">log out</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="nav-item{{if eq .Path "/login"}} active{{end}}">
|
||||
<a href="/admin/login" id="login">log in</a>
|
||||
</div>
|
||||
<div class="nav-item{{if eq .Path "/register"}} active{{end}}">
|
||||
<a href="/admin/register" id="register">create account</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</nav>
|
||||
<button type="button" id="toggle-nav" aria-label="Navigation toggle">
|
||||
<img src="/img/hamburger.svg" alt="">
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{{block "content" .}}{{end}}
|
||||
|
||||
{{template "prideflag"}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{{define "head"}}
|
||||
<title>Releases - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/releases.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<header>
|
||||
<h1>Releases <small>({{len .Releases}} total)</small></h1>
|
||||
<a class="button new" id="create-release">Create New</a>
|
||||
</header>
|
||||
|
||||
{{if .Releases}}
|
||||
<div id="releases">
|
||||
{{range .Releases}}
|
||||
{{block "release" .}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p>There are no releases.</p>
|
||||
{{end}}
|
||||
</main>
|
||||
{{end}}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{{define "head"}}
|
||||
<title>Releases - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/tracks.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<header>
|
||||
<h1>Tracks <small>({{len .Tracks}} total)</small></h1>
|
||||
<a class="button new" id="create-track">Create New</a>
|
||||
</header>
|
||||
|
||||
<div id="tracks">
|
||||
{{range $Track := .Tracks}}
|
||||
<div class="track">
|
||||
<h2 class="track-title">
|
||||
<a href="/admin/tracks/{{$Track.ID}}">{{$Track.Title}}</a>
|
||||
</h2>
|
||||
{{if $Track.Description}}
|
||||
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
|
||||
{{else}}
|
||||
<p class="track-description empty">No description provided.</p>
|
||||
{{end}}
|
||||
{{if $Track.Lyrics}}
|
||||
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
|
||||
{{else}}
|
||||
<p class="track-lyrics empty">There are no lyrics.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
{{end}}
|
||||
13
admin/templates/index.go
Normal file
|
|
@ -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"),
|
||||
))
|
||||
42
admin/templates/login.go
Normal file
|
|
@ -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"),
|
||||
))
|
||||
41
admin/templates/logs.go
Normal file
|
|
@ -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"),
|
||||
))
|
||||
54
admin/templates/music.go
Normal file
|
|
@ -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"),
|
||||
))
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -14,10 +14,10 @@
|
|||
{{end}}
|
||||
<h1>Account Settings ({{.Session.Account.Username}})</h1>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<form action="/admin/account/password" method="POST" id="change-password">
|
||||
<label for="current-password">Current Password</label>
|
||||
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
|
||||
|
|
@ -28,16 +28,14 @@
|
|||
<label for="confirm-password">Confirm Password</label>
|
||||
<input type="password" id="confirm-password" value="" autocomplete="new-password" required>
|
||||
|
||||
<br>
|
||||
|
||||
<button type="submit" class="save">Change Password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>MFA Devices</h2>
|
||||
</div>
|
||||
<div class="card mfa-devices">
|
||||
<div class="card-header">
|
||||
<h2>MFA Devices</h2>
|
||||
</div>
|
||||
{{if .TOTPs}}
|
||||
{{range .TOTPs}}
|
||||
<div class="mfa-device">
|
||||
|
|
@ -46,10 +44,7 @@
|
|||
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
|
||||
</div>
|
||||
<div>
|
||||
<form method="POST" action="/admin/account/totp-delete">
|
||||
<input type="text" name="totp-name" value="{{.TOTP.Name}}" hidden>
|
||||
<button type="submit" class="delete">Delete</button>
|
||||
</form>
|
||||
<a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -57,30 +52,28 @@
|
|||
<p>You have no MFA devices.</p>
|
||||
{{end}}
|
||||
|
||||
<div class="mfa-actions">
|
||||
<div>
|
||||
<button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button>
|
||||
<a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<div class="card danger">
|
||||
<div class="card-header">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<p>
|
||||
Clicking the button below will delete your account.
|
||||
This action is <strong>irreversible</strong>.
|
||||
You will need to enter your password and TOTP below.
|
||||
</p>
|
||||
<form action="/admin/account/delete" method="POST" id="delete-account">
|
||||
<form action="/admin/account/delete" method="POST">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" value="" autocomplete="current-password" required>
|
||||
|
||||
<label for="totp">TOTP</label>
|
||||
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
|
||||
|
||||
<br>
|
||||
|
||||
<button type="submit" class="delete">Delete Account</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
<title>Editing {{.Artist.Name}} - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/edit-artist.css">
|
||||
<link rel="stylesheet" href="/admin/static/artists.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
|
|
@ -30,16 +29,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="releases">
|
||||
<div class="card-header">
|
||||
<h2>Featured in</h2>
|
||||
</div>
|
||||
<div class="card-title">
|
||||
<h2>Featured in</h2>
|
||||
</div>
|
||||
<div class="card releases">
|
||||
{{if .Credits}}
|
||||
{{range .Credits}}
|
||||
<div class="credit">
|
||||
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
|
||||
<div class="credit-info">
|
||||
<h3 class="credit-name"><a href="/admin/releases/{{.Release.ID}}">{{.Release.Title}}</a></h3>
|
||||
<h3 class="credit-name"><a href="/admin/music/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
|
||||
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
|
||||
<p class="artist-role">
|
||||
Role: {{.Role}}
|
||||
|
|
@ -55,10 +54,10 @@
|
|||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="danger">
|
||||
<div class="card-header">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<div class="card-title">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<div class="card danger">
|
||||
<p>
|
||||
Clicking the button below will delete this artist.
|
||||
This action is <strong>irreversible</strong>.
|
||||
|
|
@ -2,13 +2,10 @@
|
|||
<title>Editing {{.Release.Title}} - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/edit-release.css">
|
||||
<link rel="stylesheet" href="/admin/static/releases.css">
|
||||
<link rel="stylesheet" href="/admin/static/tracks.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Editing {{.Release.Title}}</h1>
|
||||
|
||||
<div id="release" data-id="{{.Release.ID}}">
|
||||
<div class="release-artwork">
|
||||
|
|
@ -100,21 +97,21 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="credits">
|
||||
<div class="card-header">
|
||||
<h2>Credits <small>({{len .Release.Credits}} total)</small></h2>
|
||||
<a class="button edit"
|
||||
href="/admin/releases/{{.Release.ID}}/editcredits"
|
||||
hx-get="/admin/releases/{{.Release.ID}}/editcredits"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
<div class="card-title">
|
||||
<h2>Credits ({{len .Release.Credits}})</h2>
|
||||
<a class="button edit"
|
||||
href="/admin/music/release/{{.Release.ID}}/editcredits"
|
||||
hx-get="/admin/music/release/{{.Release.ID}}/editcredits"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
<div class="card credits">
|
||||
{{range .Release.Credits}}
|
||||
<div class="credit">
|
||||
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
||||
<div class="credit-info">
|
||||
<p class="artist-name"><a href="/admin/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
|
||||
<p class="artist-name"><a href="/admin/music/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
|
||||
<p class="artist-role">
|
||||
{{.Role}}
|
||||
{{if .Primary}}
|
||||
|
|
@ -129,42 +126,59 @@
|
|||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="links">
|
||||
<div class="card-header">
|
||||
<h2>Links ({{len .Release.Links}})</h2>
|
||||
<a class="button edit"
|
||||
href="/admin/releases/{{.Release.ID}}/editlinks"
|
||||
hx-get="/admin/releases/{{.Release.ID}}/editlinks"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
<ul>
|
||||
{{range .Release.Links}}
|
||||
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="card-title">
|
||||
<h2>Links ({{len .Release.Links}})</h2>
|
||||
<a class="button edit"
|
||||
href="/admin/music/release/{{.Release.ID}}/editlinks"
|
||||
hx-get="/admin/music/release/{{.Release.ID}}/editlinks"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tracks">
|
||||
<div class="card-header" id="tracks">
|
||||
<h2>Tracklist ({{len .Release.Tracks}})</h2>
|
||||
<a class="button edit"
|
||||
href="/admin/releases/{{.Release.ID}}/edittracks"
|
||||
hx-get="/admin/releases/{{.Release.ID}}/edittracks"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
{{range $i, $track := .Release.Tracks}}
|
||||
{{block "track" .}}{{end}}
|
||||
<div class="card links">
|
||||
{{range .Release.Links}}
|
||||
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="danger">
|
||||
<div class="card-header">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<div class="card-title" id="tracks">
|
||||
<h2>Tracklist ({{len .Release.Tracks}})</h2>
|
||||
<a class="button edit"
|
||||
href="/admin/music/release/{{.Release.ID}}/edittracks"
|
||||
hx-get="/admin/music/release/{{.Release.ID}}/edittracks"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
<div class="card tracks">
|
||||
{{range $i, $track := .Release.Tracks}}
|
||||
<div class="track" data-id="{{$track.ID}}">
|
||||
<h2 class="track-title">
|
||||
<span class="track-number">{{.Add $i 1}}</span>
|
||||
<a href="/admin/music/track/{{$track.ID}}">{{$track.Title}}</a>
|
||||
</h2>
|
||||
|
||||
<h3>Description</h3>
|
||||
{{if $track.Description}}
|
||||
<p class="track-description">{{$track.GetDescriptionHTML}}</p>
|
||||
{{else}}
|
||||
<p class="track-description empty">No description provided.</p>
|
||||
{{end}}
|
||||
|
||||
<h3>Lyrics</h3>
|
||||
{{if $track.Lyrics}}
|
||||
<p class="track-lyrics">{{$track.GetLyricsHTML}}</p>
|
||||
{{else}}
|
||||
<p class="track-lyrics empty">There are no lyrics.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<div class="card danger">
|
||||
<p>
|
||||
Clicking the button below will delete this release.
|
||||
This action is <strong>irreversible</strong>.
|
||||
|
|
@ -2,8 +2,6 @@
|
|||
<title>Editing Track - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/edit-track.css">
|
||||
<link rel="stylesheet" href="/admin/static/tracks.css">
|
||||
<link rel="stylesheet" href="/admin/static/releases.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
|
|
@ -41,10 +39,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Featured in</h2>
|
||||
</div>
|
||||
<div class="card releases">
|
||||
<div class="card-header">
|
||||
<h2>Featured in</h2>
|
||||
</div>
|
||||
{{if .Releases}}
|
||||
{{range .Releases}}
|
||||
{{block "release" .}}{{end}}
|
||||
|
|
@ -54,10 +52,10 @@
|
|||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<div class="card danger">
|
||||
<div class="card-header">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<p>
|
||||
Clicking the button below will delete this track.
|
||||
This action is <strong>irreversible</strong>.
|
||||
26
admin/views/index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{{define "head"}}
|
||||
<title>Admin - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/index.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Admin Dashboard</h1>
|
||||
<div class="card-title">
|
||||
<h2>Music</h2>
|
||||
<a class="button" href="/admin/music/">Browse All</a>
|
||||
</div>
|
||||
<div class="card" id="music">
|
||||
{{if .LatestRelease}}
|
||||
<h3>Latest Release</h3>
|
||||
{{block "release" .LatestRelease}}{{end}}
|
||||
{{else}}
|
||||
<p>There are no releases.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/admin.js"></script>
|
||||
<script type="module" src="/admin/static/index.js"></script>
|
||||
{{end}}
|
||||
59
admin/views/layout.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
{{block "head" .}}{{end}}
|
||||
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<script type="module" src="/script/vendor/htmx.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-item icon" title="return to user space">
|
||||
<a href="/"><img src="/img/favicon.png" alt=""/></a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin">home</a>
|
||||
</div>
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/music/">music</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin/blog">blog</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin/logs">logs</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="flex-fill"></div>
|
||||
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/account/">account ({{.Session.Account.Username}})</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin/logout" id="logout">log out</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/register" id="register">create account</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{{block "content" .}}{{end}}
|
||||
|
||||
{{template "prideflag"}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{{define "head"}}
|
||||
<title>Login - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<style>
|
||||
form#login-totp {
|
||||
width: 100%;
|
||||
|
|
@ -17,15 +18,8 @@ form button {
|
|||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
form input {
|
||||
width: calc(100% - 1rem - 2px) !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
h1 {
|
||||
margin-top: 3em;
|
||||
text-align: center;
|
||||
}
|
||||
input {
|
||||
width: calc(100% - 1rem - 2px);
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
|
@ -1,14 +1,8 @@
|
|||
{{define "head"}}
|
||||
<title>Login - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<style>
|
||||
@media screen and (max-width: 720px) {
|
||||
h1 {
|
||||
margin-top: 3em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
form#login {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
|
@ -24,8 +18,8 @@ form button {
|
|||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
form input {
|
||||
width: calc(100% - 1rem - 2px) !important;
|
||||
input {
|
||||
width: calc(100% - 1rem - 2px);
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{{define "head"}}
|
||||
<title>Audit Logs - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<link rel="stylesheet" href="/admin/static/logs.css">
|
||||
{{end}}
|
||||
|
||||
|
|
@ -8,7 +9,7 @@
|
|||
<main>
|
||||
<h1>Audit Logs</h1>
|
||||
|
||||
<form action="/admin/logs" method="GET" id="search-form">
|
||||
<form action="/admin/logs" method="GET">
|
||||
<div id="search">
|
||||
<input type="text" name="q" value="" placeholder="Filter by message...">
|
||||
<button type="submit" class="save">Search</button>
|
||||
|
|
@ -43,27 +44,25 @@
|
|||
|
||||
<hr>
|
||||
|
||||
<div id="logs">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="log-time">Time</th>
|
||||
<th class="log-level">Level</th>
|
||||
<th class="log-type">Type</th>
|
||||
<th class="log-content">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Logs}}
|
||||
<tr class="log {{toLower (parseLevel .Level)}}">
|
||||
<td class="log-time">{{prettyTime .CreatedAt}}</td>
|
||||
<td class="log-level">{{parseLevel .Level}}</td>
|
||||
<td class="log-type">{{titleCase .Type}}</td>
|
||||
<td class="log-content">{{.Content}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table id="logs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="log-time">Time</th>
|
||||
<th class="log-level">Level</th>
|
||||
<th class="log-type">Type</th>
|
||||
<th class="log-content">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Logs}}
|
||||
<tr class="log {{lower (parseLevel .Level)}}">
|
||||
<td class="log-time">{{prettyTime .CreatedAt}}</td>
|
||||
<td class="log-level">{{parseLevel .Level}}</td>
|
||||
<td class="log-type">{{titleCase .Type}}</td>
|
||||
<td class="log-content">{{.Content}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
{{end}}
|
||||
73
admin/views/music.html
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{{define "head"}}
|
||||
<title>Music - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/music.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Music</h1>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Releases</h2>
|
||||
<a class="button new" id="create-release">Create New</a>
|
||||
</div>
|
||||
<div class="card releases">
|
||||
{{range .Releases}}
|
||||
{{block "release" .}}{{end}}
|
||||
{{end}}
|
||||
{{if not .Releases}}
|
||||
<p>There are no releases.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Artists</h2>
|
||||
<a class="button new" id="create-artist">Create New</a>
|
||||
</div>
|
||||
<div class="card artists">
|
||||
{{range $Artist := .Artists}}
|
||||
<div class="artist">
|
||||
<img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
||||
<a href="/admin/music/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .Artists}}
|
||||
<p>There are no artists.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
<h2>Tracks</h2>
|
||||
<a class="button new" id="create-track">Create New</a>
|
||||
</div>
|
||||
<div class="card tracks">
|
||||
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
|
||||
<br>
|
||||
{{range $Track := .Tracks}}
|
||||
<div class="track">
|
||||
<h2 class="track-title">
|
||||
<a href="/admin/music/track/{{$Track.ID}}">{{$Track.Title}}</a>
|
||||
</h2>
|
||||
{{if $Track.Description}}
|
||||
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
|
||||
{{else}}
|
||||
<p class="track-description empty">No description provided.</p>
|
||||
{{end}}
|
||||
{{if $Track.Lyrics}}
|
||||
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
|
||||
{{else}}
|
||||
<p class="track-lyrics empty">There are no lyrics.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .Artists}}
|
||||
<p>There are no artists.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/admin.js"></script>
|
||||
<script type="module" src="/admin/static/music.js"></script>
|
||||
{{end}}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{{define "head"}}
|
||||
<title>Register - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<style>
|
||||
p a {
|
||||
color: #2a67c8;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{{define "head"}}
|
||||
<title>TOTP Confirmation - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<style>
|
||||
.qr-code {
|
||||
border: 1px solid #8888;
|
||||
|
|
@ -8,20 +9,11 @@
|
|||
code {
|
||||
user-select: all;
|
||||
}
|
||||
#totp-setup input {
|
||||
width: 3.8em;
|
||||
min-width: auto;
|
||||
font-size: 32px;
|
||||
font-family: 'Monaspace Argon', monospace;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Two-Factor Authentication</h1>
|
||||
|
||||
{{if .Session.Error.Valid}}
|
||||
<p id="error">{{html .Session.Error.String}}</p>
|
||||
{{end}}
|
||||
|
|
@ -48,14 +40,7 @@ code {
|
|||
<p><code>{{.TOTP.Secret}}</code></p>
|
||||
|
||||
<label for="totp">TOTP:</label>
|
||||
<input type="text"
|
||||
name="totp"
|
||||
value=""
|
||||
minlength="6"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
required
|
||||
autofocus>
|
||||
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
|
||||
|
||||
<button type="submit" class="new">Create</button>
|
||||
</form>
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
{{define "head"}}
|
||||
<title>TOTP Setup - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Two-Factor Authentication</h1>
|
||||
|
||||
{{if .Session.Error.Valid}}
|
||||
<p id="error">{{html .Session.Error.String}}</p>
|
||||
{{end}}
|
||||
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -172,7 +173,7 @@ func getSession(app *model.AppState, r *http.Request) (*model.Session, error) {
|
|||
// check cookies first
|
||||
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||
if err != nil && err != http.ErrNoCookie {
|
||||
return nil, fmt.Errorf("Failed to retrieve session cookie: %v\n", err)
|
||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v\n", err))
|
||||
}
|
||||
if sessionCookie != nil {
|
||||
token = sessionCookie.Value
|
||||
|
|
@ -187,7 +188,7 @@ func getSession(app *model.AppState, r *http.Request) (*model.Session, error) {
|
|||
session, err := controller.GetSession(app.DB, token)
|
||||
|
||||
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
||||
return nil, fmt.Errorf("Failed to retrieve session: %v\n", err)
|
||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v\n", err))
|
||||
}
|
||||
|
||||
if session != nil {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func HandleImageUpload(app *model.AppState, data *string, directory string, file
|
|||
return "", nil
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_FILES, "\"%s\" created.", imagePath)
|
||||
app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext)
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,6 @@ func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
|
|||
|
||||
return artists, nil
|
||||
}
|
||||
func GetArtistCount(db *sqlx.DB) (int, error) {
|
||||
var count int
|
||||
err := db.Get(&count, "SELECT count(*) FROM artist")
|
||||
return count, err
|
||||
}
|
||||
|
||||
func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) {
|
||||
var artists = []*model.Artist{}
|
||||
|
|
|
|||
83
controller/blog.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func GetBlogPost(db *sqlx.DB, id string) (*model.BlogPost, error) {
|
||||
var blog = model.BlogPost{}
|
||||
|
||||
err := db.Get(&blog, "SELECT * FROM blogpost WHERE id=$1", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &blog, nil
|
||||
}
|
||||
|
||||
func GetBlogPosts(db *sqlx.DB, onlyVisible bool, limit int, offset int) ([]*model.BlogPost, error) {
|
||||
var blogs = []*model.BlogPost{}
|
||||
|
||||
query := "SELECT * FROM blogpost ORDER BY created_at"
|
||||
if onlyVisible {
|
||||
query = "SELECT * FROM blogpost WHERE visible=true ORDER BY created_at"
|
||||
}
|
||||
|
||||
var err error
|
||||
if limit < 0 {
|
||||
err = db.Select(&blogs, query)
|
||||
} else {
|
||||
err = db.Select(&blogs, query + " LIMIT $1 OFFSET $2", limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// for range 4 {
|
||||
// blog := *blogs[len(blogs)-1]
|
||||
// blog.CreatedAt = blog.CreatedAt.Add(time.Hour * -5000)
|
||||
// blogs = append(blogs, &blog)
|
||||
// }
|
||||
|
||||
return blogs, nil
|
||||
}
|
||||
|
||||
func CreateBlogPost(db *sqlx.DB, post *model.BlogPost) error {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO blogpost (id,title,description,visible,author,markdown,html,bluesky_actor,bluesky_post) " +
|
||||
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)",
|
||||
post.ID,
|
||||
post.Title,
|
||||
post.Description,
|
||||
post.Visible,
|
||||
post.AuthorID,
|
||||
post.Markdown,
|
||||
post.HTML,
|
||||
post.BlueskyActorID,
|
||||
post.BlueskyPostID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateBlogPost(db *sqlx.DB, postID string, post *model.BlogPost) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE blogpost SET " +
|
||||
"id=$2,title=$3,description=$4,visible=$5,author=$6,markdown=$7,html=$8,bluesky_actor=$9,bluesky_post=$10,modified_at=CURRENT_TIMESTAMP " +
|
||||
"WHERE id=$1",
|
||||
postID,
|
||||
post.ID,
|
||||
post.Title,
|
||||
post.Description,
|
||||
post.Visible,
|
||||
post.AuthorID,
|
||||
post.Markdown,
|
||||
post.HTML,
|
||||
post.BlueskyActorID,
|
||||
post.BlueskyPostID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
48
controller/bluesky.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const BSKY_API_BASE = "https://public.api.bsky.app"
|
||||
|
||||
func FetchThreadViewPost(actorID string, postID string) (*model.ThreadViewPost, error) {
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", actorID, postID)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
strings.Join([]string{BSKY_API_BASE, "xrpc", "app.bsky.feed.getPostThread"}, "/"),
|
||||
nil,
|
||||
)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
req.URL.RawQuery = url.Values{
|
||||
"uri": { uri },
|
||||
}.Encode()
|
||||
req.Header.Set("User-Agent", "ari melody [https://arimelody.me]")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New(fmt.Sprintf("Failed to call Bluesky API: %v", err))
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
Thread model.ThreadViewPost `json:"thread"`
|
||||
}
|
||||
data := Data{}
|
||||
err = json.NewDecoder(res.Body).Decode(&data)
|
||||
if err != nil {
|
||||
return nil, errors.New(fmt.Sprintf("Invalid response from server: %v", err))
|
||||
}
|
||||
|
||||
return &data.Thread, nil
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import "math/rand"
|
|||
func GenerateAlnumString(length int) []byte {
|
||||
const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
res := []byte{}
|
||||
for range length {
|
||||
for i := 0; i < length; i++ {
|
||||
res = append(res, CHARS[rand.Intn(len(CHARS))])
|
||||
}
|
||||
return res
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const DB_VERSION int = 4
|
||||
const DB_VERSION int = 5
|
||||
|
||||
func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
|
||||
|
|
@ -50,19 +49,20 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
|||
ApplyMigration(db, "003-fail-lock")
|
||||
oldDBVersion = 4
|
||||
|
||||
case 4:
|
||||
ApplyMigration(db, "004-blog")
|
||||
oldDBVersion = 5
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Database schema up to date.\n")
|
||||
}
|
||||
|
||||
//go:embed "schema-migration"
|
||||
var schemaFS embed.FS
|
||||
|
||||
func ApplyMigration(db *sqlx.DB, scriptFile string) {
|
||||
fmt.Printf("Applying schema migration %s...\n", scriptFile)
|
||||
|
||||
bytes, err := schemaFS.ReadFile("schema-migration/" + scriptFile + ".sql")
|
||||
bytes, err := os.ReadFile("schema-migration/" + scriptFile + ".sql")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
|
||||
os.Exit(1)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package controller
|
|||
|
||||
import (
|
||||
"encoding/base64"
|
||||
// "image"
|
||||
// "image/color"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
|
@ -18,35 +18,36 @@ func GenerateQRCode(data string) (string, error) {
|
|||
}
|
||||
|
||||
// vvv DEPRECATED vvv
|
||||
// const margin = 4
|
||||
//
|
||||
// type QRCodeECCLevel int64
|
||||
// const (
|
||||
// LOW QRCodeECCLevel = iota
|
||||
// MEDIUM
|
||||
// QUARTILE
|
||||
// HIGH
|
||||
// )
|
||||
//
|
||||
// func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
|
||||
// for yi := range 7 {
|
||||
// for xi := range 7 {
|
||||
// if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
|
||||
// img.Set(x + xi, y + yi, color.Black)
|
||||
// } else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
|
||||
// img.Set(x + xi, y + yi, color.Black)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
|
||||
// for yi := range 5 {
|
||||
// for xi := range 5 {
|
||||
// if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
|
||||
// img.Set(x + xi, y + yi, color.Black)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// img.Set(x + 2, y + 2, color.Black)
|
||||
// }
|
||||
|
||||
const margin = 4
|
||||
|
||||
type QRCodeECCLevel int64
|
||||
const (
|
||||
LOW QRCodeECCLevel = iota
|
||||
MEDIUM
|
||||
QUARTILE
|
||||
HIGH
|
||||
)
|
||||
|
||||
func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
|
||||
for yi := range 7 {
|
||||
for xi := range 7 {
|
||||
if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
|
||||
img.Set(x + xi, y + yi, color.Black)
|
||||
} else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
|
||||
img.Set(x + xi, y + yi, color.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
|
||||
for yi := range 5 {
|
||||
for xi := range 5 {
|
||||
if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
|
||||
img.Set(x + xi, y + yi, color.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
img.Set(x + 2, y + 2, color.Black)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
||||
|
|
@ -20,7 +22,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
|||
// get credits
|
||||
credits, err := GetReleaseCredits(db, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Credits: %s", err)
|
||||
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
|
||||
}
|
||||
for _, credit := range credits {
|
||||
release.Credits = append(release.Credits, credit)
|
||||
|
|
@ -29,7 +31,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
|||
// get tracks
|
||||
tracks, err := GetReleaseTracks(db, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Tracks: %s", err)
|
||||
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
|
||||
}
|
||||
for _, track := range tracks {
|
||||
release.Tracks = append(release.Tracks, track)
|
||||
|
|
@ -38,7 +40,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
|||
// get links
|
||||
links, err := GetReleaseLinks(db, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Links: %s", err)
|
||||
return nil, errors.New(fmt.Sprintf("Links: %s", err))
|
||||
}
|
||||
for _, link := range links {
|
||||
release.Links = append(release.Links, link)
|
||||
|
|
@ -70,7 +72,7 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
|
|||
// get credits
|
||||
credits, err := GetReleaseCredits(db, release.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Credits: %s", err)
|
||||
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
|
||||
}
|
||||
for _, credit := range credits {
|
||||
release.Credits = append(release.Credits, credit)
|
||||
|
|
@ -80,7 +82,7 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
|
|||
// get tracks
|
||||
tracks, err := GetReleaseTracks(db, release.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Tracks: %s", err)
|
||||
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
|
||||
}
|
||||
for _, track := range tracks {
|
||||
release.Tracks = append(release.Tracks, track)
|
||||
|
|
@ -89,7 +91,7 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
|
|||
// get links
|
||||
links, err := GetReleaseLinks(db, release.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Links: %s", err)
|
||||
return nil, errors.New(fmt.Sprintf("Links: %s", err))
|
||||
}
|
||||
for _, link := range links {
|
||||
release.Links = append(release.Links, link)
|
||||
|
|
@ -99,16 +101,46 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
|
|||
|
||||
return releases, nil
|
||||
}
|
||||
func GetReleaseCount(db *sqlx.DB, onlyVisible bool) (int, error) {
|
||||
query := "SELECT count(*) FROM musicrelease"
|
||||
if onlyVisible {
|
||||
query += " WHERE visible=true"
|
||||
|
||||
func GetLatestRelease(db *sqlx.DB) (*model.Release, error) {
|
||||
var release = model.Release{}
|
||||
|
||||
err := db.Get(&release, "SELECT * FROM musicrelease WHERE visible=true ORDER BY release_date DESC LIMIT 1")
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.Get(&count, query)
|
||||
// get credits
|
||||
credits, err := GetReleaseCredits(db, release.ID)
|
||||
if err != nil {
|
||||
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
|
||||
}
|
||||
for _, credit := range credits {
|
||||
release.Credits = append(release.Credits, credit)
|
||||
}
|
||||
|
||||
return count, err
|
||||
// get tracks
|
||||
tracks, err := GetReleaseTracks(db, release.ID)
|
||||
if err != nil {
|
||||
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
|
||||
}
|
||||
for _, track := range tracks {
|
||||
release.Tracks = append(release.Tracks, track)
|
||||
}
|
||||
|
||||
// get links
|
||||
links, err := GetReleaseLinks(db, release.ID)
|
||||
if err != nil {
|
||||
return nil, errors.New(fmt.Sprintf("Links: %s", err))
|
||||
}
|
||||
for _, link := range links {
|
||||
release.Links = append(release.Links, link)
|
||||
}
|
||||
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func CreateRelease(db *sqlx.DB, release *model.Release) error {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -18,7 +19,7 @@ const TOKEN_LEN = 64
|
|||
func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) {
|
||||
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||
if err != nil && err != http.ErrNoCookie {
|
||||
return nil, fmt.Errorf("Failed to retrieve session cookie: %v", err)
|
||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err))
|
||||
}
|
||||
|
||||
var session *model.Session
|
||||
|
|
@ -28,7 +29,7 @@ func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session
|
|||
session, err = GetSession(app.DB, sessionCookie.Value)
|
||||
|
||||
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
||||
return nil, fmt.Errorf("Failed to retrieve session: %v", err)
|
||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err))
|
||||
}
|
||||
|
||||
if session != nil {
|
||||
|
|
|
|||
|
|
@ -29,11 +29,6 @@ func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
|
|||
|
||||
return tracks, nil
|
||||
}
|
||||
func GetTrackCount(db *sqlx.DB) (int, error) {
|
||||
var count int
|
||||
err := db.Get(&count, "SELECT count(*) FROM musictrack")
|
||||
return count, err
|
||||
}
|
||||
|
||||
func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
|
||||
var tracks = []*model.Track{}
|
||||
|
|
|
|||
1
go.mod
|
|
@ -10,6 +10,7 @@ require (
|
|||
require golang.org/x/crypto v0.27.0 // indirect
|
||||
|
||||
require (
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
|
|
|
|||
2
go.sum
|
|
@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
|
|
|
|||
56
main.go
|
|
@ -1,33 +1,32 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
stdLog "log"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
stdLog "log"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/api"
|
||||
"arimelody-web/colour"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/cursor"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/view"
|
||||
"arimelody-web/admin"
|
||||
"arimelody-web/api"
|
||||
"arimelody-web/colour"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/cursor"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/view"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// used for database migrations
|
||||
|
|
@ -36,16 +35,12 @@ const DB_VERSION = 1
|
|||
const DEFAULT_PORT int64 = 8080
|
||||
const HRT_DATE int64 = 1756478697
|
||||
|
||||
//go:embed "public"
|
||||
var publicFS embed.FS
|
||||
|
||||
func main() {
|
||||
fmt.Printf("made with <3 by ari melody\n\n")
|
||||
|
||||
app := model.AppState{
|
||||
Config: controller.GetConfig(),
|
||||
Twitch: nil,
|
||||
PublicFS: publicFS,
|
||||
}
|
||||
|
||||
// initialise database connection
|
||||
|
|
@ -531,7 +526,8 @@ func createServeMux(app *model.AppState) *http.ServeMux {
|
|||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.ServeFiles(filepath.Join(app.Config.DataDirectory, "uploads"))))
|
||||
mux.Handle("/blog/", http.StripPrefix("/blog", view.BlogHandler(app)))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
|
||||
mux.Handle("/cursor-ws", cursor.Handler(app))
|
||||
mux.Handle("/", view.IndexHandler(app))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/log"
|
||||
)
|
||||
|
||||
type (
|
||||
|
|
@ -45,6 +43,5 @@ type (
|
|||
Config Config
|
||||
Log log.Logger
|
||||
Twitch *TwitchState
|
||||
PublicFS embed.FS
|
||||
}
|
||||
)
|
||||
|
|
|
|||
51
model/blog.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
BlogPost struct {
|
||||
ID string `db:"id"`
|
||||
Title string `db:"title"`
|
||||
Description string `db:"description"`
|
||||
Visible bool `db:"visible"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ModifiedAt sql.NullTime `db:"modified_at"`
|
||||
AuthorID string `db:"author"`
|
||||
Markdown string `db:"markdown"`
|
||||
HTML template.HTML `db:"html"`
|
||||
BlueskyActorID *string `db:"bluesky_actor"`
|
||||
BlueskyPostID *string `db:"bluesky_post"`
|
||||
}
|
||||
)
|
||||
|
||||
func (b *BlogPost) TitleNormalised() string {
|
||||
rgx := regexp.MustCompile(`[^a-z0-9\-]`)
|
||||
return rgx.ReplaceAllString(
|
||||
strings.ReplaceAll(
|
||||
strings.ToLower(b.Title), " ", "-",
|
||||
),
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
func (b *BlogPost) GetMonth() string {
|
||||
return fmt.Sprintf("%02d", int(b.CreatedAt.Month()))
|
||||
}
|
||||
|
||||
func (b *BlogPost) PrintDate() string {
|
||||
return b.CreatedAt.Format("2 January 2006, 03:04")
|
||||
}
|
||||
|
||||
func (b *BlogPost) PrintModifiedDate() string {
|
||||
if !b.ModifiedAt.Valid {
|
||||
return ""
|
||||
}
|
||||
return b.ModifiedAt.Time.Format("2 January 2006, 03:04")
|
||||
}
|
||||
82
model/bluesky.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
Record struct {
|
||||
Type string `json:"$type"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
Profile struct {
|
||||
DID string `json:"did"`
|
||||
Handle string `json:"handle"`
|
||||
Avatar string `json:"avatar"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
PostImage struct {
|
||||
Thumbnail string `json:"thumb"`
|
||||
Fullsize string `json:"fullsize"`
|
||||
Alt string `json:"alt"`
|
||||
}
|
||||
|
||||
EmbedMedia struct {
|
||||
Images []PostImage `json:"images"`
|
||||
}
|
||||
|
||||
Embed struct {
|
||||
Media EmbedMedia `json:"media"`
|
||||
}
|
||||
|
||||
Post struct {
|
||||
Author Profile `json:"author"`
|
||||
Record Record `json:"record"`
|
||||
ReplyCount int `json:"replyCount"`
|
||||
RepostCount int `json:"repostCount"`
|
||||
LikeCount int `json:"likeCount"`
|
||||
QuoteCount int `json:"quoteCount"`
|
||||
Embed *Embed `json:"embed"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
ThreadViewPost struct {
|
||||
Post Post `json:"post"`
|
||||
Replies []*ThreadViewPost `json:"replies"`
|
||||
}
|
||||
)
|
||||
|
||||
func (record *Record) CreatedAtPrint() (string, error) {
|
||||
t, err := record.CreatedAtTime()
|
||||
if err != nil { return "", err }
|
||||
return t.Format("2 Jan 2006, 15:04"), nil
|
||||
}
|
||||
|
||||
func (record *Record) CreatedAtTime() (time.Time, error) {
|
||||
return time.Parse("2006-01-02T15:04:05Z", record.CreatedAt)
|
||||
}
|
||||
|
||||
func (post *Post) HasImage() bool {
|
||||
return post.Embed != nil && len(post.Embed.Media.Images) > 0
|
||||
}
|
||||
|
||||
func (post *Post) PostID() string {
|
||||
return strings.TrimPrefix(
|
||||
post.URI,
|
||||
fmt.Sprintf("at://%s/app.bsky.feed.post/", post.Author.DID),
|
||||
)
|
||||
}
|
||||
|
||||
func (post *Post) BskyURL() string {
|
||||
return fmt.Sprintf(
|
||||
"https://bsky.app/profile/%s/post/%s",
|
||||
post.Author.DID,
|
||||
post.PostID(),
|
||||
)
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ const (
|
|||
// GETTERS
|
||||
|
||||
func (release Release) GetDescriptionHTML() template.HTML {
|
||||
return template.HTML(strings.ReplaceAll(release.Description, "\n", "<br>"))
|
||||
return template.HTML(strings.Replace(release.Description, "\n", "<br>", -1))
|
||||
}
|
||||
|
||||
func (release Release) TextReleaseDate() string {
|
||||
|
|
|
|||
|
|
@ -13,16 +13,16 @@ type (
|
|||
Lyrics string `json:"lyrics" db:"lyrics"`
|
||||
PreviewURL string `json:"previewURL" db:"preview_url"`
|
||||
|
||||
Number int `json:"-"`
|
||||
Number int
|
||||
}
|
||||
)
|
||||
|
||||
func (track Track) GetDescriptionHTML() template.HTML {
|
||||
return template.HTML(strings.ReplaceAll(track.Description, "\n", "<br>"))
|
||||
return template.HTML(strings.Replace(track.Description, "\n", "<br>", -1))
|
||||
}
|
||||
|
||||
func (track Track) GetLyricsHTML() template.HTML {
|
||||
return template.HTML(strings.ReplaceAll(track.Lyrics, "\n", "<br>"))
|
||||
return template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1))
|
||||
}
|
||||
|
||||
// this function is stupid and i hate that i need it
|
||||
|
|
|
|||
BIN
public/img/aridoodle.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
public/img/blog/bluesky-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="bluesky-dark" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M9.176,7.042c2.503,1.879 5.195,5.69 6.184,7.734c0.989,-2.044 3.681,-5.855 6.184,-7.734c1.806,-1.356 4.733,-2.405 4.733,0.933c-0,0.667 -0.383,5.601 -0.607,6.403c-0.779,2.785 -3.619,3.495 -6.145,3.065c4.415,0.752 5.539,3.241 3.113,5.73c-4.608,4.728 -6.622,-1.186 -7.138,-2.701c-0.095,-0.278 -0.139,-0.408 -0.14,-0.298c-0.001,-0.11 -0.045,0.02 -0.14,0.298c-0.516,1.515 -2.53,7.429 -7.138,2.701c-2.426,-2.489 -1.302,-4.978 3.113,-5.73c-2.526,0.43 -5.366,-0.28 -6.145,-3.065c-0.224,-0.802 -0.607,-5.736 -0.607,-6.403c0,-3.338 2.927,-2.289 4.733,-0.933Z" style="fill:#fff;fill-rule:nonzero;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
public/img/blog/bluesky-light.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="bluesky-light" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M9.176,7.042c2.503,1.879 5.195,5.69 6.184,7.734c0.989,-2.044 3.681,-5.855 6.184,-7.734c1.806,-1.356 4.733,-2.405 4.733,0.933c-0,0.667 -0.383,5.601 -0.607,6.403c-0.779,2.785 -3.619,3.495 -6.145,3.065c4.415,0.752 5.539,3.241 3.113,5.73c-4.608,4.728 -6.622,-1.186 -7.138,-2.701c-0.095,-0.278 -0.139,-0.408 -0.14,-0.298c-0.001,-0.11 -0.045,0.02 -0.14,0.298c-0.516,1.515 -2.53,7.429 -7.138,2.701c-2.426,-2.489 -1.302,-4.978 3.113,-5.73c-2.526,0.43 -5.366,-0.28 -6.145,-3.065c-0.224,-0.802 -0.607,-5.736 -0.607,-6.403c0,-3.338 2.927,-2.289 4.733,-0.933Z" style="fill-rule:nonzero;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
public/img/blog/boost-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="boost" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M20.14,5.466c1.708,-0.458 4.028,2.658 5.179,6.953c1.151,4.295 0.699,8.154 -1.008,8.611c-1.708,0.458 -4.029,-2.658 -5.179,-6.953c-1.151,-4.295 -0.699,-8.153 1.008,-8.611Zm0.954,3.56c-0.08,1.236 0.101,2.772 0.532,4.383c0.432,1.611 1.043,3.031 1.731,4.062c0.08,-1.237 -0.101,-2.772 -0.533,-4.383c-0.432,-1.611 -1.042,-3.031 -1.73,-4.062Zm-12.3,12.963l-0.907,0.056c-1.177,0.073 -2.242,-0.693 -2.548,-1.833l-0.468,-1.747c-0.305,-1.139 0.234,-2.336 1.29,-2.861l10.773,-8.542c0.173,-0.086 0.379,-0.064 0.53,0.056c0.151,0.12 0.218,0.316 0.174,0.503c-0.318,1.746 -0.19,4.164 0.496,6.724c0.685,2.559 1.784,4.717 2.928,6.074c0.132,0.138 0.171,0.34 0.101,0.517c-0.07,0.178 -0.236,0.299 -0.427,0.311c-1.7,0.108 -5.142,0.322 -8.287,0.516c-0.044,0.385 -0.09,0.771 -0.131,1.119c-0.091,0.771 -0.713,1.371 -1.486,1.435l-0.219,0.018c-0.647,0.054 -1.241,-0.355 -1.422,-0.978l-0.397,-1.368Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
public/img/blog/boost-light.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="boost" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M20.14,5.466c1.708,-0.458 4.028,2.658 5.179,6.953c1.151,4.295 0.699,8.154 -1.008,8.611c-1.708,0.458 -4.029,-2.658 -5.179,-6.953c-1.151,-4.295 -0.699,-8.153 1.008,-8.611Zm0.954,3.56c-0.08,1.236 0.101,2.772 0.532,4.383c0.432,1.611 1.043,3.031 1.731,4.062c0.08,-1.237 -0.101,-2.772 -0.533,-4.383c-0.432,-1.611 -1.042,-3.031 -1.73,-4.062Zm-12.3,12.963l-0.907,0.056c-1.177,0.073 -2.242,-0.693 -2.548,-1.833l-0.468,-1.747c-0.305,-1.139 0.234,-2.336 1.29,-2.861l10.773,-8.542c0.173,-0.086 0.379,-0.064 0.53,0.056c0.151,0.12 0.218,0.316 0.174,0.503c-0.318,1.746 -0.19,4.164 0.496,6.724c0.685,2.559 1.784,4.717 2.928,6.074c0.132,0.138 0.171,0.34 0.101,0.517c-0.07,0.178 -0.236,0.299 -0.427,0.311c-1.7,0.108 -5.142,0.322 -8.287,0.516c-0.044,0.385 -0.09,0.771 -0.131,1.119c-0.091,0.771 -0.713,1.371 -1.486,1.435l-0.219,0.018c-0.647,0.054 -1.241,-0.355 -1.422,-0.978l-0.397,-1.368Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
public/img/blog/comment-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="comment" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M26.117,14.576c-0,4.724 -4.82,8.559 -10.757,8.559c-1.015,-0 -2.025,-0.114 -3,-0.34l-5.008,1.907l-0.007,-4.418c-1.765,-1.569 -2.742,-3.601 -2.742,-5.708c0,-4.723 4.82,-8.558 10.757,-8.558c5.937,-0 10.757,3.835 10.757,8.558Zm-15.557,-1.135c-0.795,-0 -1.44,0.645 -1.44,1.44c0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Zm9.6,-0c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Zm-4.8,-0c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
public/img/blog/comment-light.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="comment" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M26.117,14.576c-0,4.724 -4.82,8.559 -10.757,8.559c-1.015,-0 -2.025,-0.114 -3,-0.34l-5.008,1.907l-0.007,-4.418c-1.765,-1.569 -2.742,-3.601 -2.742,-5.708c0,-4.723 4.82,-8.558 10.757,-8.558c5.937,-0 10.757,3.835 10.757,8.558Zm-15.557,-1.135c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Zm9.6,-0c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Zm-4.8,-0c-0.795,-0 -1.44,0.645 -1.44,1.44c-0,0.795 0.645,1.44 1.44,1.44c0.795,-0 1.44,-0.645 1.44,-1.44c-0,-0.795 -0.645,-1.44 -1.44,-1.44Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
public/img/blog/copy-link-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="copy-link" serif:id="copy link" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M10.578,14.996c-0.133,-1.199 0.107,-2.433 0.717,-3.512c0.254,-0.448 0.571,-0.869 0.953,-1.251l3.502,-3.503c2.274,-2.273 5.966,-2.273 8.24,0c2.273,2.274 2.273,5.966 -0,8.24l-1.962,1.962c-0.145,0.144 -0.364,0.186 -0.552,0.105c-0.188,-0.081 -0.307,-0.269 -0.302,-0.473c0.023,-0.778 -0.087,-1.559 -0.329,-2.307c-0.058,-0.179 -0.011,-0.375 0.122,-0.507l0.901,-0.902c1.103,-1.103 1.103,-2.893 0,-3.996c-1.103,-1.103 -2.893,-1.103 -3.996,-0l-3.503,3.502c-0.306,0.306 -0.527,0.665 -0.663,1.048c-0.355,0.996 -0.134,2.152 0.663,2.949c0.283,0.283 0.611,0.493 0.962,0.631c0.157,0.062 0.272,0.198 0.306,0.364c0.035,0.165 -0.016,0.336 -0.136,0.455l-1.491,1.491c-0.161,0.161 -0.411,0.193 -0.608,0.077c-0.412,-0.244 -0.8,-0.543 -1.154,-0.897c-0.973,-0.973 -1.53,-2.206 -1.67,-3.476Zm8.847,4.24c-0.254,0.448 -0.571,0.869 -0.953,1.251l-3.502,3.503c-2.274,2.273 -5.966,2.273 -8.24,-0c-2.273,-2.274 -2.273,-5.966 0,-8.24l1.962,-1.962c0.145,-0.144 0.364,-0.186 0.552,-0.105c0.188,0.081 0.307,0.269 0.302,0.473c-0.023,0.778 0.087,1.559 0.329,2.307c0.058,0.179 0.011,0.375 -0.122,0.507l-0.901,0.902c-1.103,1.103 -1.103,2.893 -0,3.996c1.103,1.103 2.893,1.103 3.996,0l3.503,-3.502c0.306,-0.306 0.527,-0.665 0.663,-1.048c0.355,-0.996 0.134,-2.152 -0.663,-2.949c-0.283,-0.283 -0.611,-0.493 -0.962,-0.631c-0.157,-0.062 -0.272,-0.198 -0.306,-0.364c-0.035,-0.165 0.016,-0.336 0.136,-0.455l1.491,-1.491c0.161,-0.161 0.411,-0.193 0.608,-0.077c0.412,0.244 0.8,0.543 1.154,0.897c0.973,0.973 1.53,2.205 1.67,3.476c0.133,1.199 -0.107,2.433 -0.717,3.512Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
1
public/img/blog/copy-link-light.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="copy-link" serif:id="copy link" x="0" y="-0" width="30.72" height="30.72" style="fill:none;"/><g id="copy-link1" serif:id="copy link"><path d="M10.578,14.996c-0.133,-1.199 0.107,-2.433 0.717,-3.512c0.254,-0.448 0.571,-0.869 0.953,-1.251l3.502,-3.503c2.274,-2.273 5.966,-2.273 8.24,0c2.273,2.274 2.273,5.966 -0,8.24l-1.962,1.962c-0.145,0.144 -0.364,0.186 -0.552,0.105c-0.188,-0.081 -0.307,-0.269 -0.302,-0.473c0.023,-0.778 -0.087,-1.559 -0.329,-2.307c-0.058,-0.179 -0.011,-0.375 0.122,-0.507l0.901,-0.902c1.103,-1.103 1.103,-2.893 0,-3.996c-1.103,-1.103 -2.893,-1.103 -3.996,-0l-3.503,3.502c-0.306,0.306 -0.527,0.665 -0.663,1.048c-0.355,0.996 -0.134,2.152 0.663,2.949c0.283,0.283 0.611,0.493 0.962,0.631c0.157,0.062 0.272,0.198 0.306,0.364c0.035,0.165 -0.016,0.336 -0.136,0.455l-1.491,1.491c-0.161,0.161 -0.411,0.193 -0.608,0.077c-0.412,-0.244 -0.8,-0.543 -1.154,-0.897c-0.973,-0.973 -1.53,-2.206 -1.67,-3.476Zm8.847,4.24c-0.254,0.448 -0.571,0.869 -0.953,1.251l-3.502,3.503c-2.274,2.273 -5.966,2.273 -8.24,-0c-2.273,-2.274 -2.273,-5.966 0,-8.24l1.962,-1.962c0.145,-0.144 0.364,-0.186 0.552,-0.105c0.188,0.081 0.307,0.269 0.302,0.473c-0.023,0.778 0.087,1.559 0.329,2.307c0.058,0.179 0.011,0.375 -0.122,0.507l-0.901,0.902c-1.103,1.103 -1.103,2.893 -0,3.996c1.103,1.103 2.893,1.103 3.996,0l3.503,-3.502c0.306,-0.306 0.527,-0.665 0.663,-1.048c0.355,-0.996 0.134,-2.152 -0.663,-2.949c-0.283,-0.283 -0.611,-0.493 -0.962,-0.631c-0.157,-0.062 -0.272,-0.198 -0.306,-0.364c-0.035,-0.165 0.016,-0.336 0.136,-0.455l1.491,-1.491c0.161,-0.161 0.411,-0.193 0.608,-0.077c0.412,0.244 0.8,0.543 1.154,0.897c0.973,0.973 1.53,2.205 1.67,3.476c0.133,1.199 -0.107,2.433 -0.717,3.512Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
1
public/img/blog/like-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="like" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M15.36,9.159c2.176,-4.134 6.527,-4.134 8.703,-2.067c2.176,2.067 2.176,6.201 0,10.335c-1.523,3.1 -5.439,6.201 -8.703,8.268c-3.264,-2.067 -7.18,-5.168 -8.703,-8.268c-2.176,-4.134 -2.176,-8.268 -0,-10.335c2.176,-2.067 6.527,-2.067 8.703,2.067Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 794 B |
1
public/img/blog/like-light.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="like" x="0" y="0" width="30.72" height="30.72" style="fill:none;"/><path d="M15.36,9.159c2.176,-4.134 6.527,-4.134 8.703,-2.067c2.176,2.067 2.176,6.201 0,10.335c-1.523,3.1 -5.439,6.201 -8.703,8.268c-3.264,-2.067 -7.18,-5.168 -8.703,-8.268c-2.176,-4.134 -2.176,-8.268 -0,-10.335c2.176,-2.067 6.527,-2.067 8.703,2.067Z"/></svg>
|
||||
|
After Width: | Height: | Size: 775 B |
|
Before Width: | Height: | Size: 6.3 KiB |
|
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Artboard1" x="0" y="0" width="15.36" height="15.36" style="fill:none;"/><g id="Artboard11" serif:id="Artboard1"><path d="M13.44,7.68c0,0.53 -0.43,0.96 -0.96,0.96l-9.6,0c-0.53,0 -0.96,-0.43 -0.96,-0.96c-0,-0.53 0.43,-0.96 0.96,-0.96l9.6,-0c0.53,-0 0.96,0.43 0.96,0.96Z"/><path d="M13.44,11.52c0,0.53 -0.43,0.96 -0.96,0.96l-9.6,0c-0.53,0 -0.96,-0.43 -0.96,-0.96c-0,-0.53 0.43,-0.96 0.96,-0.96l9.6,0c0.53,0 0.96,0.43 0.96,0.96Z"/><path d="M13.44,3.84c0,0.53 -0.43,0.96 -0.96,0.96l-9.6,0c-0.53,0 -0.96,-0.43 -0.96,-0.96c-0,-0.53 0.43,-0.96 0.96,-0.96l9.6,0c0.53,0 0.96,0.43 0.96,0.96Z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1 KiB |
15
public/script/blog.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { hijackClickEvent } from "./main.js";
|
||||
|
||||
document.querySelectorAll('article.blog-post').forEach(element => {
|
||||
const link = element.querySelector('.blog-title a');
|
||||
hijackClickEvent(element, link);
|
||||
});
|
||||
document.querySelectorAll('article.blog-post').forEach(element => {
|
||||
const link = element.querySelector('.blog-title a');
|
||||
hijackClickEvent(element, link);
|
||||
});
|
||||
|
||||
document.getElementById('load-more').addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
alert('ok');
|
||||
});
|
||||
20
public/script/blogpost.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { hijackClickEvent } from "./main.js";
|
||||
|
||||
document.querySelectorAll('.comment-hover').forEach((/** @type {HTMLDivElement} */ comment) => {
|
||||
/** @type {HTMLLinkElement} */
|
||||
const commentDate = comment.querySelector('.comment-date');
|
||||
hijackClickEvent(comment, commentDate);
|
||||
});
|
||||
|
||||
/*
|
||||
document.getElementById('blog-copy-link').addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
if (navigator.clipboard === undefined) {
|
||||
console.error("clipboard is not supported by this browser!");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(location.protocol + "//" + location.host + location.pathname);
|
||||
});
|
||||
*/
|
||||
|
||||
hljs.highlightAll();
|
||||