Compare commits
39 commits
main
...
feature/bl
| Author | SHA1 | Date | |
|---|---|---|---|
| 5341ff3033 | |||
| 1d98a6fdca | |||
| 0a75216aaf | |||
| 84e40b837a | |||
| b7c1d85830 | |||
| a33e6717e0 | |||
| 09c09b6310 | |||
| 0c2aaa0b38 | |||
| eaa2f6587d | |||
| fec3325503 | |||
| 21912d4ec2 | |||
| 82fd17c836 | |||
| 65366032fd | |||
| ee8bf6543e | |||
| c547fca0d7 | |||
| 3e5ecb9372 | |||
| bf9289400e | |||
| 55b0513f67 | |||
| 823fe2e356 | |||
| 94774352b3 | |||
| 31a02a12a5 | |||
| bd2dc806d5 | |||
| ddbf3444eb | |||
| faf6095d16 | |||
| 3d64333b4f | |||
| 3da0249555 | |||
| 053faeb493 | |||
| 0596edc4b2 | |||
| fece0f5da6 | |||
| 486c9ae641 | |||
| 82a4cde8c9 | |||
| bc90015a33 | |||
| dd8e503b61 | |||
| 99e5eb290f | |||
| 0796ea8fde | |||
| 5aa241e4d6 | |||
| 835dd344ca | |||
| 1a8dc4d9ce | |||
| 8eb432539c |
|
|
@ -1,4 +1,4 @@
|
|||
package admin
|
||||
package account
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/url"
|
||||
"os"
|
||||
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/log"
|
||||
|
|
@ -15,9 +16,12 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func accountHandler(app *model.AppState) http.Handler {
|
||||
func Handler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/account/", accountIndexHandler(app))
|
||||
|
||||
mux.Handle("/account/totp-setup", totpSetupHandler(app))
|
||||
mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
|
||||
mux.Handle("/account/totp-delete", totpDeleteHandler(app))
|
||||
|
|
@ -25,7 +29,8 @@ func accountHandler(app *model.AppState) http.Handler {
|
|||
mux.Handle("/account/password", changePasswordHandler(app))
|
||||
mux.Handle("/account/delete", deleteAccountHandler(app))
|
||||
|
||||
return mux
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func accountIndexHandler(app *model.AppState) http.Handler {
|
||||
|
|
@ -45,7 +50,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
accountResponse struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
TOTPs []TOTP
|
||||
}
|
||||
)
|
||||
|
|
@ -66,7 +71,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 },
|
||||
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
|
||||
TOTPs: totps,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -93,7 +98,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 +108,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 +117,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 +125,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 +153,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 +161,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 +175,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
type totpConfirmData struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
TOTP *model.TOTP
|
||||
NameEscaped string
|
||||
QRBase64Image string
|
||||
|
|
@ -181,7 +186,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
|||
if r.Method == http.MethodGet {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session })
|
||||
err := templates.TOTPSetupTemplate.Execute(w, core.AdminPageData{ Path: "/account", Session: session })
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
@ -219,7 +224,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
|||
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 },
|
||||
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
||||
|
|
@ -235,7 +240,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{
|
||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
||||
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
|
||||
TOTP: &totp,
|
||||
NameEscaped: url.PathEscape(totp.Name),
|
||||
QRBase64Image: qrBase64Image,
|
||||
|
|
@ -271,7 +276,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
|||
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 {
|
||||
|
|
@ -291,7 +296,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
|||
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 },
|
||||
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
|
||||
TOTP: totp,
|
||||
NameEscaped: url.PathEscape(totp.Name),
|
||||
QRBase64Image: qrBase64Image,
|
||||
|
|
@ -307,7 +312,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
|||
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,7 +320,7 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -343,7 +348,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
|
|||
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)
|
||||
})
|
||||
}
|
||||
379
admin/auth/authhttp.go
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"arimelody-web/admin/core"
|
||||
"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)
|
||||
|
||||
render := func() {
|
||||
err := templates.LoginTemplate.Execute(w, core.AdminPageData{ Session: session })
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
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, core.AdminPageData{ Session: session })
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
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
|
||||
}
|
||||
133
admin/blog/blog.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package blog
|
||||
|
||||
import (
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func Handler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/blogs/{id}", serveBlogPost(app))
|
||||
mux.Handle("/blogs/", serveBlogIndex(app))
|
||||
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type (
|
||||
blogPostCollection struct {
|
||||
Year int
|
||||
Posts []*model.BlogPost
|
||||
}
|
||||
)
|
||||
|
||||
func (c *blogPostCollection) Clone() blogPostCollection {
|
||||
return blogPostCollection{
|
||||
Year: c.Year,
|
||||
Posts: slices.Clone(c.Posts),
|
||||
}
|
||||
}
|
||||
|
||||
func serveBlogIndex(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
posts, err := controller.GetBlogPosts(app.DB, false, -1, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
collections := []*blogPostCollection{}
|
||||
collection := blogPostCollection{
|
||||
Posts: []*model.BlogPost{},
|
||||
Year: -1,
|
||||
}
|
||||
for i, post := range posts {
|
||||
if i == 0 {
|
||||
collection.Year = post.PublishDate.Year()
|
||||
}
|
||||
|
||||
if post.PublishDate.Year() != collection.Year {
|
||||
clone := collection.Clone()
|
||||
collections = append(collections, &clone)
|
||||
collection = blogPostCollection{
|
||||
Year: post.PublishDate.Year(),
|
||||
Posts: []*model.BlogPost{},
|
||||
}
|
||||
}
|
||||
|
||||
collection.Posts = append(collection.Posts, post)
|
||||
|
||||
if i == len(posts) - 1 {
|
||||
collections = append(collections, &collection)
|
||||
}
|
||||
}
|
||||
|
||||
type blogsData struct {
|
||||
core.AdminPageData
|
||||
TotalPosts int
|
||||
Collections []*blogPostCollection
|
||||
}
|
||||
|
||||
err = templates.BlogsTemplate.Execute(w, blogsData{
|
||||
AdminPageData: core.AdminPageData{
|
||||
Path: r.URL.Path,
|
||||
Session: session,
|
||||
},
|
||||
TotalPosts: len(posts),
|
||||
Collections: collections,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin blog index: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func serveBlogPost(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
blogID := r.PathValue("id")
|
||||
|
||||
post, err := controller.GetBlogPost(app.DB, blogID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog %s: %v\n", blogID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if post == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type blogPostData struct {
|
||||
core.AdminPageData
|
||||
Post *model.BlogPost
|
||||
}
|
||||
|
||||
err = templates.EditBlogTemplate.Execute(w, blogPostData{
|
||||
AdminPageData: core.AdminPageData{
|
||||
Path: r.URL.Path,
|
||||
Session: session,
|
||||
},
|
||||
Post: post,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin edit page for blog %s: %v\n", blogID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
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))
|
||||
})
|
||||
}
|
||||
8
admin/core/structs.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package core
|
||||
|
||||
import "arimelody-web/model"
|
||||
|
||||
type AdminPageData struct {
|
||||
Path string
|
||||
Session *model.Session
|
||||
}
|
||||
504
admin/http.go
|
|
@ -1,59 +1,35 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/admin/account"
|
||||
"arimelody-web/admin/auth"
|
||||
"arimelody-web/admin/blog"
|
||||
"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("/logs", core.RequireAccount(logs.Handler(app)))
|
||||
mux.Handle("/music/", core.RequireAccount(music.Handler(app)))
|
||||
mux.Handle("/blogs/", core.RequireAccount(blog.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("/account/", core.RequireAccount(account.Handler(app)))
|
||||
|
||||
mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/static/admin.css" {
|
||||
|
|
@ -64,18 +40,18 @@ func Handler(app *model.AppState) http.Handler {
|
|||
http.ServeFile(w, r, "./admin/static/admin.js")
|
||||
return
|
||||
}
|
||||
requireAccount(
|
||||
core.RequireAccount(
|
||||
http.StripPrefix("/static",
|
||||
view.ServeFiles("./admin/static"))).ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
mux.Handle("/", requireAccount(AdminIndexHandler(app)))
|
||||
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 {
|
||||
func adminIndexHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
|
|
@ -86,413 +62,93 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
|||
|
||||
releases, err := controller.GetAllReleases(app.DB, false, 3, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %v\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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %v\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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %v\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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %v\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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %v\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 track count: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type BlogPost struct {
|
||||
*model.BlogPost
|
||||
Author *model.Account
|
||||
}
|
||||
blogPosts, err := controller.GetBlogPosts(app.DB, false, 1, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog posts: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var latestBlogPost *model.BlogPost = nil
|
||||
if len(blogPosts) > 0 { latestBlogPost = blogPosts[0] }
|
||||
blogCount, err := controller.GetBlogPostCount(app.DB, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog post count: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type IndexData struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
Releases []*model.Release
|
||||
ReleaseCount int
|
||||
Artists []*model.Artist
|
||||
ArtistCount int
|
||||
Tracks []*model.Track
|
||||
TrackCount int
|
||||
LatestBlogPost *model.BlogPost
|
||||
BlogCount int
|
||||
}
|
||||
|
||||
err = templates.IndexTemplate.Execute(w, IndexData{
|
||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
||||
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
|
||||
Releases: releases,
|
||||
ReleaseCount: releaseCount,
|
||||
Artists: artists,
|
||||
ArtistCount: artistCount,
|
||||
Tracks: tracks,
|
||||
TrackCount: trackCount,
|
||||
LatestBlogPost: latestBlogPost,
|
||||
BlogCount: blogCount,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -513,63 +169,3 @@ func staticHandler() http.Handler {
|
|||
})
|
||||
}
|
||||
*/
|
||||
|
||||
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,6 +1,7 @@
|
|||
package admin
|
||||
package logs
|
||||
|
||||
import (
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
|
|
@ -10,7 +11,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 +52,12 @@ func logsHandler(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
type LogsResponse struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
Logs []*log.Log
|
||||
}
|
||||
|
||||
err = templates.LogsTemplate.Execute(w, LogsResponse{
|
||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
||||
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
|
||||
Logs: logs,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
package admin
|
||||
package music
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"os"
|
||||
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
|
|
@ -14,32 +15,24 @@ 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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type ArtistsResponse struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
Artists []*model.Artist
|
||||
}
|
||||
|
||||
err = templates.ArtistsTemplate.Execute(w, ArtistsResponse{
|
||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
||||
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
|
||||
Artists: artists,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve admin artists page: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artists page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -55,31 +48,31 @@ func serveArtist(app *model.AppState, artistID string) http.Handler {
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %v\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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist credits for %s: %v\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type ArtistResponse struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
Artist *model.Artist
|
||||
Credits []*model.Credit
|
||||
}
|
||||
|
||||
err = templates.EditArtistTemplate.Execute(w, ArtistResponse{
|
||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
||||
AdminPageData: core.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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artist page for %s: %v\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
32
admin/music/musichttp.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package music
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Handler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/music/releases/{id}/", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveEditRelease(app, r.PathValue("id")).ServeHTTP(w, r)
|
||||
})
|
||||
mux.HandleFunc("/music/releases/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveRelease(app, r.PathValue("id")).ServeHTTP(w, r)
|
||||
})
|
||||
mux.Handle("/music/releases/", serveReleases(app))
|
||||
|
||||
mux.HandleFunc("/music/artists/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveArtist(app, r.PathValue("id")).ServeHTTP(w, r)
|
||||
})
|
||||
mux.Handle("/music/artists/", serveArtists(app))
|
||||
|
||||
mux.HandleFunc("/music/tracks/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveTrack(app, r.PathValue("id")).ServeHTTP(w, r)
|
||||
})
|
||||
mux.Handle("/music/tracks/", serveTracks(app))
|
||||
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
package admin
|
||||
package music
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
|
|
@ -15,115 +15,101 @@ func serveReleases(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:], "/")
|
||||
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
|
||||
core.AdminPageData
|
||||
Releases []*model.Release
|
||||
}
|
||||
|
||||
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = templates.ReleasesTemplate.Execute(w, ReleasesData{
|
||||
adminPageData: adminPageData{
|
||||
AdminPageData: core.AdminPageData{
|
||||
Path: r.URL.Path,
|
||||
Session: session,
|
||||
},
|
||||
Releases: releases,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func serveRelease(app *model.AppState, releaseID string, action string) http.Handler {
|
||||
func serveRelease(app *model.AppState, releaseID 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)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch release %s: %v\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(action) > 0 {
|
||||
switch action {
|
||||
case "editcredits":
|
||||
serveEditCredits(release).ServeHTTP(w, r)
|
||||
return
|
||||
case "addcredit":
|
||||
serveAddCredit(app, release).ServeHTTP(w, r)
|
||||
return
|
||||
case "newcredit":
|
||||
serveNewCredit(app).ServeHTTP(w, r)
|
||||
return
|
||||
case "editlinks":
|
||||
serveEditLinks(release).ServeHTTP(w, r)
|
||||
return
|
||||
case "edittracks":
|
||||
serveEditTracks(release).ServeHTTP(w, r)
|
||||
return
|
||||
case "addtrack":
|
||||
serveAddTrack(app, release).ServeHTTP(w, r)
|
||||
return
|
||||
case "newtrack":
|
||||
serveNewTrack(app).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if release == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
type ReleaseResponse struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
Release *model.Release
|
||||
}
|
||||
|
||||
for i, track := range release.Tracks {
|
||||
track.Number = i + 1
|
||||
}
|
||||
for i, track := range release.Tracks { track.Number = i + 1 }
|
||||
|
||||
err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
|
||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
||||
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
|
||||
Release: release,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin release page for %s: %v\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func serveEditRelease(app *model.AppState, releaseID string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch release %s: %v\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if release == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("GET /music/releases/{id}/editcredits", serveEditCredits(release))
|
||||
mux.Handle("GET /music/releases/{id}/addcredit", serveAddCredit(app, release))
|
||||
mux.Handle("GET /music/releases/{id}/newcredit/{artistID}", serveNewCredit(app))
|
||||
|
||||
mux.Handle("GET /music/releases/{id}/editlinks", serveEditLinks(release))
|
||||
|
||||
mux.Handle("GET /music/releases/{id}/edittracks", serveEditTracks(release))
|
||||
mux.Handle("GET /music/releases/{id}/addtrack", serveAddTrack(app, release))
|
||||
mux.Handle("GET /music/releases/{id}/newtrack/{trackID}", serveNewTrack(app))
|
||||
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func serveEditCredits(release *model.Release) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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.Fprintf(os.Stderr, "WARN: Failed to serve edit credits component for %s: %v\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -133,7 +119,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.Fprintf(os.Stderr, "WARN: Failed to fetch artists not on %s: %v\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -149,7 +135,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.Fprintf(os.Stderr, "WARN: Failed to serve add credits component for %s: %v\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -157,10 +143,10 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
|
|||
|
||||
func serveNewCredit(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
artistID := strings.Split(r.URL.Path, "/")[3]
|
||||
artistID := r.PathValue("artistID")
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %v\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -172,7 +158,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.Fprintf(os.Stderr, "WARN: Failed to serve new credit component for %s: %v\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -183,7 +169,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.Fprintf(os.Stderr, "WARN: Failed to serve edit links component for %s: %v\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -195,9 +181,11 @@ func serveEditTracks(release *model.Release) http.Handler {
|
|||
|
||||
type editTracksData struct { Release *model.Release }
|
||||
|
||||
for i, track := range release.Tracks { track.Number = i + 1 }
|
||||
|
||||
err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release })
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit tracks component for %s: %v\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -207,7 +195,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.Fprintf(os.Stderr, "WARN: Failed to fetch tracks not on %s: %v\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -223,7 +211,7 @@ 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.Fprintf(os.Stderr, "WARN: Failed to add tracks component for %s: %v\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -231,10 +219,10 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
|
|||
|
||||
func serveNewTrack(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
trackID := strings.Split(r.URL.Path, "/")[3]
|
||||
trackID := r.PathValue("trackID")
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %v\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -246,7 +234,7 @@ 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.Fprintf(os.Stderr, "WARN: Failed to serve new track component for %s: %v\n", track.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
package admin
|
||||
package music
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"os"
|
||||
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
|
|
@ -14,32 +15,24 @@ 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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type TracksResponse struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
Tracks []*model.Track
|
||||
}
|
||||
|
||||
err = templates.TracksTemplate.Execute(w, TracksResponse{
|
||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
||||
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
|
||||
Tracks: tracks,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve admin tracks page: %s\n", err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin tracks page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -51,7 +44,7 @@ func serveTrack(app *model.AppState, trackID string) http.Handler {
|
|||
|
||||
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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %v\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -62,24 +55,24 @@ func serveTrack(app *model.AppState, trackID string) http.Handler {
|
|||
|
||||
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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases for track %s: %v\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
Track *model.Track
|
||||
Releases []*model.Release
|
||||
}
|
||||
|
||||
err = templates.EditTrackTemplate.Execute(w, TrackResponse{
|
||||
adminPageData: adminPageData{ Path: r.URL.Path, Session: session },
|
||||
AdminPageData: core.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)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin track page for %s: %v\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -111,7 +111,7 @@ body {
|
|||
font-family: "Inter", sans-serif;
|
||||
font-size: 16px;
|
||||
color: var(--fg-0);
|
||||
background: var(--bg-0);
|
||||
background-color: var(--bg-0);
|
||||
|
||||
transition: background .1s ease-out, color .1s ease-out;
|
||||
}
|
||||
|
|
@ -252,12 +252,6 @@ a {
|
|||
transition: color .1s ease-out, background-color .1s ease-out;
|
||||
}
|
||||
|
||||
/*
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
*/
|
||||
|
||||
img.icon {
|
||||
height: .8em;
|
||||
transition: filter .1s ease-out;
|
||||
|
|
@ -283,7 +277,7 @@ code {
|
|||
.card {
|
||||
flex-basis: 40em;
|
||||
padding: 1em;
|
||||
background: var(--bg-1);
|
||||
background-color: var(--bg-1);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
|
|
@ -361,7 +355,7 @@ a.delete:not(.button) {
|
|||
font-size: inherit;
|
||||
|
||||
color: inherit;
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
border: none;
|
||||
border-radius: 10em;
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
|
@ -380,27 +374,27 @@ button:active, .button:active {
|
|||
|
||||
.button.new, button.new {
|
||||
color: var(--col-on-new);
|
||||
background: var(--col-new);
|
||||
background-color: var(--col-new);
|
||||
}
|
||||
.button.save, button.save {
|
||||
color: var(--col-on-save);
|
||||
background: var(--col-save);
|
||||
background-color: var(--col-save);
|
||||
}
|
||||
.button.delete, button.delete {
|
||||
color: var(--col-on-delete);
|
||||
background: var(--col-delete);
|
||||
background-color: var(--col-delete);
|
||||
}
|
||||
.button:hover, button:hover {
|
||||
color: var(--bg-3);
|
||||
background: var(--fg-3);
|
||||
background-color: var(--fg-3);
|
||||
}
|
||||
.button:active, button:active {
|
||||
color: var(--bg-2);
|
||||
background: var(--fg-0);
|
||||
background-color: var(--fg-0);
|
||||
}
|
||||
.button[disabled], button[disabled] {
|
||||
color: var(--fg-0) !important;
|
||||
background: var(--bg-3) !important;
|
||||
background-color: var(--bg-3) !important;
|
||||
opacity: .5;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
|
@ -436,6 +430,39 @@ input[disabled] {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.actions {
|
||||
margin-top: .5em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: .5em;
|
||||
user-select: none;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.actions a,
|
||||
.actions button {
|
||||
padding: .3em .5em;
|
||||
display: inline-block;
|
||||
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-3);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
transition-property: color, background, transform;
|
||||
transition-duration: .1s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.actions a:hover,
|
||||
.actions button:hover {
|
||||
background-color: var(--bg-0);
|
||||
color: var(--fg-3);
|
||||
text-decoration: none;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
main {
|
||||
padding-top: 0;
|
||||
|
|
|
|||
|
|
@ -2,18 +2,22 @@
|
|||
padding: .5em;
|
||||
|
||||
color: var(--fg-3);
|
||||
background: var(--bg-2);
|
||||
background-color: 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;
|
||||
|
||||
transition-property: background, color, transform;
|
||||
transition-duration: .1s;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.artist:hover {
|
||||
background: var(--bg-1);
|
||||
background-color: var(--bg-1);
|
||||
text-decoration: hover;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.artist .artist-avatar {
|
||||
|
|
|
|||
|
|
@ -4,4 +4,29 @@ document.addEventListener("readystatechange", () => {
|
|||
document.querySelectorAll(".artists-group .artist").forEach(el => {
|
||||
hijackClickEvent(el, el.querySelector("a.artist-name"))
|
||||
});
|
||||
|
||||
const newArtistBtn = document.getElementById("create-artist");
|
||||
if (newArtistBtn) 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/artists/" + id;
|
||||
} else {
|
||||
alert(text);
|
||||
console.error(text);
|
||||
}
|
||||
})
|
||||
}).catch(err => {
|
||||
alert("Failed to create artist. Check the console for details.");
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
62
admin/static/blog.css
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
.blog-collection {
|
||||
margin-bottom: 1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
.blog-collection h2 {
|
||||
margin: 0 0 0 1em;
|
||||
font-size: 1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.blogpost {
|
||||
padding: 1em;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.blogpost .title {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.blogpost .title small {
|
||||
display: inline-block;
|
||||
font-size: .6em;
|
||||
transform: translateY(-0.1em);
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.blogpost .description {
|
||||
margin: .5em 0 .6em 0;
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.blogpost .meta {
|
||||
margin: 0;
|
||||
font-size: .8em;
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.blogpost .meta .author {
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.blogpost .meta .author img {
|
||||
width: 1.3em;
|
||||
height: 1.3em;
|
||||
margin-right: .2em;
|
||||
display: inline-block;
|
||||
transform: translate(0, 4px);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.blogpost a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
25
admin/static/blog.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
document.addEventListener('readystatechange', () => {
|
||||
const newBlogBtn = document.getElementById("create-post");
|
||||
if (newBlogBtn) newBlogBtn.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
const id = prompt("Enter an ID for this blog post:");
|
||||
if (id == null || id == "") return;
|
||||
|
||||
fetch("/api/v1/blog", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({id})
|
||||
}).then(res => {
|
||||
if (res.ok) location = "/admin/blogs/" + id;
|
||||
else {
|
||||
res.text().then(err => {
|
||||
alert(err);
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
alert("Failed to create release. Check the console for details.");
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -33,7 +33,7 @@ form#delete-account input {
|
|||
justify-content: space-between;
|
||||
|
||||
color: var(--fg-3);
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
gap: 1.2em;
|
||||
|
||||
border-radius: 16px;
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
|
|
@ -50,26 +50,11 @@ input[type="text"] {
|
|||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg-0);
|
||||
background-color: var(--bg-0);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
input[type="text"]:hover {
|
||||
border-color: #80808080;
|
||||
}
|
||||
input[type="text"]:active,
|
||||
input[type="text"]:focus {
|
||||
border-color: #808080;
|
||||
}
|
||||
|
||||
.artist-actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
flex-direction: row;
|
||||
justify-content: right;
|
||||
}
|
||||
|
||||
.card-header a.button {
|
||||
text-decoration: none;
|
||||
|
|
@ -84,7 +69,7 @@ input[type="text"]:focus {
|
|||
align-items: center;
|
||||
|
||||
border-radius: 16px;
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
cursor: pointer;
|
||||
|
|
@ -92,7 +77,7 @@ input[type="text"]:focus {
|
|||
}
|
||||
|
||||
.credit:hover {
|
||||
background: var(--bg-1);
|
||||
background-color: var(--bg-1);
|
||||
}
|
||||
|
||||
.release-artwork {
|
||||
|
|
|
|||
140
admin/static/edit-blog.css
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
input[type="text"] {
|
||||
padding: .3em .5em;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
color: var(--fg-3);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
transition: background-color .1s ease-out, color .1s ease-out;
|
||||
}
|
||||
|
||||
#blogpost {
|
||||
margin-bottom: 1em;
|
||||
padding: 1.5em;
|
||||
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-1);
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
transition: background-color .1s ease-out, color .1s ease-out;
|
||||
}
|
||||
|
||||
#blogpost label {
|
||||
margin: 1.2em 0 .2em .1em;
|
||||
display: block;
|
||||
font-size: .8em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
#blogpost label:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#blogpost button#set-current-date {
|
||||
margin: 0 .5em;
|
||||
padding: .4em .8em;
|
||||
}
|
||||
|
||||
#blogpost h2 {
|
||||
margin: 0;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
#blogpost #title {
|
||||
width: 100%;
|
||||
margin: 0 -.2em;
|
||||
padding: 0 .2em;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--fg-3);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
transition: background-color .1s ease-out, color .1s ease-out, border-color .1s ease-out;
|
||||
|
||||
/*position: relative; outline: none;*/
|
||||
white-space: pre-wrap; overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
#blogpost #title:hover {
|
||||
background-color: var(--bg-3);
|
||||
border-color: var(--fg-0);
|
||||
}
|
||||
|
||||
#blogpost #title:active,
|
||||
#blogpost #title:focus {
|
||||
background-color: var(--bg-3);
|
||||
}
|
||||
|
||||
#blogpost #publish-date {
|
||||
padding: .4em .5em;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background-color: var(--bg-2);
|
||||
color: var(--fg-3);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
transition: background-color .1s ease-out, color .1s ease-out;
|
||||
}
|
||||
|
||||
#blogpost textarea {
|
||||
width: calc(100% - 2em);
|
||||
margin: 0;
|
||||
padding: 1em;
|
||||
display: block;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-2);
|
||||
color: var(--fg-3);
|
||||
box-shadow: var(--shadow-md);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
|
||||
transition: background-color .1s ease-out, color .1s ease-out;
|
||||
}
|
||||
|
||||
#blogpost #description {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#blogpost select {
|
||||
padding: .5em .8em;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
border-radius: 10em;
|
||||
color: var(--fg-3);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
transition: background-color .1s ease-out, color .1s ease-out;
|
||||
}
|
||||
|
||||
#blogpost .social-post-details {
|
||||
margin: 1em 0 1em 0;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
#blogpost .blog-actions {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
input[type="text"],
|
||||
#blogpost #publish-date,
|
||||
#blogpost textarea,
|
||||
#blogpost select {
|
||||
background-color: var(--bg-0);
|
||||
}
|
||||
}
|
||||
83
admin/static/edit-blog.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
const blogID = document.getElementById("blogpost").dataset.id;
|
||||
const titleInput = document.getElementById("title");
|
||||
const publishDateInput = document.getElementById("publish-date");
|
||||
const setCurrentDateBtn = document.getElementById("set-current-date");
|
||||
const descInput = document.getElementById("description");
|
||||
const mdInput = document.getElementById("markdown");
|
||||
const blueskyActorInput = document.getElementById("bluesky-actor");
|
||||
const blueskyRecordInput = document.getElementById("bluesky-record");
|
||||
const fediverseAccountInput = document.getElementById("fediverse-account");
|
||||
const fediverseStatusInput = document.getElementById("fediverse-status");
|
||||
const visInput = document.getElementById("visibility");
|
||||
const saveBtn = document.getElementById("save");
|
||||
const deleteBtn = document.getElementById("delete");
|
||||
|
||||
setCurrentDateBtn.addEventListener("click", () => {
|
||||
let now = new Date;
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
publishDateInput.value = now.toISOString().slice(0, 16);
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", () => {
|
||||
fetch("/api/v1/blog/" + blogID, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
title: titleInput.innerText,
|
||||
publish_date: publishDateInput.value + ":00Z",
|
||||
description: descInput.value,
|
||||
markdown: mdInput.value,
|
||||
bluesky: {
|
||||
actor: blueskyActorInput.value,
|
||||
record: blueskyRecordInput.value,
|
||||
},
|
||||
fediverse: {
|
||||
account: fediverseAccountInput.value,
|
||||
status: fediverseStatusInput.value,
|
||||
},
|
||||
visible: visInput.value === "true",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
res.text().then(error => {
|
||||
console.error(error);
|
||||
alert("Failed to update blog post: " + error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
location = location;
|
||||
});
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener("click", () => {
|
||||
if (blogID != prompt(
|
||||
"You are about to permanently delete " + blogID + ". " +
|
||||
"This action is irreversible. " +
|
||||
"Please enter \"" + blogID + "\" to continue.")) return;
|
||||
fetch("/api/v1/blog/" + blogID, {
|
||||
method: "DELETE",
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
res.text().then(error => {
|
||||
console.error(error);
|
||||
alert("Failed to delete blog post: " + error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
location = "/admin";
|
||||
});
|
||||
});
|
||||
|
||||
[titleInput, publishDateInput, descInput, mdInput, visInput,
|
||||
blueskyActorInput, blueskyRecordInput,
|
||||
fediverseAccountInput, fediverseStatusInput].forEach(input => {
|
||||
input.addEventListener("change", () => {
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
input.addEventListener("keypress", () => {
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
|
@ -12,7 +12,7 @@ input[type="text"] {
|
|||
gap: 1.2em;
|
||||
|
||||
border-radius: 8px;
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
transition: background .1s ease-out, color .1s ease-out;
|
||||
|
|
@ -25,6 +25,8 @@ input[type="text"] {
|
|||
.release-artwork img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.release-artwork img:hover {
|
||||
outline: 1px solid #808080;
|
||||
|
|
@ -33,7 +35,10 @@ input[type="text"] {
|
|||
.release-artwork #remove-artwork {
|
||||
margin-top: .5em;
|
||||
padding: .3em .6em;
|
||||
background: var(--bg-3);
|
||||
background-color: var(--bg-3);
|
||||
}
|
||||
#remove-artwork:hover {
|
||||
background-color: var(--fg-3);
|
||||
}
|
||||
|
||||
.release-info {
|
||||
|
|
@ -62,13 +67,13 @@ input[type="text"] {
|
|||
}
|
||||
|
||||
#title:hover {
|
||||
background: var(--bg-3);
|
||||
background-color: var(--bg-3);
|
||||
border-color: var(--fg-0);
|
||||
}
|
||||
|
||||
#title:active,
|
||||
#title:focus {
|
||||
background: var(--bg-3);
|
||||
background-color: var(--bg-3);
|
||||
}
|
||||
|
||||
.release-title small {
|
||||
|
|
@ -93,7 +98,7 @@ input[type="text"] {
|
|||
.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-color: var(--bg-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.release-info table td select,
|
||||
|
|
@ -127,7 +132,7 @@ input[type="text"] {
|
|||
.release-actions button,
|
||||
.release-actions .button {
|
||||
color: var(--fg-2);
|
||||
background: var(--bg-3);
|
||||
background-color: var(--bg-3);
|
||||
}
|
||||
|
||||
dialog {
|
||||
|
|
@ -234,7 +239,7 @@ dialog div.dialog-actions {
|
|||
gap: 1em;
|
||||
|
||||
border-radius: 8px;
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
|
|
@ -280,7 +285,7 @@ dialog div.dialog-actions {
|
|||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--fg-2);
|
||||
background: var(--bg-0);
|
||||
background-color: var(--bg-0);
|
||||
}
|
||||
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
|
||||
margin: 0 .3em;
|
||||
|
|
@ -299,6 +304,7 @@ dialog div.dialog-actions {
|
|||
#editcredits .credit .delete {
|
||||
margin-right: .5em;
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
}
|
||||
#editcredits .credit .delete:hover {
|
||||
text-decoration: underline;
|
||||
|
|
@ -315,14 +321,17 @@ dialog div.dialog-actions {
|
|||
display: flex;
|
||||
gap: .5em;
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-2);
|
||||
}
|
||||
|
||||
#addcredit ul li.new-artist:nth-child(even) {
|
||||
background: #f0f0f0;
|
||||
background-color: var(--bg-1);
|
||||
}
|
||||
|
||||
#addcredit ul li.new-artist:hover {
|
||||
background: #e0e0e0;
|
||||
background-color: var(--bg-2);
|
||||
}
|
||||
|
||||
#addcredit .new-artist .artist-id {
|
||||
|
|
@ -375,6 +384,8 @@ dialog div.dialog-actions {
|
|||
|
||||
#editlinks tr {
|
||||
display: flex;
|
||||
background-color: var(--bg-1);
|
||||
transition: background-color .1s ease-out;
|
||||
}
|
||||
|
||||
#editlinks th {
|
||||
|
|
@ -385,7 +396,7 @@ dialog div.dialog-actions {
|
|||
}
|
||||
|
||||
#editlinks tr:nth-child(odd) {
|
||||
background: #f8f8f8;
|
||||
background-color: var(--bg-2);
|
||||
}
|
||||
|
||||
#editlinks tr th,
|
||||
|
|
@ -416,6 +427,11 @@ dialog div.dialog-actions {
|
|||
width: 1em;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#editlinks tr .grabber img {
|
||||
filter: invert();
|
||||
}
|
||||
}
|
||||
#editlinks tr .link-name {
|
||||
width: 8em;
|
||||
}
|
||||
|
|
@ -454,6 +470,7 @@ dialog div.dialog-actions {
|
|||
}
|
||||
|
||||
#edittracks .track {
|
||||
background-color: var(--bg-1);
|
||||
transition: transform .2s ease-out, opacity .2s;
|
||||
}
|
||||
|
||||
|
|
@ -476,7 +493,7 @@ dialog div.dialog-actions {
|
|||
}
|
||||
|
||||
#edittracks .track:nth-child(even) {
|
||||
background: #f0f0f0;
|
||||
background-color: var(--bg-0);
|
||||
}
|
||||
|
||||
#edittracks .track-number {
|
||||
|
|
@ -492,24 +509,23 @@ dialog div.dialog-actions {
|
|||
#addtrack ul {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
#addtrack ul li.new-track {
|
||||
padding: .5em;
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
background-color: var(--bg-0);
|
||||
background-color: var(--bg-1);
|
||||
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-color: var(--bg-0);
|
||||
}
|
||||
|
||||
#addtrack ul li.new-track:hover {
|
||||
background: color-mix(in srgb, var(--bg-0) 90%, #fff);
|
||||
background-color: var(--bg-2);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1105px) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
gap: 1.2em;
|
||||
|
||||
border-radius: 16px;
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
|
|
@ -45,25 +45,13 @@
|
|||
font-weight: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--bg-0);
|
||||
background-color: var(--bg-0);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.track-info input[type="text"]:hover,
|
||||
.track-info textarea:hover {
|
||||
border-color: #80808080;
|
||||
}
|
||||
|
||||
.track-info input[type="text"]:active,
|
||||
.track-info textarea:active,
|
||||
.track-info input[type="text"]:focus,
|
||||
.track-info textarea:focus {
|
||||
border-color: #808080;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
margin-top: 1em;
|
||||
display: flex;
|
||||
|
|
|
|||
1
admin/static/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import url("/admin/static/release-list-item.css");
|
||||
|
|
@ -8,7 +8,7 @@ form#search-form {
|
|||
padding: 1em;
|
||||
border-radius: 16px;
|
||||
color: var(--fg-0);
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ div#search {
|
|||
border: none;
|
||||
border-radius: 16px;
|
||||
color: var(--fg-1);
|
||||
background: var(--bg-0);
|
||||
background-color: var(--bg-0);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
|
|
@ -100,8 +100,8 @@ td.log-content {
|
|||
|
||||
#logs .log.warn {
|
||||
color: var(--col-on-warn);
|
||||
background: var(--col-warn);
|
||||
background-color: var(--col-warn);
|
||||
}
|
||||
#logs .log.warn:hover {
|
||||
background: var(--col-warn-hover);
|
||||
background-color: var(--col-warn-hover);
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ newReleaseBtn.addEventListener("click", event => {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({id})
|
||||
}).then(res => {
|
||||
if (res.ok) location = "/admin/releases/" + id;
|
||||
if (res.ok) location = "/admin/music/releases/" + id;
|
||||
else {
|
||||
res.text().then(err => {
|
||||
alert("Request failed: " + err);
|
||||
|
|
@ -37,7 +37,7 @@ newArtistBtn.addEventListener("click", event => {
|
|||
}).then(res => {
|
||||
res.text().then(text => {
|
||||
if (res.ok) {
|
||||
location = "/admin/artists/" + id;
|
||||
location = "/admin/music/artists/" + id;
|
||||
} else {
|
||||
alert("Request failed: " + text);
|
||||
console.error(text);
|
||||
|
|
@ -61,7 +61,7 @@ newTrackBtn.addEventListener("click", event => {
|
|||
}).then(res => {
|
||||
res.text().then(text => {
|
||||
if (res.ok) {
|
||||
location = "/admin/tracks/" + text;
|
||||
location = "/admin/music/tracks/" + text;
|
||||
} else {
|
||||
alert("Request failed: " + text);
|
||||
console.error(text);
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
gap: 1em;
|
||||
|
||||
border-radius: 16px;
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
transition: background .1s ease-out, color .1s ease-out;
|
||||
|
|
@ -54,27 +54,3 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
25
admin/static/releases.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
document.addEventListener('readystatechange', () => {
|
||||
const newReleaseBtn = document.getElementById("create-release");
|
||||
if (newReleaseBtn) 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/releases/" + id;
|
||||
else {
|
||||
res.text().then(err => {
|
||||
alert(err);
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
alert("Failed to create release. Check the console for details.");
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
gap: .5em;
|
||||
|
||||
border-radius: 16px;
|
||||
background: var(--bg-2);
|
||||
background-color: var(--bg-2);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
transition: background .1s ease-out, color .1s ease-out;
|
||||
|
|
@ -44,11 +44,6 @@
|
|||
opacity: .5;
|
||||
}
|
||||
|
||||
#tracks .track-album.empty {
|
||||
color: #ff2020;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#tracks .track-description {
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -67,61 +62,4 @@
|
|||
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;
|
||||
}
|
||||
*/
|
||||
|
|
|
|||
24
admin/static/tracks.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const newTrackBtn = document.getElementById("create-track");
|
||||
if (newTrackBtn) 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/tracks/" + text;
|
||||
} else {
|
||||
alert(text);
|
||||
console.error(text);
|
||||
}
|
||||
})
|
||||
}).catch(err => {
|
||||
alert("Failed to create track. Check the console for details.");
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
33
admin/templates/html/blogs.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{{define "head"}}
|
||||
<title>Blog - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/blog.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<header>
|
||||
<h1>Blog Posts <small>({{.TotalPosts}} total)</small></h2>
|
||||
<a class="button new" id="create-post">Create New</a>
|
||||
</header>
|
||||
|
||||
{{if .Collections}}
|
||||
<div class="blog-group">
|
||||
{{range .Collections}}
|
||||
{{if .Posts}}
|
||||
<div class="blog-collection">
|
||||
<h2>{{.Year}}</h2>
|
||||
{{range .Posts}}
|
||||
{{block "blogpost" .}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p>There are no blog posts.</p>
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/blog.js"></script>
|
||||
{{end}}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{{define "artist"}}
|
||||
<div class="artist">
|
||||
<img src="{{.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
|
||||
<a href="/admin/artists/{{.ID}}" class="artist-name">{{.Name}}</a>
|
||||
<a href="/admin/music/artists/{{.ID}}" class="artist-name">{{.Name}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
14
admin/templates/html/components/blog/blogpost.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{{define "blogpost"}}
|
||||
<div class="blogpost">
|
||||
<h3 class="title"><a href="/admin/blogs/{{.ID}}">{{.Title}}</a>{{if not .Visible}} <small>(Not published)</small>{{end}}</h3>
|
||||
<p class="meta">
|
||||
<span class="author"><img src="/img/favicon.png" alt="" width="32" height="32"/> {{.Author.DisplayName}}</span>
|
||||
<span class="date">• {{.PrintDate}}</span>
|
||||
</p>
|
||||
<p class="description">{{.Description}}</p>
|
||||
<div class="actions">
|
||||
<a href="/admin/blogs/{{.ID}}">Edit</a>
|
||||
<a href="/blog/{{.ID}}">View</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -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/releases/{{$.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/releases/{{.ID}}/addcredit"
|
||||
hx-get="/admin/music/releases/{{.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/releases/{{.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>
|
||||
<div class="release-actions">
|
||||
<a href="/admin/releases/{{.ID}}">Edit</a>
|
||||
(<a href="/admin/music/releases/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
|
||||
<div class="actions">
|
||||
<a href="/admin/music/releases/{{.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/releases/{{$.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/releases/{{.Release.ID}}/addtrack"
|
||||
hx-get="/admin/music/releases/{{.Release.ID}}/addtrack"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Add</a>
|
||||
|
|
@ -12,12 +12,12 @@
|
|||
|
||||
<form action="/api/v1/music/{{.Release.ID}}/tracks">
|
||||
<ul>
|
||||
{{range $i, $track := .Release.Tracks}}
|
||||
<li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true">
|
||||
{{range .Release.Tracks}}
|
||||
<li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true">
|
||||
<div>
|
||||
<p class="track-name">
|
||||
<span class="track-number">{{.Add $i 1}}</span>
|
||||
{{$track.Title}}
|
||||
<span class="track-number">{{.Number}}</span>
|
||||
{{.Title}}
|
||||
</p>
|
||||
<a class="delete">Delete</a>
|
||||
</div>
|
||||
|
|
@ -49,7 +49,6 @@
|
|||
|
||||
deleteBtn.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
if (!confirm("Are you sure you want to remove " + trackTitle + "?")) return;
|
||||
trackItem.remove();
|
||||
refreshTrackNumbers();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
{{if .Number}}
|
||||
<span class="track-number">{{.Number}}</span>
|
||||
{{end}}
|
||||
<a href="/admin/tracks/{{.ID}}">{{.Title}}</a>
|
||||
<a href="/admin/music/tracks/{{.ID}}">{{.Title}}</a>
|
||||
</h2>
|
||||
|
||||
<h3>Description</h3>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
<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/releases/{{.Release.ID}}">{{.Release.Title}}</a></h3>
|
||||
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
|
||||
<p class="artist-role">
|
||||
Role: {{.Role}}
|
||||
|
|
|
|||
118
admin/templates/html/edit-blog.html
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{{define "head"}}
|
||||
<title>Editing {{.Post.Title}} - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/edit-blog.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Editing Blog Post</h1>
|
||||
|
||||
<div id="blogpost" data-id="{{.Post.ID}}">
|
||||
<label for="title">Title</label>
|
||||
<h2 id="blog-title">
|
||||
<div
|
||||
id="title"
|
||||
name="title"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
spellcheck="true"
|
||||
aria-haspopup="listbox"
|
||||
aria-invalid="false"
|
||||
aria-autocomplete="list"
|
||||
autocorrect="off"
|
||||
contenteditable="true"
|
||||
zindex="-1"
|
||||
>{{.Post.Title}}</div>
|
||||
</h2>
|
||||
|
||||
<label for="publish-date">Publish Date</label>
|
||||
<input type="datetime-local" name="publish-date" id="publish-date" value="{{.Post.TextPublishDate}}">
|
||||
<button type="button" id="set-current-date">Current date</button>
|
||||
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value="{{.Post.Description}}"
|
||||
placeholder="No description provided."
|
||||
rows="3"
|
||||
>{{.Post.Description}}</textarea>
|
||||
|
||||
<label for="markdown">Markdown</label>
|
||||
<textarea
|
||||
id="markdown"
|
||||
name="markdown"
|
||||
value="{{.Post.Markdown}}"
|
||||
rows="30"
|
||||
>{{.Post.Markdown}}</textarea>
|
||||
|
||||
<div class="social-post-details">
|
||||
<div class="social-post-item">
|
||||
<label for="bluesky-actor">Bluesky Author DID</label>
|
||||
<input
|
||||
type="text"
|
||||
name="bluesky-actor"
|
||||
id="bluesky-actor"
|
||||
placeholder="did:plc:1234abcd..."
|
||||
value="{{if .Post.Bluesky}}{{.Post.Bluesky.ActorDID}}{{end}}">
|
||||
</div>
|
||||
<div class="social-post-item">
|
||||
<label for="bluesky-record">Bluesky Post ID</label>
|
||||
<input
|
||||
type="text"
|
||||
name="bluesky-record"
|
||||
id="bluesky-record"
|
||||
placeholder="3m109a03..."
|
||||
value="{{if .Post.Bluesky}}{{.Post.Bluesky.RecordID}}{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="social-post-details">
|
||||
<div class="social-post-item">
|
||||
<label for="fediverse-account">Fediverse Account</label>
|
||||
<input
|
||||
type="text"
|
||||
name="fediverse-account"
|
||||
id="fediverse-account"
|
||||
placeholder="@me@my.fediverse.place"
|
||||
value="{{if .Post.Fediverse}}{{.Post.Fediverse.AccountID}}{{end}}">
|
||||
</div>
|
||||
<div class="social-post-item">
|
||||
<label for="fediverse-status">Fediverse Status ID</label>
|
||||
<input
|
||||
type="text"
|
||||
name="fediverse-status"
|
||||
id="fediverse-status"
|
||||
placeholder="never consistent ¯\_(ツ)_/¯"
|
||||
value="{{if .Post.Fediverse}}{{.Post.Fediverse.StatusID}}{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="visibility">Visibility</label>
|
||||
<select name="visibility" id="visibility">
|
||||
<option value="true"{{if .Post.Visible}} selected{{end}}>Visible</option>
|
||||
<option value="false"{{if not .Post.Visible}} selected{{end}}>Hidden</option>
|
||||
</select>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/blog/{{.Post.ID}}" class="button">View</a>
|
||||
<button type="submit" class="save" id="save" disabled>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="danger">
|
||||
<div class="card-header">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<p>
|
||||
Clicking the button below will delete this blog post.
|
||||
This action is <strong>irreversible</strong>.
|
||||
You will be prompted to confirm this decision.
|
||||
</p>
|
||||
<button class="delete" id="delete">Delete Post</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/edit-blog.js"></script>
|
||||
{{end}}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Editing {{.Release.Title}}</h1>
|
||||
<h1>Editing Release</h1>
|
||||
|
||||
<div id="release" data-id="{{.Release.ID}}">
|
||||
<div class="release-artwork">
|
||||
|
|
@ -100,21 +100,22 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="credits">
|
||||
<div id="credits" class="card">
|
||||
<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"
|
||||
href="/admin/music/releases/{{.Release.ID}}/editcredits"
|
||||
hx-get="/admin/music/releases/{{.Release.ID}}/editcredits"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
|
||||
{{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/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
|
||||
<p class="artist-role">
|
||||
{{.Role}}
|
||||
{{if .Primary}}
|
||||
|
|
@ -125,40 +126,49 @@
|
|||
</div>
|
||||
{{end}}
|
||||
{{if not .Release.Credits}}
|
||||
<p>There are no credits.</p>
|
||||
<p>This release has no credits.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="links">
|
||||
<div id="links" class="card">
|
||||
<div class="card-header">
|
||||
<h2>Links ({{len .Release.Links}})</h2>
|
||||
<h2>Links <small>({{len .Release.Links}} total)</small></h2>
|
||||
<a class="button edit"
|
||||
href="/admin/releases/{{.Release.ID}}/editlinks"
|
||||
hx-get="/admin/releases/{{.Release.ID}}/editlinks"
|
||||
href="/admin/music/releases/{{.Release.ID}}/editlinks"
|
||||
hx-get="/admin/music/releases/{{.Release.ID}}/editlinks"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
|
||||
{{if .Release.Links}}
|
||||
<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>
|
||||
{{else}}
|
||||
<p>This release has no links.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="tracks">
|
||||
<div class="card-header" id="tracks">
|
||||
<h2>Tracklist ({{len .Release.Tracks}})</h2>
|
||||
<div id="tracks" class="card">
|
||||
<div class="card-header">
|
||||
<h2>Tracks <small>({{len .Release.Tracks}} total)</small></h2>
|
||||
<a class="button edit"
|
||||
href="/admin/releases/{{.Release.ID}}/edittracks"
|
||||
hx-get="/admin/releases/{{.Release.ID}}/edittracks"
|
||||
href="/admin/music/releases/{{.Release.ID}}/edittracks"
|
||||
hx-get="/admin/music/releases/{{.Release.ID}}/edittracks"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
>Edit</a>
|
||||
</div>
|
||||
{{range $i, $track := .Release.Tracks}}
|
||||
|
||||
{{range .Release.Tracks}}
|
||||
{{block "track" .}}{{end}}
|
||||
{{end}}
|
||||
{{if not .Release.Tracks}}
|
||||
<p>This release has no tracks.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="danger">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<link rel="stylesheet" href="/admin/static/releases.css">
|
||||
<link rel="stylesheet" href="/admin/static/artists.css">
|
||||
<link rel="stylesheet" href="/admin/static/tracks.css">
|
||||
<link rel="stylesheet" href="/admin/static/blog.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
|
|
@ -13,7 +14,7 @@
|
|||
<div class="cards">
|
||||
<div class="card" id="releases">
|
||||
<div class="card-header">
|
||||
<h2><a href="/admin/releases/">Releases</a> <small>({{.ReleaseCount}} total)</small></h2>
|
||||
<h2><a href="/admin/music/releases/">Releases</a> <small>({{.ReleaseCount}} total)</small></h2>
|
||||
<a class="button new" id="create-release">Create New</a>
|
||||
</div>
|
||||
{{if .Artists}}
|
||||
|
|
@ -27,7 +28,7 @@
|
|||
|
||||
<div class="card" id="artists">
|
||||
<div class="card-header">
|
||||
<h2><a href="/admin/artists/">Artists</a> <small>({{.ArtistCount}} total)</small></h2>
|
||||
<h2><a href="/admin/music/artists/">Artists</a> <small>({{.ArtistCount}} total)</small></h2>
|
||||
<a class="button new" id="create-artist">Create New</a>
|
||||
</div>
|
||||
{{if .Artists}}
|
||||
|
|
@ -43,7 +44,7 @@
|
|||
|
||||
<div class="card" id="tracks">
|
||||
<div class="card-header">
|
||||
<h2><a href="/admin/tracks/">Tracks</a> <small>({{.TrackCount}} total)</small></h2>
|
||||
<h2><a href="/admin/music/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>
|
||||
|
|
@ -52,10 +53,24 @@
|
|||
{{block "track" .}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="blogs">
|
||||
<div class="card-header">
|
||||
<h2><a href="/admin/blogs/">Latest Blog Post</a> <small>({{.BlogCount}} total)</small></h2>
|
||||
<a class="button new" id="create-post">Create New</a>
|
||||
</div>
|
||||
{{if .LatestBlogPost}}
|
||||
{{block "blogpost" .LatestBlogPost}}{{end}}
|
||||
{{else}}
|
||||
<p>There are no blog posts.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/releases.js"></script>
|
||||
<script type="module" src="/admin/static/artists.js"></script>
|
||||
<script type="module" src="/admin/static/index.js"></script>
|
||||
<script type="module" src="/admin/static/tracks.js"></script>
|
||||
<script type="module" src="/admin/static/blog.js"></script>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -27,24 +27,33 @@
|
|||
<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 class="nav-item{{if hasPrefix .Path "/music/releases"}} active{{end}}">
|
||||
<a href="/admin/music/releases/">releases</a>
|
||||
</div>
|
||||
<div class="nav-item{{if hasPrefix .Path "/artists"}} active{{end}}">
|
||||
<a href="/admin/artists/">artists</a>
|
||||
<div class="nav-item{{if hasPrefix .Path "/music/artists"}} active{{end}}">
|
||||
<a href="/admin/music/artists/">artists</a>
|
||||
</div>
|
||||
<div class="nav-item{{if hasPrefix .Path "/tracks"}} active{{end}}">
|
||||
<a href="/admin/tracks/">tracks</a>
|
||||
<div class="nav-item{{if hasPrefix .Path "/music/tracks"}} active{{end}}">
|
||||
<a href="/admin/music/tracks/">tracks</a>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<p class="section-label">blog</p>
|
||||
<div class="nav-item{{if hasPrefix .Path "/blogs"}} active{{end}}">
|
||||
<a href="/admin/blogs/">blog</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 class="nav-item{{if hasPrefix .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>
|
||||
|
|
|
|||
|
|
@ -21,4 +21,6 @@
|
|||
<p>There are no releases.</p>
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/releases.js"></script>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -12,22 +12,8 @@
|
|||
</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>
|
||||
{{range .Tracks}}
|
||||
{{block "track" .}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -17,63 +17,9 @@ 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{
|
||||
|
|
@ -84,48 +30,102 @@ var BaseTemplate = template.Must(
|
|||
prideflagHTML,
|
||||
}, "\n")))
|
||||
|
||||
//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
|
||||
|
||||
var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
||||
strings.Join([]string{
|
||||
indexHTML,
|
||||
componentReleaseHTML,
|
||||
componentArtistHTML,
|
||||
componentTrackHTML,
|
||||
componentBlogPostHTML,
|
||||
}, "\n"),
|
||||
))
|
||||
|
||||
|
||||
|
||||
//go:embed "html/login.html"
|
||||
var loginHTML string
|
||||
var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML))
|
||||
|
||||
//go:embed "html/login-totp.html"
|
||||
var loginTotpHTML string
|
||||
var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML))
|
||||
|
||||
//go:embed "html/register.html"
|
||||
var registerHTML string
|
||||
var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML))
|
||||
|
||||
//go:embed "html/logout.html"
|
||||
var logoutHTML string
|
||||
var LogoutTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(logoutHTML))
|
||||
|
||||
//go:embed "html/edit-account.html"
|
||||
var editAccountHTML string
|
||||
var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editAccountHTML))
|
||||
|
||||
//go:embed "html/totp-setup.html"
|
||||
var totpSetupHTML string
|
||||
var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML))
|
||||
|
||||
//go:embed "html/totp-confirm.html"
|
||||
var totpConfirmHTML string
|
||||
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,
|
||||
"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)
|
||||
},
|
||||
"toLower": func (str string) string {
|
||||
return strings.ToLower(str)
|
||||
},
|
||||
"prettyTime": func (t time.Time) string {
|
||||
return t.Format("02 Jan 2006, 15:04:05")
|
||||
},
|
||||
}).Parse(logsHTML))
|
||||
|
||||
|
||||
|
||||
//go:embed "html/releases.html"
|
||||
var releasesHTML string
|
||||
var ReleasesTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
||||
strings.Join([]string{
|
||||
releasesHTML,
|
||||
componentReleaseHTML,
|
||||
}, "\n"),
|
||||
))
|
||||
//go:embed "html/artists.html"
|
||||
var artistsHTML string
|
||||
var ArtistsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
||||
strings.Join([]string{
|
||||
artistsHTML,
|
||||
componentArtistHTML,
|
||||
}, "\n"),
|
||||
))
|
||||
//go:embed "html/tracks.html"
|
||||
var tracksHTML string
|
||||
var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
||||
strings.Join([]string{
|
||||
tracksHTML,
|
||||
|
|
@ -135,13 +135,21 @@ var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
|||
|
||||
|
||||
|
||||
//go:embed "html/edit-release.html"
|
||||
var editReleaseHTML string
|
||||
var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
||||
strings.Join([]string{
|
||||
editReleaseHTML,
|
||||
componentTrackHTML,
|
||||
}, "\n"),
|
||||
))
|
||||
|
||||
//go:embed "html/edit-artist.html"
|
||||
var editArtistHTML string
|
||||
var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML))
|
||||
|
||||
//go:embed "html/edit-track.html"
|
||||
var editTrackHTML string
|
||||
var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
||||
strings.Join([]string{
|
||||
editTrackHTML,
|
||||
|
|
@ -151,41 +159,54 @@ var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
|||
|
||||
|
||||
|
||||
//go:embed "html/components/credit/newcredit.html"
|
||||
var componentNewCreditHTML string
|
||||
var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML))
|
||||
|
||||
//go:embed "html/components/credit/addcredit.html"
|
||||
var componentAddCreditHTML string
|
||||
var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML))
|
||||
|
||||
//go:embed "html/components/credit/editcredits.html"
|
||||
var componentEditCreditsHTML string
|
||||
var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML))
|
||||
|
||||
|
||||
//go:embed "html/components/link/editlinks.html"
|
||||
var componentEditLinksHTML string
|
||||
var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditLinksHTML))
|
||||
|
||||
|
||||
//go:embed "html/components/track/newtrack.html"
|
||||
var componentNewTrackHTML string
|
||||
var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML))
|
||||
|
||||
//go:embed "html/components/track/addtrack.html"
|
||||
var componentAddTrackHTML string
|
||||
var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML))
|
||||
|
||||
//go:embed "html/components/track/edittracks.html"
|
||||
var componentEditTracksHTML string
|
||||
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")
|
||||
}
|
||||
//go:embed "html/blogs.html"
|
||||
var blogsHTML string
|
||||
//go:embed "html/components/blog/blogpost.html"
|
||||
var componentBlogPostHTML string
|
||||
var BlogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
||||
strings.Join([]string{
|
||||
blogsHTML,
|
||||
componentBlogPostHTML,
|
||||
}, "\n"),
|
||||
))
|
||||
|
||||
//go:embed "html/edit-blog.html"
|
||||
var editBlogHTML string
|
||||
var EditBlogTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
|
||||
strings.Join([]string{
|
||||
editBlogHTML,
|
||||
componentBlogPostHTML,
|
||||
}, "\n"),
|
||||
))
|
||||
|
|
|
|||
150
api/api.go
|
|
@ -18,129 +18,51 @@ func Handler(app *model.AppState) http.Handler {
|
|||
|
||||
// ARTIST ENDPOINTS
|
||||
|
||||
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var artistID = strings.Split(r.URL.Path[1:], "/")[0]
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
mux.Handle("GET /v1/artist/{id}", ServeArtist(app))
|
||||
mux.Handle("PUT /v1/artist/{id}", requireAccount(UpdateArtist(app)))
|
||||
mux.Handle("DELETE /v1/artist/{id}", requireAccount(DeleteArtist(app)))
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/artist/{id}
|
||||
ServeArtist(app, artist).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// PUT /api/v1/artist/{id} (admin)
|
||||
requireAccount(UpdateArtist(app, artist)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/artist/{id} (admin)
|
||||
requireAccount(DeleteArtist(app, artist)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})))
|
||||
mux.Handle("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/artist
|
||||
ServeAllArtists(app).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/artist (admin)
|
||||
requireAccount(CreateArtist(app)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
mux.Handle("GET /v1/artist/", ServeAllArtists(app))
|
||||
mux.Handle("GET /v1/artist", ServeAllArtists(app))
|
||||
mux.Handle("POST /v1/artist/", requireAccount(CreateArtist(app)))
|
||||
mux.Handle("POST /v1/artist", requireAccount(CreateArtist(app)))
|
||||
|
||||
// RELEASE ENDPOINTS
|
||||
|
||||
mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var releaseID = strings.Split(r.URL.Path[1:], "/")[0]
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
mux.Handle("GET /v1/music/{id}", ServeRelease(app))
|
||||
mux.Handle("PUT /v1/music/{id}", requireAccount(UpdateRelease(app)))
|
||||
mux.Handle("DELETE /v1/music/{id}", requireAccount(DeleteRelease(app)))
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/music/{id}
|
||||
ServeRelease(app, release).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// PUT /api/v1/music/{id} (admin)
|
||||
requireAccount(UpdateRelease(app, release)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/music/{id} (admin)
|
||||
requireAccount(DeleteRelease(app, release)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})))
|
||||
mux.Handle("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/music
|
||||
ServeCatalog(app).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/music (admin)
|
||||
requireAccount(CreateRelease(app)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
mux.Handle("PUT /v1/music/{id}/tracks", requireAccount(UpdateReleaseTracks(app)))
|
||||
mux.Handle("PUT /v1/music/{id}/credits", requireAccount(UpdateReleaseCredits(app)))
|
||||
mux.Handle("PUT /v1/music/{id}/links", requireAccount(UpdateReleaseLinks(app)))
|
||||
|
||||
mux.Handle("GET /v1/music/", ServeCatalog(app))
|
||||
mux.Handle("GET /v1/music", ServeCatalog(app))
|
||||
mux.Handle("POST /v1/music/", requireAccount(CreateRelease(app)))
|
||||
mux.Handle("POST /v1/music", requireAccount(CreateRelease(app)))
|
||||
|
||||
// TRACK ENDPOINTS
|
||||
|
||||
mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var trackID = strings.Split(r.URL.Path[1:], "/")[0]
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
mux.Handle("GET /v1/track/{id}", requireAccount(ServeTrack(app)))
|
||||
mux.Handle("PUT /v1/track/{id}", requireAccount(UpdateTrack(app)))
|
||||
mux.Handle("DELETE /v1/track/{id}", requireAccount(DeleteTrack(app)))
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/track/{id} (admin)
|
||||
requireAccount(ServeTrack(app, track)).ServeHTTP(w, r)
|
||||
case http.MethodPut:
|
||||
// PUT /api/v1/track/{id} (admin)
|
||||
requireAccount(UpdateTrack(app, track)).ServeHTTP(w, r)
|
||||
case http.MethodDelete:
|
||||
// DELETE /api/v1/track/{id} (admin)
|
||||
requireAccount(DeleteTrack(app, track)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})))
|
||||
mux.Handle("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// GET /api/v1/track (admin)
|
||||
requireAccount(ServeAllTracks(app)).ServeHTTP(w, r)
|
||||
case http.MethodPost:
|
||||
// POST /api/v1/track (admin)
|
||||
requireAccount(CreateTrack(app)).ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
mux.Handle("GET /v1/track/", requireAccount(ServeAllTracks(app)))
|
||||
mux.Handle("GET /v1/track", requireAccount(ServeAllTracks(app)))
|
||||
mux.Handle("POST /v1/track/", requireAccount(CreateTrack(app)))
|
||||
mux.Handle("POST /v1/track", requireAccount(CreateTrack(app)))
|
||||
|
||||
// BLOG ENDPOINTS
|
||||
|
||||
mux.Handle("GET /v1/blog/{id}", ServeBlog(app))
|
||||
mux.Handle("PUT /v1/blog/{id}", requireAccount(UpdateBlog(app)))
|
||||
mux.Handle("DELETE /v1/blog/{id}", requireAccount(DeleteBlog(app)))
|
||||
|
||||
mux.Handle("GET /v1/blog/", ServeAllBlogs(app))
|
||||
mux.Handle("GET /v1/blog", ServeAllBlogs(app))
|
||||
mux.Handle("POST /v1/blog/", requireAccount(CreateBlog(app)))
|
||||
mux.Handle("POST /v1/blog", requireAccount(CreateBlog(app)))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := getSession(app, r)
|
||||
|
|
|
|||
|
|
@ -35,8 +35,20 @@ func ServeAllArtists(app *model.AppState) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||
func ServeArtist(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var artistID = r.PathValue("id")
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if artist == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type (
|
||||
creditJSON struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -99,7 +111,7 @@ func CreateArtist(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
if artist.ID == "" {
|
||||
http.Error(w, "Artist ID cannot be blank\n", http.StatusBadRequest)
|
||||
http.Error(w, "Artist ID cannot be blank", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if artist.Name == "" { artist.Name = artist.ID }
|
||||
|
|
@ -107,7 +119,7 @@ func CreateArtist(app *model.AppState) http.Handler {
|
|||
err = controller.CreateArtist(app.DB, &artist)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
|
||||
http.Error(w, fmt.Sprintf("Artist %s already exists", artist.ID), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err)
|
||||
|
|
@ -121,11 +133,23 @@ func CreateArtist(app *model.AppState) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||
func UpdateArtist(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&artist)
|
||||
var artistID = r.PathValue("id")
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if artist == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = json.NewDecoder(r.Body).Decode(&artist)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to update artist: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
|
|
@ -158,10 +182,6 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
|||
|
||||
err = controller.UpdateArtist(app.DB, artist)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -170,16 +190,24 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||
func DeleteArtist(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := controller.DeleteArtist(app.DB, artist.ID)
|
||||
var artistID = r.PathValue("id")
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if artist == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteArtist(app.DB, artist.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
|
|
|||
256
api/blog.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ServeAllBlogs(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
onlyVisible := true
|
||||
if session != nil && session.Account != nil {
|
||||
onlyVisible = false
|
||||
}
|
||||
|
||||
posts, err := controller.GetBlogPosts(app.DB, onlyVisible, -1, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type (
|
||||
ShortBlogPost struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Author *model.BlogAuthor `json:"author"`
|
||||
PublishDate time.Time `json:"publish_date"`
|
||||
}
|
||||
)
|
||||
resPosts := []*ShortBlogPost{}
|
||||
|
||||
for _, post := range posts {
|
||||
resPosts = append(resPosts, &ShortBlogPost{
|
||||
ID: post.ID,
|
||||
Title: post.Title,
|
||||
Description: post.Description,
|
||||
Author: &post.Author,
|
||||
PublishDate: post.PublishDate,
|
||||
})
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(resPosts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve blog posts: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func ServeBlog(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
privileged := session != nil && session.Account != nil
|
||||
blogID := r.PathValue("id")
|
||||
|
||||
blog, err := controller.GetBlogPost(app.DB, blogID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post %s: %v\n", blogID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if blog == nil || (!blog.Visible && !privileged) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
blog.Author.ID = blog.Author.DisplayName
|
||||
|
||||
err = json.NewEncoder(w).Encode(blog)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve blog post %s: %v\n", blogID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func CreateBlog(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var blog model.BlogPost
|
||||
err := json.NewDecoder(r.Body).Decode(&blog)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if blog.ID == "" {
|
||||
http.Error(w, "Post ID cannot be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if blog.Title == "" { blog.Title = blog.ID }
|
||||
|
||||
if blog.PublishDate.Equal(time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
blog.PublishDate = time.Date(
|
||||
time.Now().Year(), time.Now().Month(), time.Now().Day(),
|
||||
time.Now().Hour(), time.Now().Minute(), 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
blog.Author.ID = session.Account.ID
|
||||
|
||||
err = controller.CreateBlogPost(app.DB, &blog)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, fmt.Sprintf("Post %s already exists", blog.ID), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to create blog %s: %v\n", blog.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" created by \"%s\".", blog.ID, session.Account.Username)
|
||||
|
||||
blog.Author.ID = session.Account.Username
|
||||
blog.Author.DisplayName = session.Account.Username
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
err = json.NewEncoder(w).Encode(blog)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Blog post %s created, but failed to send JSON response: %v\n", blog.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateBlog(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
blogID := r.PathValue("id")
|
||||
|
||||
blog, err := controller.GetBlogPost(app.DB, blogID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post %s: %v\n", blogID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type (
|
||||
BlueskyRecord struct {
|
||||
ActorID string `json:"actor"`
|
||||
RecordID string `json:"record"`
|
||||
}
|
||||
FediverseStatus struct {
|
||||
AccountID string `json:"account"`
|
||||
StatusID string `json:"status"`
|
||||
}
|
||||
|
||||
UpdatedBlog struct {
|
||||
Title string `json:"title"`
|
||||
PublishDate time.Time `json:"publish_date"`
|
||||
Description string `json:"description"`
|
||||
Markdown string `json:"markdown"`
|
||||
Bluesky BlueskyRecord `json:"bluesky"`
|
||||
Fediverse FediverseStatus `json:"fediverse"`
|
||||
Visible bool `json:"visible"`
|
||||
}
|
||||
)
|
||||
var updatedBlog UpdatedBlog
|
||||
|
||||
err = json.NewDecoder(r.Body).Decode(&updatedBlog)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to update blog %s: %v\n", blog.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
blog.Title = updatedBlog.Title
|
||||
blog.PublishDate = updatedBlog.PublishDate
|
||||
blog.Description = updatedBlog.Description
|
||||
blog.Markdown = updatedBlog.Markdown
|
||||
if len(updatedBlog.Bluesky.ActorID) > 0 && len(updatedBlog.Bluesky.RecordID) > 0 {
|
||||
blog.Bluesky = &model.BlueskyRecord{
|
||||
ActorDID: updatedBlog.Bluesky.ActorID,
|
||||
RecordID: updatedBlog.Bluesky.RecordID,
|
||||
}
|
||||
} else {
|
||||
blog.Bluesky = nil
|
||||
}
|
||||
if len(updatedBlog.Fediverse.AccountID) > 0 && len(updatedBlog.Fediverse.StatusID) > 0 {
|
||||
blog.Fediverse = &model.FediverseActivity{
|
||||
AccountID: updatedBlog.Fediverse.AccountID,
|
||||
StatusID: updatedBlog.Fediverse.StatusID,
|
||||
}
|
||||
} else {
|
||||
blog.Fediverse = nil
|
||||
}
|
||||
blog.Visible = updatedBlog.Visible
|
||||
|
||||
err = controller.UpdateBlogPost(app.DB, blogID, blog)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to update release %s: %v\n", blogID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" updated by \"%s\".", blog.ID, session.Account.Username)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
err = json.NewEncoder(w).Encode(blog)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Blog post %s updated, but failed to send JSON response: %v\n", blog.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteBlog(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
blogID := r.PathValue("id")
|
||||
|
||||
blog, err := controller.GetBlogPost(app.DB, blogID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch blog post %s: %v\n", blogID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if blog == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteBlogPost(app.DB, blogID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to delete blog post %s: %v\n", blogID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" deleted by \"%s\".", blogID, session.Account.Username)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
err = json.NewEncoder(w).Encode(blog)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Blog post %s deleted, but failed to send JSON response: %v\n", blog.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
134
api/release.go
|
|
@ -15,8 +15,20 @@ import (
|
|||
"arimelody-web/model"
|
||||
)
|
||||
|
||||
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||
func ServeRelease(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var releaseID = r.PathValue("id")
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if release == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// only allow authorised users to view hidden releases
|
||||
privileged := false
|
||||
if !release.Visible {
|
||||
|
|
@ -119,7 +131,7 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
|||
w.Header().Add("Content-Type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", "\t")
|
||||
err := encoder.Encode(response)
|
||||
err = encoder.Encode(response)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -200,7 +212,7 @@ func CreateRelease(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
if release.ID == "" {
|
||||
http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest)
|
||||
http.Error(w, "Release ID cannot be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +228,7 @@ func CreateRelease(app *model.AppState) http.Handler {
|
|||
err = controller.CreateRelease(app.DB, &release)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest)
|
||||
http.Error(w, fmt.Sprintf("Release %s already exists", release.ID), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err)
|
||||
|
|
@ -238,35 +250,23 @@ func CreateRelease(app *model.AppState) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||
func UpdateRelease(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
if r.URL.Path == "/" {
|
||||
var releaseID = r.PathValue("id")
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if release == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
segments := strings.Split(r.URL.Path[1:], "/")
|
||||
|
||||
if len(segments) == 2 {
|
||||
switch segments[1] {
|
||||
case "tracks":
|
||||
UpdateReleaseTracks(app, release).ServeHTTP(w, r)
|
||||
case "credits":
|
||||
UpdateReleaseCredits(app, release).ServeHTTP(w, r)
|
||||
case "links":
|
||||
UpdateReleaseLinks(app, release).ServeHTTP(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(segments) > 2 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&release)
|
||||
err = json.NewDecoder(r.Body).Decode(&release)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
|
|
@ -299,10 +299,6 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
|
|||
|
||||
err = controller.UpdateRelease(app.DB, release)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -311,12 +307,24 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
|
||||
func UpdateReleaseTracks(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var releaseID = r.PathValue("id")
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if release == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var trackIDs = []string{}
|
||||
err := json.NewDecoder(r.Body).Decode(&trackIDs)
|
||||
err = json.NewDecoder(r.Body).Decode(&trackIDs)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -324,8 +332,8 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
|
|||
|
||||
err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, "Release cannot have duplicate tracks", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
|
||||
|
|
@ -336,17 +344,29 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
|
||||
func UpdateReleaseCredits(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var releaseID = r.PathValue("id")
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if release == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
type creditJSON struct {
|
||||
Artist string
|
||||
Role string
|
||||
Primary bool
|
||||
}
|
||||
var data []creditJSON
|
||||
err := json.NewDecoder(r.Body).Decode(&data)
|
||||
err = json.NewDecoder(r.Body).Decode(&data)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -366,14 +386,10 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
|
|||
err = controller.UpdateReleaseCredits(app.DB, release.ID, credits)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest)
|
||||
http.Error(w, "Artists may only be credited once", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
||||
fmt.Printf("WARN: Failed to update credits for %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
|
|
@ -381,12 +397,24 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
|
||||
func UpdateReleaseLinks(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var releaseID = r.PathValue("id")
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if release == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var links = []*model.Link{}
|
||||
err := json.NewDecoder(r.Body).Decode(&links)
|
||||
err = json.NewDecoder(r.Body).Decode(&links)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -394,8 +422,8 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
|
|||
|
||||
err = controller.UpdateReleaseLinks(app.DB, release.ID, links)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
http.Error(w, "Release cannot have duplicate link names", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
||||
|
|
@ -406,16 +434,24 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
|
|||
})
|
||||
}
|
||||
|
||||
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||
func DeleteRelease(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := controller.DeleteRelease(app.DB, release.ID)
|
||||
var releaseID = r.PathValue("id")
|
||||
release, err := controller.GetRelease(app.DB, releaseID, true)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if release == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.DeleteRelease(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
|
|
|||
53
api/track.go
|
|
@ -50,8 +50,20 @@ func ServeAllTracks(app *model.AppState) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||
func ServeTrack(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var trackID = r.PathValue("id")
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if track == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
|
||||
|
|
@ -86,7 +98,7 @@ func CreateTrack(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
if track.Title == "" {
|
||||
http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest)
|
||||
http.Error(w, "Track title cannot be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -105,23 +117,30 @@ func CreateTrack(app *model.AppState) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||
func UpdateTrack(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var trackID = r.PathValue("id")
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if track == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&track)
|
||||
err = json.NewDecoder(r.Body).Decode(&track)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if track.Title == "" {
|
||||
http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest)
|
||||
http.Error(w, "Track title cannot be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -144,17 +163,23 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||
func DeleteTrack(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var trackID = r.PathValue("id")
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if track == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
var trackID = r.URL.Path[1:]
|
||||
err := controller.DeleteTrack(app.DB, trackID)
|
||||
err = controller.DeleteTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
|
|
|||
|
|
@ -23,9 +23,7 @@ func GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) {
|
|||
|
||||
err := db.Get(&account, "SELECT * FROM account WHERE id=$1", id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -37,9 +35,7 @@ func GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error)
|
|||
|
||||
err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -51,9 +47,7 @@ func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) {
|
|||
|
||||
err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -67,9 +61,7 @@ func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, erro
|
|||
|
||||
err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
|
@ -13,6 +14,7 @@ func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) {
|
|||
|
||||
err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
|||
197
controller/blog.go
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func GetBlogPost(db *sqlx.DB, id string) (*model.BlogPost, error) {
|
||||
var blog = model.BlogPost{}
|
||||
|
||||
rows, err := db.Query(
|
||||
"SELECT post.id,post.title,post.description,post.visible," +
|
||||
"post.publish_date,post.author,post.markdown," +
|
||||
"post.bluesky_actor,post.bluesky_record," +
|
||||
"post.fediverse_account,post.fediverse_status," +
|
||||
"author.id,author.username,author.avatar_url " +
|
||||
"FROM blogpost AS post " +
|
||||
"JOIN account AS author ON post.author=author.id " +
|
||||
"WHERE post.id=$1",
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
blueskyActor := sql.NullString{}
|
||||
blueskyRecord := sql.NullString{}
|
||||
fediverseAccount := sql.NullString{}
|
||||
fediverseStatus := sql.NullString{}
|
||||
|
||||
if !rows.Next() {
|
||||
return nil, nil
|
||||
}
|
||||
err = rows.Scan(
|
||||
&blog.ID, &blog.Title, &blog.Description, &blog.Visible,
|
||||
&blog.PublishDate, &blog.Author.ID, &blog.Markdown,
|
||||
&blueskyActor, &blueskyRecord,
|
||||
&fediverseAccount, &fediverseStatus,
|
||||
&blog.Author.ID, &blog.Author.DisplayName, &blog.Author.AvatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if blueskyActor.Valid && blueskyRecord.Valid {
|
||||
blog.Bluesky = &model.BlueskyRecord{
|
||||
ActorDID: blueskyActor.String,
|
||||
RecordID: blueskyRecord.String,
|
||||
}
|
||||
}
|
||||
if fediverseAccount.Valid && fediverseStatus.Valid {
|
||||
blog.Fediverse = &model.FediverseActivity{
|
||||
AccountID: fediverseAccount.String,
|
||||
StatusID: fediverseStatus.String,
|
||||
}
|
||||
}
|
||||
|
||||
return &blog, nil
|
||||
}
|
||||
|
||||
func GetBlogPosts(db *sqlx.DB, onlyVisible bool, limit int, offset int) ([]*model.BlogPost, error) {
|
||||
var blogs = []*model.BlogPost{}
|
||||
|
||||
query := "SELECT post.id,post.title,post.publish_date," +
|
||||
"post.description,post.visible," +
|
||||
"author.id,author.username,author.avatar_url " +
|
||||
"FROM blogpost AS post " +
|
||||
"JOIN account AS author ON post.author=author.id"
|
||||
if onlyVisible { query += " WHERE visible=true" }
|
||||
query += " ORDER BY publish_date DESC"
|
||||
|
||||
var rows *sql.Rows
|
||||
|
||||
var err error
|
||||
if limit < 0 {
|
||||
rows, err = db.Query(query)
|
||||
} else {
|
||||
rows, err = db.Query(query + " LIMIT $1 OFFSET $2", limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
blog := model.BlogPost{}
|
||||
err = rows.Scan(
|
||||
&blog.ID,
|
||||
&blog.Title,
|
||||
&blog.PublishDate,
|
||||
&blog.Description,
|
||||
&blog.Visible,
|
||||
&blog.Author.ID,
|
||||
&blog.Author.DisplayName,
|
||||
&blog.Author.AvatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blogs = append(blogs, &blog)
|
||||
}
|
||||
|
||||
return blogs, nil
|
||||
}
|
||||
|
||||
func GetBlogPostCount(db *sqlx.DB, onlyVisible bool) (int, error) {
|
||||
query := "SELECT count(*) FROM blogpost"
|
||||
if onlyVisible {
|
||||
query += " WHERE visible=true"
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.Get(&count, query)
|
||||
|
||||
return count, err
|
||||
}
|
||||
|
||||
func CreateBlogPost(db *sqlx.DB, post *model.BlogPost) error {
|
||||
var blueskyActor *string
|
||||
var blueskyRecord *string
|
||||
if post.Bluesky != nil {
|
||||
blueskyActor = &post.Bluesky.ActorDID
|
||||
blueskyRecord = &post.Bluesky.RecordID
|
||||
}
|
||||
|
||||
var fediverseAccount *string
|
||||
var fediverseStatus *string
|
||||
if post.Fediverse != nil {
|
||||
fediverseAccount = &post.Fediverse.AccountID
|
||||
fediverseStatus = &post.Fediverse.StatusID
|
||||
}
|
||||
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO blogpost (" +
|
||||
"id,title,description,visible," +
|
||||
"publish_date,author,markdown," +
|
||||
"bluesky_actor,bluesky_record," +
|
||||
"fediverse_account,fediverse_status) " +
|
||||
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)",
|
||||
post.ID, post.Title, post.Description, post.Visible,
|
||||
post.PublishDate, post.Author.ID, post.Markdown,
|
||||
blueskyActor, blueskyRecord,
|
||||
fediverseAccount, fediverseStatus,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateBlogPost(db *sqlx.DB, postID string, post *model.BlogPost) error {
|
||||
var blueskyActor string
|
||||
var blueskyRecord string
|
||||
if post.Bluesky != nil {
|
||||
blueskyActor = post.Bluesky.ActorDID
|
||||
blueskyRecord = post.Bluesky.RecordID
|
||||
}
|
||||
|
||||
var fediverseAccount string
|
||||
var fediverseStatus string
|
||||
if post.Fediverse != nil {
|
||||
fediverseAccount = post.Fediverse.AccountID
|
||||
fediverseStatus = post.Fediverse.StatusID
|
||||
}
|
||||
|
||||
_, err := db.Exec(
|
||||
"UPDATE blogpost SET " +
|
||||
"id=$2,title=$3,description=$4,visible=$5," +
|
||||
"publish_date=$6,author=$7,markdown=$8," +
|
||||
"bluesky_actor=$9,bluesky_record=$10," +
|
||||
"fediverse_account=$11,fediverse_status=$12 " +
|
||||
"WHERE id=$1",
|
||||
postID,
|
||||
post.ID, post.Title, post.Description, post.Visible,
|
||||
post.PublishDate, post.Author.ID, post.Markdown,
|
||||
blueskyActor, blueskyRecord,
|
||||
fediverseAccount, fediverseStatus,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteBlogPost(db *sqlx.DB, postID string) error {
|
||||
_, err := db.Exec(
|
||||
"DELETE FROM blogpost "+
|
||||
"WHERE id=$1",
|
||||
postID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
47
controller/bluesky.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const BSKY_API_BASE = "https://public.api.bsky.app"
|
||||
|
||||
func FetchThreadViewPost(app *model.AppState, actorID string, recordID string) (*model.ThreadViewPost, error) {
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", actorID, recordID)
|
||||
|
||||
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", fmt.Sprintf("ari melody [%s]", app.Config.BaseUrl))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("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, fmt.Errorf("Invalid response from server: %v", err)
|
||||
}
|
||||
|
||||
return &data.Thread, nil
|
||||
}
|
||||
|
|
@ -16,9 +16,7 @@ func GetInvite(db *sqlx.DB, code string) (*model.Invite, error) {
|
|||
|
||||
err := db.Get(&invite, "SELECT * FROM invite WHERE code=$1", code)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +30,7 @@ func CreateInvite(db *sqlx.DB, length int, lifetime time.Duration) (*model.Invit
|
|||
}
|
||||
|
||||
code := []byte{}
|
||||
for i := 0; i < length; i++ {
|
||||
for range length {
|
||||
code = append(code, inviteChars[rand.Intn(len(inviteChars) - 1)])
|
||||
}
|
||||
invite.Code = string(code)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"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,6 +50,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
|||
ApplyMigration(db, "003-fail-lock")
|
||||
oldDBVersion = 4
|
||||
|
||||
case 4:
|
||||
ApplyMigration(db, "004-blog")
|
||||
oldDBVersion = 5
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/model"
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
|||
|
||||
err := db.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +113,45 @@ func GetReleaseCount(db *sqlx.DB, onlyVisible bool) (int, error) {
|
|||
return count, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// get credits
|
||||
credits, err := GetReleaseCredits(db, release.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Credits: %s", err)
|
||||
}
|
||||
for _, credit := range credits {
|
||||
release.Credits = append(release.Credits, credit)
|
||||
}
|
||||
|
||||
// get tracks
|
||||
tracks, err := GetReleaseTracks(db, release.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("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, fmt.Errorf("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 {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO musicrelease "+
|
||||
|
|
@ -298,6 +339,7 @@ func GetReleaseCredits(db *sqlx.DB, releaseID string) ([]*model.Credit, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var credits []*model.Credit
|
||||
for rows.Next() {
|
||||
|
|
|
|||
|
|
@ -127,17 +127,39 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM
|
|||
|
||||
|
||||
|
||||
CREATE TABLE arimelody.blogpost (
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
visible BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||
author UUID NOT NULL,
|
||||
markdown TEXT NOT NULL,
|
||||
bluesky_actor TEXT,
|
||||
bluesky_record TEXT,
|
||||
fediverse_account TEXT,
|
||||
fediverse_status TEXT
|
||||
);
|
||||
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_pk PRIMARY KEY (id);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Foreign keys
|
||||
--
|
||||
|
||||
-- Account
|
||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||
|
||||
-- Music
|
||||
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
|
||||
ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES musictrack(id) ON DELETE CASCADE;
|
||||
|
||||
-- Blog
|
||||
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_author_fk FOREIGN KEY (author) REFERENCES account(id) ON DELETE CASCADE;
|
||||
|
|
|
|||
15
controller/schema-migration/004-blog.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
CREATE TABLE arimelody.blogpost (
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
visible BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||
author UUID NOT NULL,
|
||||
markdown TEXT NOT NULL,
|
||||
bluesky_actor TEXT,
|
||||
bluesky_record TEXT,
|
||||
fediverse_account TEXT,
|
||||
fediverse_status TEXT
|
||||
);
|
||||
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_pk PRIMARY KEY (id);
|
||||
ALTER TABLE arimelody.blogpost ADD CONSTRAINT blogpost_author_fk FOREIGN KEY (author) REFERENCES account(id) ON DELETE CASCADE;
|
||||
|
|
@ -121,9 +121,7 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
|
|||
name,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
|
@ -11,9 +12,9 @@ import (
|
|||
func GetTrack(db *sqlx.DB, id string) (*model.Track, error) {
|
||||
var track = model.Track{}
|
||||
|
||||
stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1")
|
||||
err := stmt.Get(&track, id)
|
||||
err := db.Get(&track, "SELECT * FROM musictrack WHERE id=$1", id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") { return nil, nil }
|
||||
return nil, err
|
||||
}
|
||||
return &track, nil
|
||||
|
|
|
|||
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=
|
||||
|
|
|
|||
11
log/log.go
|
|
@ -114,17 +114,6 @@ func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, conten
|
|||
conditions,
|
||||
)
|
||||
|
||||
/*
|
||||
fmt.Printf("%s (", query)
|
||||
for i, param := range params {
|
||||
fmt.Print(param)
|
||||
if i < len(params) - 1 {
|
||||
fmt.Print(", ")
|
||||
}
|
||||
}
|
||||
fmt.Print(")\n")
|
||||
*/
|
||||
|
||||
err := self.DB.Select(&logs, query, params...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
23
main.go
|
|
@ -517,7 +517,15 @@ func main() {
|
|||
go cursor.StartCursor(&app)
|
||||
|
||||
// start the web server!
|
||||
mux := createServeMux(&app)
|
||||
mux := http.NewServeMux()
|
||||
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("/blog/", http.StripPrefix("/blog", view.BlogHandler(&app)))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.ServeFiles(filepath.Join(app.Config.DataDirectory, "uploads"))))
|
||||
mux.Handle("/cursor-ws", cursor.Handler(&app))
|
||||
mux.Handle("/", view.IndexHandler(&app))
|
||||
|
||||
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
|
||||
stdLog.Fatal(
|
||||
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
|
||||
|
|
@ -525,19 +533,6 @@ func main() {
|
|||
))
|
||||
}
|
||||
|
||||
func createServeMux(app *model.AppState) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
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("/cursor-ws", cursor.Handler(app))
|
||||
mux.Handle("/", view.IndexHandler(app))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
var PoweredByStrings = []string{
|
||||
"nerd rage",
|
||||
"estrogen",
|
||||
|
|
|
|||
63
model/blog.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
BlueskyRecord struct {
|
||||
ActorDID string `json:"actor"`
|
||||
RecordID string `json:"record"`
|
||||
}
|
||||
FediverseActivity struct {
|
||||
AccountID string `json:"account"`
|
||||
StatusID string `json:"status"`
|
||||
}
|
||||
|
||||
BlogAuthor struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
BlogPost struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Visible bool `json:"visible"`
|
||||
PublishDate time.Time `json:"publish_date"`
|
||||
Author BlogAuthor `json:"author"`
|
||||
Markdown string `json:"markdown"`
|
||||
Bluesky *BlueskyRecord `json:"bluesky"`
|
||||
Fediverse *FediverseActivity `json:"fediverse"`
|
||||
}
|
||||
)
|
||||
|
||||
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.PublishDate.Month()))
|
||||
}
|
||||
|
||||
func (b *BlogPost) PrintDate() string {
|
||||
return b.PublishDate.Format("02 January 2006, 15:04")
|
||||
}
|
||||
|
||||
func (b *BlogPost) PrintShortDate() string {
|
||||
return b.PublishDate.Format("2 Jan 2006")
|
||||
}
|
||||
|
||||
func (b *BlogPost) TextPublishDate() string {
|
||||
return b.PublishDate.Format("2006-01-02T15: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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -24,8 +24,3 @@ func (track Track) GetDescriptionHTML() template.HTML {
|
|||
func (track Track) GetLyricsHTML() template.HTML {
|
||||
return template.HTML(strings.ReplaceAll(track.Lyrics, "\n", "<br>"))
|
||||
}
|
||||
|
||||
// this function is stupid and i hate that i need it
|
||||
func (track Track) Add(a int, b int) int {
|
||||
return a + b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,13 +31,3 @@ func Test_Track_LyricsHTML(t *testing.T) {
|
|||
t.Errorf(`track lyrics incorrectly formatted (want "%s", got "%s")`, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Track_Add(t *testing.T) {
|
||||
track := Track{}
|
||||
|
||||
want := 4
|
||||
got := track.Add(2, 2)
|
||||
if want != got {
|
||||
t.Errorf(`somehow, we screwed up addition. (want %d, got %d)`, want, got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 |
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();
|
||||
70
public/style/blog.css
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
article.blog-post {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #8882;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff08;
|
||||
transition: background-color .1s;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.blog-post h2:hover,
|
||||
.blog-post p:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.blog-title {
|
||||
margin: 0;
|
||||
}
|
||||
.blog-title a {
|
||||
display: inherit;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.blog-meta {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.blog-author {
|
||||
margin: 0;
|
||||
font-size: .8em;
|
||||
}
|
||||
.blog-author img {
|
||||
width: 1.3em;
|
||||
height: 1.3em;
|
||||
display: inline-block;
|
||||
transform: translate(0, 4px);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.blog-date {
|
||||
margin: 0;
|
||||
font-size: .8em;
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
.blog-description {
|
||||
margin: .5em 0 0 0;
|
||||
display: -webkit-box;
|
||||
font-size: .8em;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#load-more {
|
||||
margin: 0 auto;
|
||||
padding: .5em 2em;
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1500px) {
|
||||
#blog-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
349
public/style/blogpost.css
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
:root {
|
||||
--like: rgb(223, 104, 104);
|
||||
--boost: rgb(162, 223, 73);
|
||||
--bluesky: rgb(16, 131, 254);
|
||||
--mastodon: rgb(86, 58, 204);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(calc(100% - 4rem), 800px);
|
||||
margin: 0 auto 1rem auto;
|
||||
}
|
||||
|
||||
#blog-sidebar {
|
||||
position: fixed;
|
||||
width: 3em;
|
||||
padding: 3em;
|
||||
transform: translate(-9em, -1em);
|
||||
overflow: clip;
|
||||
opacity: .5;
|
||||
transition: opacity .2s;
|
||||
}
|
||||
#blog-sidebar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#blog-sidebar ul {
|
||||
margin: 0;
|
||||
padding: .3em;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--on-background);
|
||||
box-shadow: 4px 4px 4px #0001;
|
||||
}
|
||||
|
||||
#blog-sidebar a {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: block;
|
||||
padding: .2em;
|
||||
border-radius: 2px;
|
||||
text-decoration: none;
|
||||
}
|
||||
#blog-sidebar a:hover {
|
||||
background: #0001;
|
||||
}
|
||||
#blog-sidebar a:active {
|
||||
background: #0002;
|
||||
}
|
||||
|
||||
#blog-sidebar a img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
#blog-sidebar span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
#blog-sidebar hr {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#blog p:hover,
|
||||
.comment p:hover {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
#blog {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
#blog h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
#blog h2::before { content: "## " }
|
||||
#blog h3::before { content: "### " }
|
||||
|
||||
.blog-author {
|
||||
margin: .2em 0;
|
||||
}
|
||||
#blog .blog-author img {
|
||||
width: 1.3em;
|
||||
height: 1.3em;
|
||||
display: inline-block;
|
||||
transform: translate(0, 6px);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.blog-date {
|
||||
margin: .5em 0;
|
||||
font-size: .7em;
|
||||
}
|
||||
.blog-modified-date {
|
||||
font-style: italic;
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
#blog {
|
||||
font-family: 'Lora', serif;
|
||||
}
|
||||
|
||||
#blog header {
|
||||
position: relative;
|
||||
width: auto;
|
||||
font-family: 'Monaspace Argon', monospace;
|
||||
border: none;
|
||||
background: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#blog p {
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
#blog p.no-content {
|
||||
font-style: italic;
|
||||
opacity: .66;
|
||||
}
|
||||
|
||||
#blog sub {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
#blog pre {
|
||||
max-height: 15em;
|
||||
padding: .5em;
|
||||
font-size: .9em;
|
||||
border: 1px solid #8884;
|
||||
border-radius: 2px;
|
||||
overflow: scroll;
|
||||
background: var(--background-alt);
|
||||
}
|
||||
|
||||
#blog p code {
|
||||
padding: .2em .3em;
|
||||
font-size: .9em;
|
||||
border: 1px solid #8884;
|
||||
border-radius: 2px;
|
||||
background: var(--background-alt);
|
||||
}
|
||||
|
||||
#blog blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0 0 0 1em;
|
||||
border-left: .2em solid #8888;
|
||||
}
|
||||
|
||||
#blog img {
|
||||
max-height: 50%;
|
||||
max-width: 100%;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
#blog figure {
|
||||
margin: 1em 0;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
#blog figure.quote {
|
||||
border-left: 4px solid color-mix(in srgb, var(--on-background), transparent 75%);
|
||||
color: color-mix(in srgb, var(--on-background), transparent 25%);
|
||||
}
|
||||
|
||||
#blog i.end-mark {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
margin-top: -.2em;
|
||||
|
||||
display: inline-block;
|
||||
transform: translateY(.2em);
|
||||
|
||||
background: url("/img/aridoodle.png");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* COMMENTS */
|
||||
|
||||
#interactions {
|
||||
margin: 1em 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: .5em;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: .4em .6em;
|
||||
border: 1px solid var(--on-background);
|
||||
border-radius: 2px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#interactions .button {
|
||||
min-width: fit-content;
|
||||
padding: 0 .75em 0 .5em;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: .5em;
|
||||
|
||||
font-family: monospace;
|
||||
font-size: inherit;
|
||||
text-align: center;
|
||||
line-height: 2em;
|
||||
text-wrap: nowrap;
|
||||
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 1px solid var(--on-background);
|
||||
border-radius: 4px;
|
||||
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
#interactions .button:hover {
|
||||
background: #0001;
|
||||
}
|
||||
#interactions .button:active {
|
||||
background: #0002;
|
||||
}
|
||||
|
||||
#interactions img {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.comment-callout {
|
||||
margin: 0 0 0 .5em;
|
||||
}
|
||||
.comment-callout:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.bluesky {
|
||||
color: var(--bluesky);
|
||||
}
|
||||
|
||||
.mastodon {
|
||||
color: var(--mastodon);
|
||||
}
|
||||
|
||||
.comment {
|
||||
font-family: 'Inter', 'Arial', sans-serif;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.comment .comment-hover {
|
||||
padding: 1em;
|
||||
transition: background-color .1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.comment .comment-hover:hover {
|
||||
background-color: #8881;
|
||||
}
|
||||
|
||||
.comment .comment-header a {
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.comment .comment-header a .display-name {
|
||||
overflow: inherit;
|
||||
text-overflow: inherit;
|
||||
}
|
||||
|
||||
.comment .comment-header a .handle {
|
||||
opacity: .5;
|
||||
font-family: monospace;
|
||||
font-size: .9em;
|
||||
overflow: inherit;
|
||||
text-overflow: inherit;
|
||||
}
|
||||
|
||||
.comment .comment-header img.avatar {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.comment .comment-body {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.comment p.comment-text {
|
||||
margin: .5em 0;
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.comment .comment-footer {
|
||||
margin: 0;
|
||||
font-size: .8em;
|
||||
}
|
||||
|
||||
.comment .comment-footer .comment-footer-static {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.comment .comment-replies {
|
||||
margin-left: 1em;
|
||||
border-left: 2px solid #8884;
|
||||
}
|
||||
|
||||
.comment .comment-date {
|
||||
transition: opacity .2s;
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
#blog-sidebar a:hover {
|
||||
background: #fff2;
|
||||
}
|
||||
#blog-sidebar a:active {
|
||||
background: #fff4;
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
:root {
|
||||
--background: #080808;
|
||||
--background-alt: #040404;
|
||||
--on-background: #f0f0f0;
|
||||
|
||||
--primary: #b7fd49;
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--background-alt: #f0f0f0;
|
||||
--on-background: #101010;
|
||||
|
||||
--primary: #6d9e23;
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ header ul li a:hover {
|
|||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid #888;
|
||||
background: var(--background);
|
||||
background-color: var(--background);
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
main {
|
||||
width: min(calc(100% - 4rem), 720px);
|
||||
min-height: calc(100vh - 10.3rem);
|
||||
min-height: calc(100vh - 10.3em);
|
||||
margin: 0 auto 2rem auto;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--background);
|
||||
background-color: var(--background);
|
||||
color: var(--on-background);
|
||||
font-family: "Monaspace Argon", monospace;
|
||||
font-size: 18px;
|
||||
|
|
@ -38,7 +38,7 @@ a:hover {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a.link-button {
|
||||
.link-button {
|
||||
padding: .3em .5em;
|
||||
border: 1px solid var(--links);
|
||||
color: var(--links);
|
||||
|
|
@ -51,7 +51,7 @@ a.link-button {
|
|||
opacity: 0;
|
||||
}
|
||||
|
||||
a.link-button:hover {
|
||||
.link-button:hover {
|
||||
color: #eee;
|
||||
border-color: #eee;
|
||||
background-color: var(--links) !important;
|
||||
|
|
@ -141,8 +141,23 @@ a#backtotop:hover {
|
|||
}
|
||||
}
|
||||
|
||||
.light-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dark-only {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.light-only {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.dark-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a.link-button:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
|
@ -150,7 +165,7 @@ a#backtotop:hover {
|
|||
@keyframes list-item-fadein {
|
||||
from {
|
||||
opacity: 1;
|
||||
background: var(--links);
|
||||
background-color: var(--links);
|
||||
}
|
||||
|
||||
to {
|
||||
|
|
@ -161,6 +176,14 @@ a#backtotop:hover {
|
|||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.light-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dark-only {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
body.crt {
|
||||
text-shadow: 0 0 3em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ header {
|
|||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(25px) saturate(25%) brightness(0.5);
|
||||
-webkit-filter: blur(25px) saturate(25%) brightness(0.5);;
|
||||
-webkit-filter: blur(25px) saturate(25%) brightness(0.5);
|
||||
animation: background-init .5s forwards,background-loop 30s ease-in-out infinite
|
||||
}
|
||||
|
||||
|
|
|
|||
6
public/vendor/highlight/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
es
|
||||
package.json
|
||||
DIGESTS.md
|
||||
highlight.js
|
||||
languages/*.js
|
||||
!languages/*.min.js
|
||||
29
public/vendor/highlight/LICENSE
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2006, Ivan Sagalaev.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
45
public/vendor/highlight/README.md
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Highlight.js CDN Assets
|
||||
|
||||
[](https://packagephobia.now.sh/result?p=highlight.js)
|
||||
|
||||
**This package contains only the CDN build assets of highlight.js.**
|
||||
|
||||
This may be what you want if you'd like to install the pre-built distributable highlight.js client-side assets via NPM. If you're wanting to use highlight.js mainly on the server-side you likely want the [highlight.js][1] package instead.
|
||||
|
||||
To access these files via CDN:<br>
|
||||
https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@latest/build/
|
||||
|
||||
**If you just want a single .js file with the common languages built-in:
|
||||
<https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@latest/build/highlight.min.js>**
|
||||
|
||||
---
|
||||
|
||||
## Highlight.js
|
||||
|
||||
Highlight.js is a syntax highlighter written in JavaScript. It works in
|
||||
the browser as well as on the server. It works with pretty much any
|
||||
markup, doesn’t depend on any framework, and has automatic language
|
||||
detection.
|
||||
|
||||
If you'd like to read the full README:<br>
|
||||
<https://github.com/highlightjs/highlight.js/blob/main/README.md>
|
||||
|
||||
## License
|
||||
|
||||
Highlight.js is released under the BSD License. See [LICENSE][7] file
|
||||
for details.
|
||||
|
||||
## Links
|
||||
|
||||
The official site for the library is at <https://highlightjs.org/>.
|
||||
|
||||
The Github project may be found at: <https://github.com/highlightjs/highlight.js>
|
||||
|
||||
Further in-depth documentation for the API and other topics is at
|
||||
<http://highlightjs.readthedocs.io/>.
|
||||
|
||||
A list of the Core Team and contributors can be found in the [CONTRIBUTORS.md][8] file.
|
||||
|
||||
[1]: https://www.npmjs.com/package/highlight.js
|
||||
[7]: https://github.com/highlightjs/highlight.js/blob/main/LICENSE
|
||||
[8]: https://github.com/highlightjs/highlight.js/blob/main/CONTRIBUTORS.md
|
||||
912
public/vendor/highlight/highlight.min.js
vendored
Normal file
21
public/vendor/highlight/languages/bash.min.js
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/*! `bash` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const s=e.regex,t={},n={begin:/\$\{/,
|
||||
end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]};Object.assign(t,{
|
||||
className:"variable",variants:[{
|
||||
begin:s.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},n]});const a={
|
||||
className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]
|
||||
},i=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),c={
|
||||
begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,
|
||||
end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/,
|
||||
contains:[e.BACKSLASH_ESCAPE,t,a]};a.contains.push(o);const r={begin:/\$?\(\(/,
|
||||
end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t]
|
||||
},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10
|
||||
}),m={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,
|
||||
contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{
|
||||
name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/,
|
||||
keyword:["if","then","else","elif","fi","time","for","while","until","in","do","done","case","esac","coproc","function","select"],
|
||||
literal:["true","false"],
|
||||
built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"]
|
||||
},contains:[l,e.SHEBANG(),m,r,i,c,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{
|
||||
className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}})()
|
||||
;hljs.registerLanguage("bash",e)})();
|
||||
41
public/vendor/highlight/languages/c.min.js
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/*! `c` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const t=e.regex,n=e.COMMENT("//","$",{
|
||||
contains:[{begin:/\\\n/}]
|
||||
}),a="decltype\\(auto\\)",s="[a-zA-Z_]\\w*::",r="("+a+"|"+t.optional(s)+"[a-zA-Z_]\\w*"+t.optional("<[^<>]+>")+")",i={
|
||||
className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{
|
||||
match:/\batomic_[a-z]{3,6}\b/}]},l={className:"string",variants:[{
|
||||
begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{
|
||||
begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
|
||||
end:"'",illegal:"."},e.END_SAME_AS_BEGIN({
|
||||
begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
|
||||
className:"number",variants:[{match:/\b(0b[01']+)/},{
|
||||
match:/(-?)\b([\d']+(\.[\d']*)?|\.[\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)/
|
||||
},{
|
||||
match:/(-?)\b(0[xX][a-fA-F0-9]+(?:'[a-fA-F0-9]+)*(?:\.[a-fA-F0-9]*(?:'[a-fA-F0-9]*)*)?(?:[pP][-+]?[0-9]+)?(l|L)?(u|U)?)/
|
||||
},{match:/(-?)\b\d+(?:'\d+)*(?:\.\d*(?:'\d*)*)?(?:[eE][-+]?\d+)?/}],relevance:0
|
||||
},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
|
||||
keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef elifdef elifndef include"
|
||||
},contains:[{begin:/\\\n/,relevance:0},e.inherit(l,{className:"string"}),{
|
||||
className:"string",begin:/<.*?>/},n,e.C_BLOCK_COMMENT_MODE]},d={
|
||||
className:"title",begin:t.optional(s)+e.IDENT_RE,relevance:0
|
||||
},_=t.optional(s)+e.IDENT_RE+"\\s*\\(",u={
|
||||
keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","typeof","typeof_unqual","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"],
|
||||
type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_BitInt","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal96","_Decimal128","_Decimal64x","_Decimal128x","_Float16","_Float32","_Float64","_Float128","_Float32x","_Float64x","_Float128x","const","static","constexpr","complex","bool","imaginary"],
|
||||
literal:"true false NULL",
|
||||
built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr"
|
||||
},m=[c,i,n,e.C_BLOCK_COMMENT_MODE,o,l],g={variants:[{begin:/=/,end:/;/},{
|
||||
begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}],
|
||||
keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u,
|
||||
contains:m.concat(["self"]),relevance:0}]),relevance:0},p={
|
||||
begin:"("+r+"[\\*&\\s]+)+"+_,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,
|
||||
keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{
|
||||
begin:_,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})],
|
||||
relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/,
|
||||
keywords:u,relevance:0,contains:[n,e.C_BLOCK_COMMENT_MODE,l,o,i,{begin:/\(/,
|
||||
end:/\)/,keywords:u,relevance:0,contains:["self",n,e.C_BLOCK_COMMENT_MODE,l,o,i]
|
||||
}]},i,n,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u,
|
||||
disableAutodetect:!0,illegal:"</",contains:[].concat(g,p,m,[c,{
|
||||
begin:e.IDENT_RE+"::",keywords:u},{className:"class",
|
||||
beginKeywords:"enum class struct union",end:/[{;:<>=]/,contains:[{
|
||||
beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c,
|
||||
strings:l,keywords:u}}}})();hljs.registerLanguage("c",e)})();
|
||||
47
public/vendor/highlight/languages/cpp.min.js
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/*! `cpp` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const t=e.regex,a=e.COMMENT("//","$",{
|
||||
contains:[{begin:/\\\n/}]
|
||||
}),n="decltype\\(auto\\)",r="[a-zA-Z_]\\w*::",i="(?!struct)("+n+"|"+t.optional(r)+"[a-zA-Z_]\\w*"+t.optional("<[^<>]+>")+")",s={
|
||||
className:"type",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",variants:[{
|
||||
begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{
|
||||
begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
|
||||
end:"'",illegal:"."},e.END_SAME_AS_BEGIN({
|
||||
begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
|
||||
className:"number",variants:[{
|
||||
begin:"[+-]?(?:(?:[0-9](?:'?[0-9])*\\.(?:[0-9](?:'?[0-9])*)?|\\.[0-9](?:'?[0-9])*)(?:[Ee][+-]?[0-9](?:'?[0-9])*)?|[0-9](?:'?[0-9])*[Ee][+-]?[0-9](?:'?[0-9])*|0[Xx](?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*(?:\\.(?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)?)?|\\.[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)[Pp][+-]?[0-9](?:'?[0-9])*)(?:[Ff](?:16|32|64|128)?|(BF|bf)16|[Ll]|)"
|
||||
},{
|
||||
begin:"[+-]?\\b(?:0[Bb][01](?:'?[01])*|0[Xx][0-9A-Fa-f](?:'?[0-9A-Fa-f])*|0(?:'?[0-7])*|[1-9](?:'?[0-9])*)(?:[Uu](?:LL?|ll?)|[Uu][Zz]?|(?:LL?|ll?)[Uu]?|[Zz][Uu]|)"
|
||||
}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
|
||||
keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
|
||||
},contains:[{begin:/\\\n/,relevance:0},e.inherit(c,{className:"string"}),{
|
||||
className:"string",begin:/<.*?>/},a,e.C_BLOCK_COMMENT_MODE]},u={
|
||||
className:"title",begin:t.optional(r)+e.IDENT_RE,relevance:0
|
||||
},d=t.optional(r)+e.IDENT_RE+"\\s*\\(",p={
|
||||
type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"],
|
||||
keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"],
|
||||
literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"],
|
||||
_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","flat_map","flat_set","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"]
|
||||
},_={className:"function.dispatch",relevance:0,keywords:{
|
||||
_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"]
|
||||
},
|
||||
begin:t.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,t.lookahead(/(<[^<>]+>|)\s*\(/))
|
||||
},m=[_,l,s,a,e.C_BLOCK_COMMENT_MODE,o,c],f={variants:[{begin:/=/,end:/;/},{
|
||||
begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}],
|
||||
keywords:p,contains:m.concat([{begin:/\(/,end:/\)/,keywords:p,
|
||||
contains:m.concat(["self"]),relevance:0}]),relevance:0},g={className:"function",
|
||||
begin:"("+i+"[\\*&\\s]+)+"+d,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,
|
||||
keywords:p,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:n,keywords:p,relevance:0},{
|
||||
begin:d,returnBegin:!0,contains:[u],relevance:0},{begin:/::/,relevance:0},{
|
||||
begin:/:/,endsWithParent:!0,contains:[c,o]},{relevance:0,match:/,/},{
|
||||
className:"params",begin:/\(/,end:/\)/,keywords:p,relevance:0,
|
||||
contains:[a,e.C_BLOCK_COMMENT_MODE,c,o,s,{begin:/\(/,end:/\)/,keywords:p,
|
||||
relevance:0,contains:["self",a,e.C_BLOCK_COMMENT_MODE,c,o,s]}]
|
||||
},s,a,e.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
|
||||
aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:p,illegal:"</",
|
||||
classNameAliases:{"function.dispatch":"built_in"},
|
||||
contains:[].concat(f,g,_,m,[l,{
|
||||
begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array|tuple|optional|variant|function|flat_map|flat_set)\\s*<(?!<)",
|
||||
end:">",keywords:p,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:p},{
|
||||
match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/],
|
||||
className:{1:"keyword",3:"title.class"}}])}}})();hljs.registerLanguage("cpp",e)
|
||||
})();
|
||||
31
public/vendor/highlight/languages/css.min.js
vendored
Normal file
20
public/vendor/highlight/languages/go.min.js
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/*! `go` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const a={
|
||||
keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"],
|
||||
type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"],
|
||||
literal:["true","false","iota","nil"],
|
||||
built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"]
|
||||
};return{name:"Go",aliases:["golang"],keywords:a,illegal:"</",
|
||||
contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
|
||||
variants:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{begin:"`",end:"`"}]},{
|
||||
className:"number",variants:[{
|
||||
match:/-?\b0[xX]\.[a-fA-F0-9](_?[a-fA-F0-9])*[pP][+-]?\d(_?\d)*i?/,relevance:0
|
||||
},{
|
||||
match:/-?\b0[xX](_?[a-fA-F0-9])+((\.([a-fA-F0-9](_?[a-fA-F0-9])*)?)?[pP][+-]?\d(_?\d)*)?i?/,
|
||||
relevance:0},{match:/-?\b0[oO](_?[0-7])*i?/,relevance:0},{
|
||||
match:/-?\.\d(_?\d)*([eE][+-]?\d(_?\d)*)?i?/,relevance:0},{
|
||||
match:/-?\b\d(_?\d)*(\.(\d(_?\d)*)?)?([eE][+-]?\d(_?\d)*)?i?/,relevance:0}]},{
|
||||
begin:/:=/},{className:"function",beginKeywords:"func",end:"\\s*(\\{|$)",
|
||||
excludeEnd:!0,contains:[e.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,
|
||||
endsParent:!0,keywords:a,illegal:/["']/}]}]}}})();hljs.registerLanguage("go",e)
|
||||
})();
|
||||