Compare commits
24 commits
main
...
feature/bl
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,12 +16,14 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func accountHandler(app *model.AppState) http.Handler {
|
||||
func Handler(app *model.AppState) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/", accountIndexHandler(app))
|
||||
|
||||
mux.Handle("/account/totp-setup", totpSetupHandler(app))
|
||||
mux.Handle("/account/totp-confirm", totpConfirmHandler(app))
|
||||
mux.Handle("/account/totp-delete", totpDeleteHandler(app))
|
||||
mux.Handle("/account/totp-delete", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
|
||||
|
||||
mux.Handle("/account/password", changePasswordHandler(app))
|
||||
mux.Handle("/account/delete", deleteAccountHandler(app))
|
||||
|
|
@ -45,7 +48,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
accountResponse struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
TOTPs []TOTP
|
||||
}
|
||||
)
|
||||
|
|
@ -66,7 +69,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 +96,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 +106,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 +115,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 +123,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 +151,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 +159,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 +173,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
type totpConfirmData struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
TOTP *model.TOTP
|
||||
NameEscaped string
|
||||
QRBase64Image string
|
||||
|
|
@ -181,7 +184,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 +222,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 +238,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 +274,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 +294,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 +310,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 +318,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 +346,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 +358,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 +366,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
|
||||
}
|
||||
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
|
||||
}
|
||||
464
admin/http.go
|
|
@ -1,59 +1,32 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arimelody-web/admin/account"
|
||||
"arimelody-web/admin/auth"
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/logs"
|
||||
"arimelody-web/admin/music"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/log"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/view"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type adminPageData struct {
|
||||
Path string
|
||||
Session *model.Session
|
||||
}
|
||||
|
||||
func Handler(app *model.AppState) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
mux.Handle("/register", auth.RegisterAccountHandler(app))
|
||||
mux.Handle("/login", auth.LoginHandler(app))
|
||||
mux.Handle("/totp", auth.LoginTOTPHandler(app))
|
||||
mux.Handle("/logout", core.RequireAccount(auth.LogoutHandler(app)))
|
||||
|
||||
w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
|
||||
}))
|
||||
|
||||
mux.Handle("/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("/music/", core.RequireAccount(http.StripPrefix("/music", music.Handler(app))))
|
||||
mux.Handle("/logs", core.RequireAccount(logs.Handler(app)))
|
||||
mux.Handle("/account/", core.RequireAccount(http.StripPrefix("/account", account.Handler(app))))
|
||||
|
||||
mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/static/admin.css" {
|
||||
|
|
@ -64,15 +37,15 @@ 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 {
|
||||
|
|
@ -124,7 +97,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
type IndexData struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
Releases []*model.Release
|
||||
ReleaseCount int
|
||||
Artists []*model.Artist
|
||||
|
|
@ -134,7 +107,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
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,
|
||||
|
|
@ -150,349 +123,6 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func registerAccountHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
if session.Account != nil {
|
||||
// user is already logged in
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
render := func() {
|
||||
err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Invite string `json:"invite"`
|
||||
}
|
||||
credentials := RegisterRequest{
|
||||
Username: r.Form.Get("username"),
|
||||
Email: r.Form.Get("email"),
|
||||
Password: r.Form.Get("password"),
|
||||
Invite: r.Form.Get("invite"),
|
||||
}
|
||||
|
||||
// make sure invite code exists in DB
|
||||
invite, err := controller.GetInvite(app.DB, credentials.Invite)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
if invite == nil || time.Now().After(invite.ExpiresAt) {
|
||||
if invite != nil {
|
||||
err := controller.DeleteInvite(app.DB, invite.Code)
|
||||
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||
}
|
||||
controller.SetSessionError(app.DB, session, "Invalid invite code.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
account := model.Account{
|
||||
Username: credentials.Username,
|
||||
Password: string(hashedPassword),
|
||||
Email: sql.NullString{ String: credentials.Email, Valid: true },
|
||||
AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
|
||||
}
|
||||
err = controller.CreateAccount(app.DB, &account)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
|
||||
controller.SetSessionError(app.DB, session, "An account with that username already exists.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
|
||||
|
||||
err = controller.DeleteInvite(app.DB, invite.Code)
|
||||
if err != nil {
|
||||
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
|
||||
}
|
||||
|
||||
// registration success!
|
||||
controller.SetSessionAccount(app.DB, session, &account)
|
||||
controller.SetSessionMessage(app.DB, session, "")
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func loginHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
render := func() {
|
||||
err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
if session.Account != nil {
|
||||
// user is already logged in
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
return
|
||||
}
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !r.Form.Has("username") || !r.Form.Has("password") {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
if account.Locked {
|
||||
controller.SetSessionError(app.DB, session, "This account is locked.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
|
||||
if err != nil {
|
||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
|
||||
if locked := handleFailedLogin(app, account, r); locked {
|
||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
||||
} else {
|
||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||
}
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
if len(totps) > 0 {
|
||||
err = controller.SetSessionAttemptAccount(app.DB, session, account)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
controller.SetSessionMessage(app.DB, session, "")
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
http.Redirect(w, r, "/admin/totp", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// login success!
|
||||
// TODO: log login activity to user
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
|
||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
|
||||
|
||||
err = controller.SetSessionAccount(app.DB, session, account)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
controller.SetSessionMessage(app.DB, session, "")
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func loginTOTPHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
|
||||
if session.AttemptAccount == nil {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
render := func() {
|
||||
err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session })
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
if !r.Form.Has("totp") {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
totpCode := r.FormValue("totp")
|
||||
|
||||
if len(totpCode) != controller.TOTP_CODE_LENGTH {
|
||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
||||
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
if totpMethod == nil {
|
||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
||||
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
|
||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
||||
controller.SetSessionAttemptAccount(app.DB, session, nil)
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
} else {
|
||||
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
|
||||
}
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
|
||||
|
||||
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
|
||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
|
||||
}
|
||||
controller.SetSessionMessage(app.DB, session, "")
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
func logoutHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
err := controller.DeleteSession(app.DB, session.Token)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: model.COOKIE_TOKEN,
|
||||
Expires: time.Now(),
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
err = templates.LogoutTemplate.Execute(w, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func requireAccount(next http.Handler) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*model.Session)
|
||||
if session.Account == nil {
|
||||
// TODO: include context in redirect
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
//go:embed "static"
|
||||
var staticFS embed.FS
|
||||
|
|
@ -513,63 +143,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,12 @@
|
|||
package admin
|
||||
package music
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
|
|
@ -24,22 +26,22 @@ func serveArtists(app *model.AppState) http.Handler {
|
|||
|
||||
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: %s\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: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -55,31 +57,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: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
credits, err := controller.GetArtistCredits(app.DB, artist.ID, true)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artist page for %s: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type ArtistResponse struct {
|
||||
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: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
18
admin/music/musichttp.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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.Handle("/releases/", serveReleases(app))
|
||||
mux.Handle("/artists/", serveArtists(app))
|
||||
mux.Handle("/tracks/", serveTracks(app))
|
||||
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package admin
|
||||
package music
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
|
|
@ -29,7 +30,7 @@ func serveReleases(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
type ReleasesData struct {
|
||||
adminPageData
|
||||
core.AdminPageData
|
||||
Releases []*model.Release
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ func serveReleases(app *model.AppState) http.Handler {
|
|||
}
|
||||
|
||||
err = templates.ReleasesTemplate.Execute(w, ReleasesData{
|
||||
adminPageData: adminPageData{
|
||||
AdminPageData: core.AdminPageData{
|
||||
Path: r.URL.Path,
|
||||
Session: session,
|
||||
},
|
||||
|
|
@ -65,7 +66,7 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -99,20 +100,18 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han
|
|||
}
|
||||
|
||||
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: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -123,7 +122,7 @@ func serveEditCredits(release *model.Release) http.Handler {
|
|||
w.Header().Set("Content-Type", "text/html")
|
||||
err := templates.EditCreditsTemplate.Execute(w, release)
|
||||
if err != nil {
|
||||
fmt.Printf("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: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -133,7 +132,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -149,7 +148,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: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -160,7 +159,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
|
|||
artistID := strings.Split(r.URL.Path, "/")[3]
|
||||
artist, err := controller.GetArtist(app.DB, artistID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %s\n", artistID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -172,7 +171,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
|
|||
w.Header().Set("Content-Type", "text/html")
|
||||
err = templates.NewCreditTemplate.Execute(w, artist)
|
||||
if err != nil {
|
||||
fmt.Printf("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: %s\n", artist.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -183,7 +182,7 @@ func serveEditLinks(release *model.Release) http.Handler {
|
|||
w.Header().Set("Content-Type", "text/html")
|
||||
err := templates.EditLinksTemplate.Execute(w, release)
|
||||
if err != nil {
|
||||
fmt.Printf("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: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -195,9 +194,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: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -207,7 +208,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -223,7 +224,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: %s\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -234,7 +235,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
|
|||
trackID := strings.Split(r.URL.Path, "/")[3]
|
||||
track, err := controller.GetTrack(app.DB, trackID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -246,7 +247,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: %s\n", track.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
package admin
|
||||
package music
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/admin/core"
|
||||
"arimelody-web/admin/templates"
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
|
|
@ -24,22 +26,22 @@ func serveTracks(app *model.AppState) http.Handler {
|
|||
|
||||
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: %s\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: %s\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -51,7 +53,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 serve admin track page for %s: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -62,24 +64,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: %s\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: %s\n", trackID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
|
@ -270,6 +270,10 @@ code {
|
|||
border-radius: 4px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 .5em 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.cards {
|
||||
|
|
|
|||
1
admin/static/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import url("/admin/static/release-list-item.css");
|
||||
82
admin/static/music.css
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
@import url("/admin/static/release-list-item.css");
|
||||
|
||||
.artist {
|
||||
margin-bottom: .5em;
|
||||
padding: .5em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: .5em;
|
||||
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
||||
.artist:hover {
|
||||
text-decoration: hover;
|
||||
}
|
||||
|
||||
.artist-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.track {
|
||||
margin-bottom: 1em;
|
||||
padding: 1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5em;
|
||||
|
||||
border-radius: 8px;
|
||||
background: #f8f8f8f8;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
||||
.track p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card h2.track-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.track-id {
|
||||
width: fit-content;
|
||||
font-family: "Monaspace Argon", monospace;
|
||||
font-size: .8em;
|
||||
font-style: italic;
|
||||
line-height: 1em;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.track-album {
|
||||
margin-left: auto;
|
||||
font-style: italic;
|
||||
font-size: .75em;
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.track-album.empty {
|
||||
color: #ff2020;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-description {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.track-lyrics {
|
||||
max-height: 10em;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.track .empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
74
admin/static/music.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
const newReleaseBtn = document.getElementById("create-release");
|
||||
const newArtistBtn = document.getElementById("create-artist");
|
||||
const newTrackBtn = document.getElementById("create-track");
|
||||
|
||||
newReleaseBtn.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
const id = prompt("Enter an ID for this release:");
|
||||
if (id == null || id == "") return;
|
||||
|
||||
fetch("/api/v1/music", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({id})
|
||||
}).then(res => {
|
||||
if (res.ok) location = "/admin/music/releases/" + id;
|
||||
else {
|
||||
res.text().then(err => {
|
||||
alert("Request failed: " + err);
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
alert("Failed to create release. Check the console for details.");
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
newArtistBtn.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
const id = prompt("Enter an ID for this artist:");
|
||||
if (id == null || id == "") return;
|
||||
|
||||
fetch("/api/v1/artist", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({id})
|
||||
}).then(res => {
|
||||
res.text().then(text => {
|
||||
if (res.ok) {
|
||||
location = "/admin/music/artists/" + id;
|
||||
} else {
|
||||
alert("Request failed: " + text);
|
||||
console.error(text);
|
||||
}
|
||||
})
|
||||
}).catch(err => {
|
||||
alert("Failed to create artist. Check the console for details.");
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
newTrackBtn.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
const title = prompt("Enter an title for this track:");
|
||||
if (title == null || title == "") return;
|
||||
|
||||
fetch("/api/v1/track", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({title})
|
||||
}).then(res => {
|
||||
res.text().then(text => {
|
||||
if (res.ok) {
|
||||
location = "/admin/music/tracks/" + text;
|
||||
} else {
|
||||
alert("Request failed: " + text);
|
||||
console.error(text);
|
||||
}
|
||||
})
|
||||
}).catch(err => {
|
||||
alert("Failed to create track. Check the console for details.");
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
(<a href="/admin/music/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}}">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>
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -129,16 +130,17 @@
|
|||
{{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>
|
||||
|
||||
<ul>
|
||||
{{range .Release.Links}}
|
||||
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
|
||||
|
|
@ -146,17 +148,18 @@
|
|||
</ul>
|
||||
</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}}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@
|
|||
<hr>
|
||||
<p class="section-label">music</p>
|
||||
<div class="nav-item{{if hasPrefix .Path "/releases"}} active{{end}}">
|
||||
<a href="/admin/releases/">releases</a>
|
||||
<a href="/admin/music/releases/">releases</a>
|
||||
</div>
|
||||
<div class="nav-item{{if hasPrefix .Path "/artists"}} active{{end}}">
|
||||
<a href="/admin/artists/">artists</a>
|
||||
<a href="/admin/music/artists/">artists</a>
|
||||
</div>
|
||||
<div class="nav-item{{if hasPrefix .Path "/tracks"}} active{{end}}">
|
||||
<a href="/admin/tracks/">tracks</a>
|
||||
<a href="/admin/music/tracks/">tracks</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,21 +13,7 @@
|
|||
|
||||
<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>
|
||||
{{block "track" $Track}}{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
26
admin/views/index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{{define "head"}}
|
||||
<title>Admin - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/admin/static/index.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1>Admin Dashboard</h1>
|
||||
<div class="card-header">
|
||||
<h2>Music</h2>
|
||||
<a class="button" href="/admin/music/">Browse All</a>
|
||||
</div>
|
||||
<div class="card" id="music">
|
||||
{{if .LatestRelease}}
|
||||
<h3>Latest Release</h3>
|
||||
{{block "release" .LatestRelease}}{{end}}
|
||||
{{else}}
|
||||
<p>There are no releases.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/admin/static/admin.js"></script>
|
||||
<script type="module" src="/admin/static/index.js"></script>
|
||||
{{end}}
|
||||
59
admin/views/layout.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
{{block "head" .}}{{end}}
|
||||
|
||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||
<script type="module" src="/script/vendor/htmx.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="nav-item icon" title="return to user space">
|
||||
<a href="/"><img src="/img/favicon.png" alt=""/></a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin">home</a>
|
||||
</div>
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/music/">music</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin/blog">blog</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin/logs">logs</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="flex-fill"></div>
|
||||
|
||||
{{if .Session.Account}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/account/">account ({{.Session.Account.Username}})</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/admin/logout" id="logout">log out</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="nav-item">
|
||||
<a href="/admin/register" id="register">create account</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{{block "content" .}}{{end}}
|
||||
|
||||
{{template "prideflag"}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
83
controller/blog.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"arimelody-web/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func GetBlogPost(db *sqlx.DB, id string) (*model.BlogPost, error) {
|
||||
var blog = model.BlogPost{}
|
||||
|
||||
err := db.Get(&blog, "SELECT * FROM blogpost WHERE id=$1", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &blog, nil
|
||||
}
|
||||
|
||||
func GetBlogPosts(db *sqlx.DB, onlyVisible bool, limit int, offset int) ([]*model.BlogPost, error) {
|
||||
var blogs = []*model.BlogPost{}
|
||||
|
||||
query := "SELECT * FROM blogpost ORDER BY created_at"
|
||||
if onlyVisible {
|
||||
query = "SELECT * FROM blogpost WHERE visible=true ORDER BY created_at"
|
||||
}
|
||||
|
||||
var err error
|
||||
if limit < 0 {
|
||||
err = db.Select(&blogs, query)
|
||||
} else {
|
||||
err = db.Select(&blogs, query + " LIMIT $1 OFFSET $2", limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// for range 4 {
|
||||
// blog := *blogs[len(blogs)-1]
|
||||
// blog.CreatedAt = blog.CreatedAt.Add(time.Hour * -5000)
|
||||
// blogs = append(blogs, &blog)
|
||||
// }
|
||||
|
||||
return blogs, nil
|
||||
}
|
||||
|
||||
func CreateBlogPost(db *sqlx.DB, post *model.BlogPost) error {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO blogpost (id,title,description,visible,author,markdown,html,bluesky_actor,bluesky_post) " +
|
||||
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)",
|
||||
post.ID,
|
||||
post.Title,
|
||||
post.Description,
|
||||
post.Visible,
|
||||
post.AuthorID,
|
||||
post.Markdown,
|
||||
post.HTML,
|
||||
post.BlueskyActorID,
|
||||
post.BlueskyPostID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateBlogPost(db *sqlx.DB, postID string, post *model.BlogPost) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE blogpost SET " +
|
||||
"id=$2,title=$3,description=$4,visible=$5,author=$6,markdown=$7,html=$8,bluesky_actor=$9,bluesky_post=$10,modified_at=CURRENT_TIMESTAMP " +
|
||||
"WHERE id=$1",
|
||||
postID,
|
||||
post.ID,
|
||||
post.Title,
|
||||
post.Description,
|
||||
post.Visible,
|
||||
post.AuthorID,
|
||||
post.Markdown,
|
||||
post.HTML,
|
||||
post.BlueskyActorID,
|
||||
post.BlueskyPostID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
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(actorID string, postID string) (*model.ThreadViewPost, error) {
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", actorID, postID)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
strings.Join([]string{BSKY_API_BASE, "xrpc", "app.bsky.feed.getPostThread"}, "/"),
|
||||
nil,
|
||||
)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
req.URL.RawQuery = url.Values{
|
||||
"uri": { uri },
|
||||
}.Encode()
|
||||
req.Header.Set("User-Agent", "ari melody [https://arimelody.me]")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
@ -111,6 +112,47 @@ 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 "+
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
modified_at TIMESTAMP,
|
||||
author UUID NOT NULL,
|
||||
markdown TEXT NOT NULL,
|
||||
html TEXT NOT NULL,
|
||||
bluesky_actor TEXT,
|
||||
bluesky_post 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,
|
||||
modified_at TIMESTAMP,
|
||||
author UUID NOT NULL,
|
||||
markdown TEXT NOT NULL,
|
||||
html TEXT NOT NULL,
|
||||
bluesky_actor TEXT,
|
||||
bluesky_post 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;
|
||||
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=
|
||||
|
|
|
|||
1
main.go
|
|
@ -531,6 +531,7 @@ func createServeMux(app *model.AppState) *http.ServeMux {
|
|||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
|
||||
mux.Handle("/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))
|
||||
|
|
|
|||
51
model/blog.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
BlogPost struct {
|
||||
ID string `db:"id"`
|
||||
Title string `db:"title"`
|
||||
Description string `db:"description"`
|
||||
Visible bool `db:"visible"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ModifiedAt sql.NullTime `db:"modified_at"`
|
||||
AuthorID string `db:"author"`
|
||||
Markdown string `db:"markdown"`
|
||||
HTML template.HTML `db:"html"`
|
||||
BlueskyActorID *string `db:"bluesky_actor"`
|
||||
BlueskyPostID *string `db:"bluesky_post"`
|
||||
}
|
||||
)
|
||||
|
||||
func (b *BlogPost) TitleNormalised() string {
|
||||
rgx := regexp.MustCompile(`[^a-z0-9\-]`)
|
||||
return rgx.ReplaceAllString(
|
||||
strings.ReplaceAll(
|
||||
strings.ToLower(b.Title), " ", "-",
|
||||
),
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
func (b *BlogPost) GetMonth() string {
|
||||
return fmt.Sprintf("%02d", int(b.CreatedAt.Month()))
|
||||
}
|
||||
|
||||
func (b *BlogPost) PrintDate() string {
|
||||
return b.CreatedAt.Format("2 January 2006, 03:04")
|
||||
}
|
||||
|
||||
func (b *BlogPost) PrintModifiedDate() string {
|
||||
if !b.ModifiedAt.Valid {
|
||||
return ""
|
||||
}
|
||||
return b.ModifiedAt.Time.Format("2 January 2006, 03:04")
|
||||
}
|
||||
82
model/bluesky.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
Record struct {
|
||||
Type string `json:"$type"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
Profile struct {
|
||||
DID string `json:"did"`
|
||||
Handle string `json:"handle"`
|
||||
Avatar string `json:"avatar"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
PostImage struct {
|
||||
Thumbnail string `json:"thumb"`
|
||||
Fullsize string `json:"fullsize"`
|
||||
Alt string `json:"alt"`
|
||||
}
|
||||
|
||||
EmbedMedia struct {
|
||||
Images []PostImage `json:"images"`
|
||||
}
|
||||
|
||||
Embed struct {
|
||||
Media EmbedMedia `json:"media"`
|
||||
}
|
||||
|
||||
Post struct {
|
||||
Author Profile `json:"author"`
|
||||
Record Record `json:"record"`
|
||||
ReplyCount int `json:"replyCount"`
|
||||
RepostCount int `json:"repostCount"`
|
||||
LikeCount int `json:"likeCount"`
|
||||
QuoteCount int `json:"quoteCount"`
|
||||
Embed *Embed `json:"embed"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
ThreadViewPost struct {
|
||||
Post Post `json:"post"`
|
||||
Replies []*ThreadViewPost `json:"replies"`
|
||||
}
|
||||
)
|
||||
|
||||
func (record *Record) CreatedAtPrint() (string, error) {
|
||||
t, err := record.CreatedAtTime()
|
||||
if err != nil { return "", err }
|
||||
return t.Format("2 Jan 2006, 15:04"), nil
|
||||
}
|
||||
|
||||
func (record *Record) CreatedAtTime() (time.Time, error) {
|
||||
return time.Parse("2006-01-02T15:04:05Z", record.CreatedAt)
|
||||
}
|
||||
|
||||
func (post *Post) HasImage() bool {
|
||||
return post.Embed != nil && len(post.Embed.Media.Images) > 0
|
||||
}
|
||||
|
||||
func (post *Post) PostID() string {
|
||||
return strings.TrimPrefix(
|
||||
post.URI,
|
||||
fmt.Sprintf("at://%s/app.bsky.feed.post/", post.Author.DID),
|
||||
)
|
||||
}
|
||||
|
||||
func (post *Post) BskyURL() string {
|
||||
return fmt.Sprintf(
|
||||
"https://bsky.app/profile/%s/post/%s",
|
||||
post.Author.DID,
|
||||
post.PostID(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
332
public/style/blogpost.css
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
: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), 1200px);
|
||||
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;
|
||||
}
|
||||
|
||||
article#blog p:hover,
|
||||
.comment p:hover {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
article#blog {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
article#blog h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.blog-author {
|
||||
margin: .2em 0;
|
||||
}
|
||||
article#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;
|
||||
}
|
||||
|
||||
article#blog {
|
||||
font-family: 'Lora', serif;
|
||||
}
|
||||
|
||||
article#blog header {
|
||||
position: relative;
|
||||
width: auto;
|
||||
font-family: 'Monaspace Argon', monospace;
|
||||
border: none;
|
||||
background: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
article#blog p {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
article#blog sub {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
article#blog pre {
|
||||
max-height: 15em;
|
||||
padding: .5em;
|
||||
font-size: .9em;
|
||||
border: 1px solid #8884;
|
||||
border-radius: 2px;
|
||||
overflow: scroll;
|
||||
background: var(--background-alt);
|
||||
}
|
||||
|
||||
article#blog p code {
|
||||
padding: .2em .3em;
|
||||
font-size: .9em;
|
||||
border: 1px solid #8884;
|
||||
border-radius: 2px;
|
||||
background: var(--background-alt);
|
||||
}
|
||||
|
||||
article#blog blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0 0 0 1em;
|
||||
border-left: .2em solid #8888;
|
||||
}
|
||||
|
||||
article#blog img {
|
||||
max-height: 50%;
|
||||
max-width: 100%;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
article#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 1em;
|
||||
}
|
||||
.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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
})();
|
||||
14
public/vendor/highlight/languages/http.min.js
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/*! `http` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const n="HTTP/([32]|1\\.[01])",a={
|
||||
className:"attribute",
|
||||
begin:e.regex.concat("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{
|
||||
contains:[{className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",
|
||||
relevance:0}}]}},s=[a,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}
|
||||
}];return{name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{
|
||||
begin:"^(?="+n+" \\d{3})",end:/$/,contains:[{className:"meta",begin:n},{
|
||||
className:"number",begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,
|
||||
contains:s}},{begin:"(?=^[A-Z]+ (.*?) "+n+"$)",end:/$/,contains:[{
|
||||
className:"string",begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{
|
||||
className:"meta",begin:n},{className:"keyword",begin:"[A-Z]+"}],starts:{
|
||||
end:/\b\B/,illegal:/\S/,contains:s}},e.inherit(a,{relevance:0})]}}})()
|
||||
;hljs.registerLanguage("http",e)})();
|
||||
15
public/vendor/highlight/languages/ini.min.js
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/*! `ini` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const n=e.regex,a={className:"number",
|
||||
relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{begin:e.NUMBER_RE}]
|
||||
},s=e.COMMENT();s.variants=[{begin:/;/,end:/$/},{begin:/#/,end:/$/}];const i={
|
||||
className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{begin:/\$\{(.*?)\}/
|
||||
}]},t={className:"literal",begin:/\bon|off|true|false|yes|no\b/},r={
|
||||
className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",
|
||||
end:"'''",relevance:10},{begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'
|
||||
},{begin:"'",end:"'"}]},l={begin:/\[/,end:/\]/,contains:[s,t,i,r,a,"self"],
|
||||
relevance:0},c=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{
|
||||
name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/,
|
||||
contains:[s,{className:"section",begin:/\[+/,end:/\]+/},{
|
||||
begin:n.concat(c,"(\\s*\\.\\s*",c,")*",n.lookahead(/\s*=\s*[^#\s]/)),
|
||||
className:"attr",starts:{end:/$/,contains:[s,l,t,i,r,a]}}]}}})()
|
||||
;hljs.registerLanguage("ini",e)})();
|
||||
38
public/vendor/highlight/languages/java.min.js
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/*! `java` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict"
|
||||
;var e="[0-9](_*[0-9])*",a=`\\.(${e})`,n="[0-9a-fA-F](_*[0-9a-fA-F])*",s={
|
||||
className:"number",variants:[{
|
||||
begin:`(\\b(${e})((${a})|\\.)?|(${a}))[eE][+-]?(${e})[fFdD]?\\b`},{
|
||||
begin:`\\b(${e})((${a})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{begin:`(${a})[fFdD]?\\b`
|
||||
},{begin:`\\b(${e})[fFdD]\\b`},{
|
||||
begin:`\\b0[xX]((${n})\\.?|(${n})?\\.(${n}))[pP][+-]?(${e})[fFdD]?\\b`},{
|
||||
begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${n})[lL]?\\b`},{
|
||||
begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}],
|
||||
relevance:0};function t(e,a,n){return-1===n?"":e.replace(a,(s=>t(e,a,n-1)))}
|
||||
return e=>{
|
||||
const a=e.regex,n="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",i=n+t("(?:<"+n+"~~~(?:\\s*,\\s*"+n+"~~~)*>)?",/~~~/g,2),r={
|
||||
keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits","goto","when"],
|
||||
literal:["false","true","null"],
|
||||
type:["char","boolean","long","float","int","byte","short","double"],
|
||||
built_in:["super","this"]},l={className:"meta",begin:"@"+n,contains:[{
|
||||
begin:/\(/,end:/\)/,contains:["self"]}]},c={className:"params",begin:/\(/,
|
||||
end:/\)/,keywords:r,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0}
|
||||
;return{name:"Java",aliases:["jsp"],keywords:r,illegal:/<\/|#/,
|
||||
contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,
|
||||
relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{
|
||||
begin:/import java\.[a-z]+\./,keywords:"import",relevance:2
|
||||
},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/,
|
||||
className:"string",contains:[e.BACKSLASH_ESCAPE]
|
||||
},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{
|
||||
match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,n],className:{
|
||||
1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{
|
||||
begin:[a.concat(/(?!else)/,n),/\s+/,n,/\s+/,/=(?!=)/],className:{1:"type",
|
||||
3:"variable",5:"operator"}},{begin:[/record/,/\s+/,n],className:{1:"keyword",
|
||||
3:"title.class"},contains:[c,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{
|
||||
beginKeywords:"new throw return else",relevance:0},{
|
||||
begin:["(?:"+i+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{
|
||||
2:"title.function"},keywords:r,contains:[{className:"params",begin:/\(/,
|
||||
end:/\)/,keywords:r,relevance:0,
|
||||
contains:[l,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,s,e.C_BLOCK_COMMENT_MODE]
|
||||
},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},s,l]}}})()
|
||||
;hljs.registerLanguage("java",e)})();
|
||||
81
public/vendor/highlight/languages/javascript.min.js
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/*! `javascript` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict"
|
||||
;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],a=["true","false","null","undefined","NaN","Infinity"],t=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],s=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],r=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],c=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],i=[].concat(r,t,s)
|
||||
;return o=>{const l=o.regex,d=e,b={begin:/<[A-Za-z0-9\\._:-]+/,
|
||||
end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{
|
||||
const a=e[0].length+e.index,t=e.input[a]
|
||||
;if("<"===t||","===t)return void n.ignoreMatch();let s
|
||||
;">"===t&&(((e,{after:n})=>{const a="</"+e[0].slice(1)
|
||||
;return-1!==e.input.indexOf(a,n)})(e,{after:a})||n.ignoreMatch())
|
||||
;const r=e.input.substring(a)
|
||||
;((s=r.match(/^\s*=/))||(s=r.match(/^\s+extends\s+/))&&0===s.index)&&n.ignoreMatch()
|
||||
}},g={$pattern:e,keyword:n,literal:a,built_in:i,"variable.language":c
|
||||
},u="[0-9](_?[0-9])*",m=`\\.(${u})`,E="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",A={
|
||||
className:"number",variants:[{
|
||||
begin:`(\\b(${E})((${m})|\\.)?|(${m}))[eE][+-]?(${u})\\b`},{
|
||||
begin:`\\b(${E})\\b((${m})\\b|\\.)?|(${m})\\b`},{
|
||||
begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{
|
||||
begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{
|
||||
begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{
|
||||
begin:"\\b0[0-7]+n?\\b"}],relevance:0},y={className:"subst",begin:"\\$\\{",
|
||||
end:"\\}",keywords:g,contains:[]},h={begin:".?html`",end:"",starts:{end:"`",
|
||||
returnEnd:!1,contains:[o.BACKSLASH_ESCAPE,y],subLanguage:"xml"}},_={
|
||||
begin:".?css`",end:"",starts:{end:"`",returnEnd:!1,
|
||||
contains:[o.BACKSLASH_ESCAPE,y],subLanguage:"css"}},N={begin:".?gql`",end:"",
|
||||
starts:{end:"`",returnEnd:!1,contains:[o.BACKSLASH_ESCAPE,y],
|
||||
subLanguage:"graphql"}},f={className:"string",begin:"`",end:"`",
|
||||
contains:[o.BACKSLASH_ESCAPE,y]},p={className:"comment",
|
||||
variants:[o.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{
|
||||
begin:"(?=@[A-Za-z]+)",relevance:0,contains:[{className:"doctag",
|
||||
begin:"@[A-Za-z]+"},{className:"type",begin:"\\{",end:"\\}",excludeEnd:!0,
|
||||
excludeBegin:!0,relevance:0},{className:"variable",begin:d+"(?=\\s*(-)|$)",
|
||||
endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]
|
||||
}),o.C_BLOCK_COMMENT_MODE,o.C_LINE_COMMENT_MODE]
|
||||
},v=[o.APOS_STRING_MODE,o.QUOTE_STRING_MODE,h,_,N,f,{match:/\$\d+/},A]
|
||||
;y.contains=v.concat({begin:/\{/,end:/\}/,keywords:g,contains:["self"].concat(v)
|
||||
});const S=[].concat(p,y.contains),w=S.concat([{begin:/(\s*)\(/,end:/\)/,
|
||||
keywords:g,contains:["self"].concat(S)}]),R={className:"params",begin:/(\s*)\(/,
|
||||
end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:g,contains:w},O={variants:[{
|
||||
match:[/class/,/\s+/,d,/\s+/,/extends/,/\s+/,l.concat(d,"(",l.concat(/\./,d),")*")],
|
||||
scope:{1:"keyword",3:"title.class",5:"keyword",7:"title.class.inherited"}},{
|
||||
match:[/class/,/\s+/,d],scope:{1:"keyword",3:"title.class"}}]},k={relevance:0,
|
||||
match:l.either(/\bJSON/,/\b[A-Z][a-z]+([A-Z][a-z]*|\d)*/,/\b[A-Z]{2,}([A-Z][a-z]+|\d)+([A-Z][a-z]*)*/,/\b[A-Z]{2,}[a-z]+([A-Z][a-z]+|\d)*([A-Z][a-z]*)*/),
|
||||
className:"title.class",keywords:{_:[...t,...s]}},I={variants:[{
|
||||
match:[/function/,/\s+/,d,/(?=\s*\()/]},{match:[/function/,/\s*(?=\()/]}],
|
||||
className:{1:"keyword",3:"title.function"},label:"func.def",contains:[R],
|
||||
illegal:/%/},x={
|
||||
match:l.concat(/\b/,(T=[...r,"super","import"].map((e=>e+"\\s*\\(")),
|
||||
l.concat("(?!",T.join("|"),")")),d,l.lookahead(/\s*\(/)),
|
||||
className:"title.function",relevance:0};var T;const C={
|
||||
begin:l.concat(/\./,l.lookahead(l.concat(d,/(?![0-9A-Za-z$_(])/))),end:d,
|
||||
excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},M={
|
||||
match:[/get|set/,/\s+/,d,/(?=\()/],className:{1:"keyword",3:"title.function"},
|
||||
contains:[{begin:/\(\)/},R]
|
||||
},B="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+o.UNDERSCORE_IDENT_RE+")\\s*=>",$={
|
||||
match:[/const|var|let/,/\s+/,d,/\s*/,/=\s*/,/(async\s*)?/,l.lookahead(B)],
|
||||
keywords:"async",className:{1:"keyword",3:"title.function"},contains:[R]}
|
||||
;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:g,exports:{
|
||||
PARAMS_CONTAINS:w,CLASS_REFERENCE:k},illegal:/#(?![$_A-z])/,
|
||||
contains:[o.SHEBANG({label:"shebang",binary:"node",relevance:5}),{
|
||||
label:"use_strict",className:"meta",relevance:10,
|
||||
begin:/^\s*['"]use (strict|asm)['"]/
|
||||
},o.APOS_STRING_MODE,o.QUOTE_STRING_MODE,h,_,N,f,p,{match:/\$\d+/},A,k,{
|
||||
scope:"attr",match:d+l.lookahead(":"),relevance:0},$,{
|
||||
begin:"("+o.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
|
||||
keywords:"return throw case",relevance:0,contains:[p,o.REGEXP_MODE,{
|
||||
className:"function",begin:B,returnBegin:!0,end:"\\s*=>",contains:[{
|
||||
className:"params",variants:[{begin:o.UNDERSCORE_IDENT_RE,relevance:0},{
|
||||
className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/,
|
||||
excludeBegin:!0,excludeEnd:!0,keywords:g,contains:w}]}]},{begin:/,/,relevance:0
|
||||
},{match:/\s+/,relevance:0},{variants:[{begin:"<>",end:"</>"},{
|
||||
match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:b.begin,
|
||||
"on:begin":b.isTrulyOpeningTag,end:b.end}],subLanguage:"xml",contains:[{
|
||||
begin:b.begin,end:b.end,skip:!0,contains:["self"]}]}]},I,{
|
||||
beginKeywords:"while if switch catch for"},{
|
||||
begin:"\\b(?!function)"+o.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",
|
||||
returnBegin:!0,label:"func.def",contains:[R,o.inherit(o.TITLE_MODE,{begin:d,
|
||||
className:"title.function"})]},{match:/\.\.\./,relevance:0},C,{match:"\\$"+d,
|
||||
relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"},
|
||||
contains:[R]},x,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,
|
||||
className:"variable.constant"},O,M,{match:/\$[(.]/}]}}})()
|
||||
;hljs.registerLanguage("javascript",e)})();
|
||||
8
public/vendor/highlight/languages/json.min.js
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/*! `json` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const a=["true","false","null"],s={
|
||||
scope:"literal",beginKeywords:a.join(" ")};return{name:"JSON",aliases:["jsonc"],
|
||||
keywords:{literal:a},contains:[{className:"attr",
|
||||
begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{match:/[{}[\],:]/,
|
||||
className:"punctuation",relevance:0
|
||||
},e.QUOTE_STRING_MODE,s,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE],
|
||||
illegal:"\\S"}}})();hljs.registerLanguage("json",e)})();
|
||||
15
public/vendor/highlight/languages/lua.min.js
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/*! `lua` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const t="\\[=*\\[",a="\\]=*\\]",n={
|
||||
begin:t,end:a,contains:["self"]
|
||||
},o=[e.COMMENT("--(?!"+t+")","$"),e.COMMENT("--"+t,a,{contains:[n],relevance:10
|
||||
})];return{name:"Lua",aliases:["pluto"],keywords:{
|
||||
$pattern:e.UNDERSCORE_IDENT_RE,literal:"true false nil",
|
||||
keyword:"and break do else elseif end for goto if in local not or repeat return then until while",
|
||||
built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"
|
||||
},contains:o.concat([{className:"function",beginKeywords:"function",end:"\\)",
|
||||
contains:[e.inherit(e.TITLE_MODE,{
|
||||
begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",
|
||||
begin:"\\(",endsWithParent:!0,contains:o}].concat(o)
|
||||
},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",
|
||||
begin:t,end:a,contains:[n],relevance:5}])}}})();hljs.registerLanguage("lua",e)
|
||||
})();
|
||||
32
public/vendor/highlight/languages/markdown.min.js
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/*! `markdown` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const n={begin:/<\/?[A-Za-z_]/,
|
||||
end:">",subLanguage:"xml",relevance:0},a={variants:[{begin:/\[.+?\]\[.*?\]/,
|
||||
relevance:0},{
|
||||
begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/,
|
||||
relevance:2},{
|
||||
begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/),
|
||||
relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{
|
||||
begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/
|
||||
},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0,
|
||||
returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)",
|
||||
excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[",
|
||||
end:"\\]",excludeBegin:!0,excludeEnd:!0}]},i={className:"strong",contains:[],
|
||||
variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}]
|
||||
},s={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{
|
||||
begin:/_(?![_\s])/,end:/_/,relevance:0}]},c=e.inherit(i,{contains:[]
|
||||
}),t=e.inherit(s,{contains:[]});i.contains.push(t),s.contains.push(c)
|
||||
;let g=[n,a];return[i,s,c,t].forEach((e=>{e.contains=e.contains.concat(g)
|
||||
})),g=g.concat(i,s),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{
|
||||
className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:g},{
|
||||
begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n",
|
||||
contains:g}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)",
|
||||
end:"\\s+",excludeEnd:!0},i,s,{className:"quote",begin:"^>\\s+",contains:g,
|
||||
end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{
|
||||
begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{
|
||||
begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))",
|
||||
contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{
|
||||
begin:"^[-\\*]{3,}",end:"$"},a,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{
|
||||
className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{
|
||||
className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]},{scope:"literal",
|
||||
match:/&([a-zA-Z0-9]+|#[0-9]{1,7}|#[Xx][0-9a-fA-F]{1,6});/}]}}})()
|
||||
;hljs.registerLanguage("markdown",e)})();
|
||||
21
public/vendor/highlight/languages/nginx.min.js
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/*! `nginx` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const n=e.regex,a={
|
||||
className:"variable",variants:[{begin:/\$\d+/},{begin:/\$\{\w+\}/},{
|
||||
begin:n.concat(/[$@]/,e.UNDERSCORE_IDENT_RE)}]},s={endsWithParent:!0,keywords:{
|
||||
$pattern:/[a-z_]{2,}|\/dev\/poll/,
|
||||
literal:["on","off","yes","no","true","false","none","blocked","debug","info","notice","warn","error","crit","select","break","last","permanent","redirect","kqueue","rtsig","epoll","poll","/dev/poll"]
|
||||
},relevance:0,illegal:"=>",contains:[e.HASH_COMMENT_MODE,{className:"string",
|
||||
contains:[e.BACKSLASH_ESCAPE,a],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/
|
||||
}]},{begin:"([a-z]+):/",end:"\\s",endsWithParent:!0,excludeEnd:!0,contains:[a]
|
||||
},{className:"regexp",contains:[e.BACKSLASH_ESCAPE,a],variants:[{begin:"\\s\\^",
|
||||
end:"\\s|\\{|;",returnEnd:!0},{begin:"~\\*?\\s+",end:"\\s|\\{|;",returnEnd:!0},{
|
||||
begin:"\\*(\\.[a-z\\-]+)+"},{begin:"([a-z\\-]+\\.)+\\*"}]},{className:"number",
|
||||
begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{
|
||||
className:"number",begin:"\\b\\d+[kKmMgGdshdwy]?\\b",relevance:0},a]};return{
|
||||
name:"Nginx config",aliases:["nginxconf"],contains:[e.HASH_COMMENT_MODE,{
|
||||
beginKeywords:"upstream location",end:/;|\{/,contains:s.contains,keywords:{
|
||||
section:"upstream location"}},{className:"section",
|
||||
begin:n.concat(e.UNDERSCORE_IDENT_RE+n.lookahead(/\s+\{/)),relevance:0},{
|
||||
begin:n.lookahead(e.UNDERSCORE_IDENT_RE+"\\s"),end:";|\\{",contains:[{
|
||||
className:"attribute",begin:e.UNDERSCORE_IDENT_RE,starts:s}],relevance:0}],
|
||||
illegal:"[^\\s\\}\\{]"}}})();hljs.registerLanguage("nginx",e)})();
|
||||
69
public/vendor/highlight/languages/pgsql.min.js
vendored
Normal file
42
public/vendor/highlight/languages/python.min.js
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/*! `python` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{
|
||||
const n=e.regex,a=/[\p{XID_Start}_]\p{XID_Continue}*/u,s=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],t={
|
||||
$pattern:/[A-Za-z]\w+|__\w+__/,keyword:s,
|
||||
built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"],
|
||||
literal:["__debug__","Ellipsis","False","None","NotImplemented","True"],
|
||||
type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"]
|
||||
},i={className:"meta",begin:/^(>>>|\.\.\.) /},r={className:"subst",begin:/\{/,
|
||||
end:/\}/,keywords:t,illegal:/#/},l={begin:/\{\{/,relevance:0},o={
|
||||
className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{
|
||||
begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/,
|
||||
contains:[e.BACKSLASH_ESCAPE,i],relevance:10},{
|
||||
begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/,
|
||||
contains:[e.BACKSLASH_ESCAPE,i],relevance:10},{
|
||||
begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/,
|
||||
contains:[e.BACKSLASH_ESCAPE,i,l,r]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/,
|
||||
end:/"""/,contains:[e.BACKSLASH_ESCAPE,i,l,r]},{begin:/([uU]|[rR])'/,end:/'/,
|
||||
relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{
|
||||
begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/,
|
||||
end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/,
|
||||
contains:[e.BACKSLASH_ESCAPE,l,r]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/,
|
||||
contains:[e.BACKSLASH_ESCAPE,l,r]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
|
||||
},b="[0-9](_?[0-9])*",c=`(\\b(${b}))?\\.(${b})|\\b(${b})\\.`,d="\\b|"+s.join("|"),g={
|
||||
className:"number",relevance:0,variants:[{
|
||||
begin:`(\\b(${b})|(${c}))[eE][+-]?(${b})[jJ]?(?=${d})`},{begin:`(${c})[jJ]?`},{
|
||||
begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${d})`},{
|
||||
begin:`\\b0[bB](_?[01])+[lL]?(?=${d})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${d})`
|
||||
},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${d})`},{begin:`\\b(${b})[jJ](?=${d})`
|
||||
}]},p={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:t,
|
||||
contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={
|
||||
className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/,
|
||||
end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:t,
|
||||
contains:["self",i,g,o,e.HASH_COMMENT_MODE]}]};return r.contains=[o,g,i],{
|
||||
name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:t,
|
||||
illegal:/(<\/|\?)|=>/,contains:[i,g,{scope:"variable.language",match:/\bself\b/
|
||||
},{beginKeywords:"if",relevance:0},{match:/\bor\b/,scope:"keyword"
|
||||
},o,p,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,a],scope:{1:"keyword",
|
||||
3:"title.function"},contains:[m]},{variants:[{
|
||||
match:[/\bclass/,/\s+/,a,/\s*/,/\(\s*/,a,/\s*\)/]},{match:[/\bclass/,/\s+/,a]}],
|
||||
scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{
|
||||
className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[g,m,o]}]}}})()
|
||||
;hljs.registerLanguage("python",e)})();
|
||||
27
public/vendor/highlight/languages/rust.min.js
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*! `rust` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{
|
||||
const t=e.regex,n=/(r#)?/,a=t.concat(n,e.UNDERSCORE_IDENT_RE),i=t.concat(n,e.IDENT_RE),s={
|
||||
className:"title.function.invoke",relevance:0,
|
||||
begin:t.concat(/\b/,/(?!let|for|while|if|else|match\b)/,i,t.lookahead(/\s*\(/))
|
||||
},r="([ui](8|16|32|64|128|size)|f(32|64))?",l=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],o=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"]
|
||||
;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:o,
|
||||
keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","union","unsafe","unsized","use","virtual","where","while","yield"],
|
||||
literal:["true","false","Some","None","Ok","Err"],built_in:l},illegal:"</",
|
||||
contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:["self"]
|
||||
}),e.inherit(e.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{
|
||||
className:"symbol",begin:/'[a-zA-Z_][a-zA-Z0-9_]*(?!')/},{scope:"string",
|
||||
variants:[{begin:/b?r(#*)"(.|\n)*?"\1(?!#)/},{begin:/b?'/,end:/'/,contains:[{
|
||||
scope:"char.escape",match:/\\('|\w|x\w{2}|u\w{4}|U\w{8})/}]}]},{
|
||||
className:"number",variants:[{begin:"\\b0b([01_]+)"+r},{begin:"\\b0o([0-7_]+)"+r
|
||||
},{begin:"\\b0x([A-Fa-f0-9_]+)"+r},{
|
||||
begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)"+r}],relevance:0},{
|
||||
begin:[/fn/,/\s+/,a],className:{1:"keyword",3:"title.function"}},{
|
||||
className:"meta",begin:"#!?\\[",end:"\\]",contains:[{className:"string",
|
||||
begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE]}]},{
|
||||
begin:[/let/,/\s+/,/(?:mut\s+)?/,a],className:{1:"keyword",3:"keyword",
|
||||
4:"variable"}},{begin:[/for/,/\s+/,a,/\s+/,/in/],className:{1:"keyword",
|
||||
3:"variable",5:"keyword"}},{begin:[/type/,/\s+/,a],className:{1:"keyword",
|
||||
3:"title.class"}},{begin:[/(?:trait|enum|struct|union|impl|for)/,/\s+/,a],
|
||||
className:{1:"keyword",3:"title.class"}},{begin:e.IDENT_RE+"::",keywords:{
|
||||
keyword:"Self",built_in:l,type:o}},{className:"punctuation",begin:"->"},s]}}})()
|
||||
;hljs.registerLanguage("rust",e)})();
|
||||
99
public/vendor/highlight/languages/typescript.min.js
vendored
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/*! `typescript` grammar compiled for Highlight.js 11.11.1 */
|
||||
(()=>{var e=(()=>{"use strict"
|
||||
;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],a=["true","false","null","undefined","NaN","Infinity"],t=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],s=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],c=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],r=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],i=[].concat(c,t,s)
|
||||
;function o(o){const l=o.regex,d=e,b={begin:/<[A-Za-z0-9\\._:-]+/,
|
||||
end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{
|
||||
const a=e[0].length+e.index,t=e.input[a]
|
||||
;if("<"===t||","===t)return void n.ignoreMatch();let s
|
||||
;">"===t&&(((e,{after:n})=>{const a="</"+e[0].slice(1)
|
||||
;return-1!==e.input.indexOf(a,n)})(e,{after:a})||n.ignoreMatch())
|
||||
;const c=e.input.substring(a)
|
||||
;((s=c.match(/^\s*=/))||(s=c.match(/^\s+extends\s+/))&&0===s.index)&&n.ignoreMatch()
|
||||
}},g={$pattern:e,keyword:n,literal:a,built_in:i,"variable.language":r
|
||||
},u="[0-9](_?[0-9])*",m=`\\.(${u})`,E="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",A={
|
||||
className:"number",variants:[{
|
||||
begin:`(\\b(${E})((${m})|\\.)?|(${m}))[eE][+-]?(${u})\\b`},{
|
||||
begin:`\\b(${E})\\b((${m})\\b|\\.)?|(${m})\\b`},{
|
||||
begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{
|
||||
begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{
|
||||
begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{
|
||||
begin:"\\b0[0-7]+n?\\b"}],relevance:0},y={className:"subst",begin:"\\$\\{",
|
||||
end:"\\}",keywords:g,contains:[]},p={begin:".?html`",end:"",starts:{end:"`",
|
||||
returnEnd:!1,contains:[o.BACKSLASH_ESCAPE,y],subLanguage:"xml"}},N={
|
||||
begin:".?css`",end:"",starts:{end:"`",returnEnd:!1,
|
||||
contains:[o.BACKSLASH_ESCAPE,y],subLanguage:"css"}},f={begin:".?gql`",end:"",
|
||||
starts:{end:"`",returnEnd:!1,contains:[o.BACKSLASH_ESCAPE,y],
|
||||
subLanguage:"graphql"}},_={className:"string",begin:"`",end:"`",
|
||||
contains:[o.BACKSLASH_ESCAPE,y]},h={className:"comment",
|
||||
variants:[o.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{
|
||||
begin:"(?=@[A-Za-z]+)",relevance:0,contains:[{className:"doctag",
|
||||
begin:"@[A-Za-z]+"},{className:"type",begin:"\\{",end:"\\}",excludeEnd:!0,
|
||||
excludeBegin:!0,relevance:0},{className:"variable",begin:d+"(?=\\s*(-)|$)",
|
||||
endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]
|
||||
}),o.C_BLOCK_COMMENT_MODE,o.C_LINE_COMMENT_MODE]
|
||||
},S=[o.APOS_STRING_MODE,o.QUOTE_STRING_MODE,p,N,f,_,{match:/\$\d+/},A]
|
||||
;y.contains=S.concat({begin:/\{/,end:/\}/,keywords:g,contains:["self"].concat(S)
|
||||
});const v=[].concat(h,y.contains),w=v.concat([{begin:/(\s*)\(/,end:/\)/,
|
||||
keywords:g,contains:["self"].concat(v)}]),R={className:"params",begin:/(\s*)\(/,
|
||||
end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:g,contains:w},k={variants:[{
|
||||
match:[/class/,/\s+/,d,/\s+/,/extends/,/\s+/,l.concat(d,"(",l.concat(/\./,d),")*")],
|
||||
scope:{1:"keyword",3:"title.class",5:"keyword",7:"title.class.inherited"}},{
|
||||
match:[/class/,/\s+/,d],scope:{1:"keyword",3:"title.class"}}]},x={relevance:0,
|
||||
match:l.either(/\bJSON/,/\b[A-Z][a-z]+([A-Z][a-z]*|\d)*/,/\b[A-Z]{2,}([A-Z][a-z]+|\d)+([A-Z][a-z]*)*/,/\b[A-Z]{2,}[a-z]+([A-Z][a-z]+|\d)*([A-Z][a-z]*)*/),
|
||||
className:"title.class",keywords:{_:[...t,...s]}},O={variants:[{
|
||||
match:[/function/,/\s+/,d,/(?=\s*\()/]},{match:[/function/,/\s*(?=\()/]}],
|
||||
className:{1:"keyword",3:"title.function"},label:"func.def",contains:[R],
|
||||
illegal:/%/},I={
|
||||
match:l.concat(/\b/,(C=[...c,"super","import"].map((e=>e+"\\s*\\(")),
|
||||
l.concat("(?!",C.join("|"),")")),d,l.lookahead(/\s*\(/)),
|
||||
className:"title.function",relevance:0};var C;const T={
|
||||
begin:l.concat(/\./,l.lookahead(l.concat(d,/(?![0-9A-Za-z$_(])/))),end:d,
|
||||
excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},M={
|
||||
match:[/get|set/,/\s+/,d,/(?=\()/],className:{1:"keyword",3:"title.function"},
|
||||
contains:[{begin:/\(\)/},R]
|
||||
},B="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+o.UNDERSCORE_IDENT_RE+")\\s*=>",$={
|
||||
match:[/const|var|let/,/\s+/,d,/\s*/,/=\s*/,/(async\s*)?/,l.lookahead(B)],
|
||||
keywords:"async",className:{1:"keyword",3:"title.function"},contains:[R]}
|
||||
;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:g,exports:{
|
||||
PARAMS_CONTAINS:w,CLASS_REFERENCE:x},illegal:/#(?![$_A-z])/,
|
||||
contains:[o.SHEBANG({label:"shebang",binary:"node",relevance:5}),{
|
||||
label:"use_strict",className:"meta",relevance:10,
|
||||
begin:/^\s*['"]use (strict|asm)['"]/
|
||||
},o.APOS_STRING_MODE,o.QUOTE_STRING_MODE,p,N,f,_,h,{match:/\$\d+/},A,x,{
|
||||
scope:"attr",match:d+l.lookahead(":"),relevance:0},$,{
|
||||
begin:"("+o.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
|
||||
keywords:"return throw case",relevance:0,contains:[h,o.REGEXP_MODE,{
|
||||
className:"function",begin:B,returnBegin:!0,end:"\\s*=>",contains:[{
|
||||
className:"params",variants:[{begin:o.UNDERSCORE_IDENT_RE,relevance:0},{
|
||||
className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/,
|
||||
excludeBegin:!0,excludeEnd:!0,keywords:g,contains:w}]}]},{begin:/,/,relevance:0
|
||||
},{match:/\s+/,relevance:0},{variants:[{begin:"<>",end:"</>"},{
|
||||
match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:b.begin,
|
||||
"on:begin":b.isTrulyOpeningTag,end:b.end}],subLanguage:"xml",contains:[{
|
||||
begin:b.begin,end:b.end,skip:!0,contains:["self"]}]}]},O,{
|
||||
beginKeywords:"while if switch catch for"},{
|
||||
begin:"\\b(?!function)"+o.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",
|
||||
returnBegin:!0,label:"func.def",contains:[R,o.inherit(o.TITLE_MODE,{begin:d,
|
||||
className:"title.function"})]},{match:/\.\.\./,relevance:0},T,{match:"\\$"+d,
|
||||
relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"},
|
||||
contains:[R]},I,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,
|
||||
className:"variable.constant"},k,M,{match:/\$[(.]/}]}}return t=>{
|
||||
const s=t.regex,c=o(t),l=e,d=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],b={
|
||||
begin:[/namespace/,/\s+/,t.IDENT_RE],beginScope:{1:"keyword",3:"title.class"}
|
||||
},g={beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:{
|
||||
keyword:"interface extends",built_in:d},contains:[c.exports.CLASS_REFERENCE]
|
||||
},u={$pattern:e,
|
||||
keyword:n.concat(["type","interface","public","private","protected","implements","declare","abstract","readonly","enum","override","satisfies"]),
|
||||
literal:a,built_in:i.concat(d),"variable.language":r},m={className:"meta",
|
||||
begin:"@"+l},E=(e,n,a)=>{const t=e.contains.findIndex((e=>e.label===n))
|
||||
;if(-1===t)throw Error("can not find mode to replace");e.contains.splice(t,1,a)}
|
||||
;Object.assign(c.keywords,u),c.exports.PARAMS_CONTAINS.push(m)
|
||||
;const A=c.contains.find((e=>"attr"===e.scope)),y=Object.assign({},A,{
|
||||
match:s.concat(l,s.lookahead(/\s*\?:/))})
|
||||
;return c.exports.PARAMS_CONTAINS.push([c.exports.CLASS_REFERENCE,A,y]),
|
||||
c.contains=c.contains.concat([m,b,g,y]),
|
||||
E(c,"shebang",t.SHEBANG()),E(c,"use_strict",{className:"meta",relevance:10,
|
||||
begin:/^\s*['"]use strict['"]/
|
||||
}),c.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(c,{
|
||||
name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),c}})()
|
||||
;hljs.registerLanguage("typescript",e)})();
|
||||
5
public/vendor/highlight/styles/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
*.css
|
||||
*.jpg
|
||||
*.png
|
||||
base16
|
||||
!ari.css
|
||||
109
public/vendor/highlight/styles/ari.css
vendored
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/*!
|
||||
Theme: Default
|
||||
Description: Original highlight.js style
|
||||
Author: (c) Ivan Sagalaev <maniac@softwaremaniacs.org>
|
||||
Maintainer: @highlightjs/core-team
|
||||
Website: https://highlightjs.org/
|
||||
License: see project LICENSE
|
||||
Touched: 2021
|
||||
*/
|
||||
/*
|
||||
This is left on purpose making default.css the single file that can be lifted
|
||||
as-is from the repository directly without the need for a build step
|
||||
|
||||
Typically this "required" baseline CSS is added by `makestuff.js` during build.
|
||||
*/
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
/* padding: 1em */
|
||||
}
|
||||
code.hljs {
|
||||
padding: 3px 5px
|
||||
}
|
||||
/* end baseline CSS */
|
||||
.hljs {
|
||||
background: #F3F3F3;
|
||||
color: #444
|
||||
}
|
||||
/* Base color: saturation 0; */
|
||||
.hljs-subst {
|
||||
/* default */
|
||||
|
||||
}
|
||||
/* purposely ignored */
|
||||
.hljs-formula,
|
||||
.hljs-attr,
|
||||
.hljs-property,
|
||||
.hljs-params {
|
||||
|
||||
}
|
||||
.hljs-comment {
|
||||
color: #697070
|
||||
}
|
||||
.hljs-tag,
|
||||
.hljs-punctuation {
|
||||
color: #444a
|
||||
}
|
||||
.hljs-tag .hljs-name,
|
||||
.hljs-tag .hljs-attr {
|
||||
color: #444
|
||||
}
|
||||
.hljs-keyword,
|
||||
.hljs-attribute,
|
||||
.hljs-selector-tag,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-doctag,
|
||||
.hljs-name {
|
||||
font-weight: bold
|
||||
}
|
||||
/* User color: hue: 0 */
|
||||
.hljs-type,
|
||||
.hljs-string,
|
||||
.hljs-number,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class,
|
||||
.hljs-quote,
|
||||
.hljs-template-tag,
|
||||
.hljs-deletion {
|
||||
color: #880000
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #880000;
|
||||
font-weight: bold
|
||||
}
|
||||
.hljs-regexp,
|
||||
.hljs-symbol,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-link,
|
||||
.hljs-selector-attr,
|
||||
.hljs-operator,
|
||||
.hljs-selector-pseudo {
|
||||
color: #ab5656
|
||||
}
|
||||
/* Language color: hue: 90; */
|
||||
.hljs-literal {
|
||||
color: #695
|
||||
}
|
||||
.hljs-built_in,
|
||||
.hljs-bullet,
|
||||
.hljs-code,
|
||||
.hljs-addition {
|
||||
color: #397300
|
||||
}
|
||||
/* Meta color: hue: 200 */
|
||||
.hljs-meta {
|
||||
color: #1f7199
|
||||
}
|
||||
.hljs-meta .hljs-string {
|
||||
color: #38a
|
||||
}
|
||||
/* Misc effects */
|
||||
.hljs-emphasis {
|
||||
font-style: italic
|
||||
}
|
||||
.hljs-strong {
|
||||
font-weight: bold
|
||||
}
|
||||
51
templates/html/blog.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{{define "head"}}
|
||||
<title>blog - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
|
||||
<meta name="description" content="thoughts from your local SPACEGIRL 💫">
|
||||
|
||||
<meta property="og:title" content="ari melody blog 💫">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://arimelody.space/blog/">
|
||||
<meta property="og:image" content="https://arimelody.space/img/favicon.png">
|
||||
<meta property="og:site_name" content="ari melody">
|
||||
<meta property="og:description" content="thoughts from your local SPACEGIRL 💫">
|
||||
|
||||
<link rel="stylesheet" href="/style/main.css">
|
||||
<link rel="stylesheet" href="/style/index.css">
|
||||
<link rel="stylesheet" href="/style/blog.css">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<h1 class="typeout"># blog</h1>
|
||||
<p class="">thoughts from your local SPACEGIRL 💫</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="posts">
|
||||
{{if eq (len .Collections) 0}}
|
||||
<p>there are no posts! 🍃</p>
|
||||
{{end}}
|
||||
{{range .Collections}}
|
||||
<h2 id="{{.Name}}" class="collection-name">{{.Name}}</h2>
|
||||
{{range .Posts}}
|
||||
<article class="blog-post">
|
||||
<h3 class="blog-title"><a href="/blog/{{.ID}}">{{.Title}}</a></h3>
|
||||
<p class="blog-meta">
|
||||
<span class="blog-author"><img src="/img/favicon.png" alt="{{.Author.Username}}'s avatar" width="32" height="32"/> {{.Author.Username}}</span>
|
||||
<span class="blog-date">• {{.PrintDate}}</span>
|
||||
</p>
|
||||
{{if ne .Description ""}}
|
||||
<p class="blog-description">{{.Description}}</p>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- <button type="submit" class="link-button" id="load-more">load more</button> -->
|
||||
|
||||
<script src="/script/blog.js" type="module" defer></script>
|
||||
</main>
|
||||
{{end}}
|
||||
181
templates/html/blogpost.html
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
{{define "head"}}
|
||||
<title>{{.Title}} - ari melody 💫</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||
|
||||
<meta name="description" content="{{.Description}}">
|
||||
|
||||
<meta property="og:title" content="{{.Title}}">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://arimelody.space/blog/{{.ID}}">
|
||||
<meta property="og:image" content="https://arimelody.space/img/favicon.png">
|
||||
<meta property="og:site_name" content="ari melody">
|
||||
<meta property="og:description" content="{{.Description}}">
|
||||
|
||||
<link rel="stylesheet" href="/style/main.css">
|
||||
<link rel="stylesheet" href="/style/index.css">
|
||||
<link rel="stylesheet" href="/style/blogpost.css">
|
||||
|
||||
<link rel="stylesheet" href="/vendor/highlight/styles/ari.css">
|
||||
<script src="/vendor/highlight/highlight.min.js" defer></script>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<main>
|
||||
<!--
|
||||
<div id="blog-sidebar">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#copy-link" id="blog-copy-link" title="copy link">
|
||||
<div class="dark-only">
|
||||
<img src="/img/blog/copy-link-dark.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
<div class="light-only">
|
||||
<img src="/img/blog/copy-link-light.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{{if ne .BlueskyURL ""}}
|
||||
<li>
|
||||
<a href="{{.BlueskyURL}}" id="blog-share-bsky" title="share on bluesky">
|
||||
<div class="dark-only">
|
||||
<img src="/img/blog/bluesky-dark.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
<div class="light-only">
|
||||
<img src="/img/blog/bluesky-light.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<hr>
|
||||
<li>
|
||||
<a href="{{.BlueskyURL}}" id="blog-like" title="like this post">
|
||||
<div class="dark-only">
|
||||
<img src="/img/blog/like-dark.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
<div class="light-only">
|
||||
<img src="/img/blog/like-light.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{.BlueskyURL}}" id="blog-boost" title="boost this post">
|
||||
<div class="dark-only">
|
||||
<img src="/img/blog/boost-dark.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
<div class="light-only">
|
||||
<img src="/img/blog/boost-light.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#comments" id="blog-comments" title="comments">
|
||||
<div class="dark-only">
|
||||
<img src="/img/blog/comment-dark.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
<div class="light-only">
|
||||
<img src="/img/blog/comment-light.svg" alt="" width="36" height="36">
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
-->
|
||||
<div id="blog-container">
|
||||
<article id="blog">
|
||||
<header>
|
||||
<h1 class="typeout"># {{.Title}}</h1>
|
||||
<p class="blog-author">by <a href="/blog?author={{.Author.Username}}">{{.Author.Username}} <img src="/img/favicon.png" alt="" aria-hidden="true" width="32" height="32"/></a></p>
|
||||
<p class="blog-date">posted {{.PrintDate}}{{if .ModifiedAt.Valid}} <span class="blog-modified-date">• updated {{.PrintModifiedDate}}</span>{{end}}</p>
|
||||
</header>
|
||||
|
||||
<hr>
|
||||
|
||||
{{.HTML}}
|
||||
</article>
|
||||
|
||||
{{if ne .BlueskyURL ""}}
|
||||
<hr>
|
||||
|
||||
<div id="interactions">
|
||||
<a href="{{.BlueskyURL}}" class="button likes" aria-label="{{.Likes}} likes">
|
||||
<div class="dark-only">
|
||||
<img src="/img/blog/like-dark.svg" alt="" width="32" height="32">
|
||||
</div>
|
||||
<div class="light-only">
|
||||
<img src="/img/blog/like-light.svg" alt="" width="32" height="32">
|
||||
</div>
|
||||
{{.Likes}}
|
||||
</a>
|
||||
<a href="{{.BlueskyURL}}" class="button boosts" aria-label="{{.Boosts}} boosts">
|
||||
<div class="dark-only">
|
||||
<img src="/img/blog/boost-dark.svg" alt="" width="32" height="32">
|
||||
</div>
|
||||
<div class="light-only">
|
||||
<img src="/img/blog/boost-light.svg" alt="" width="32" height="32">
|
||||
</div>
|
||||
{{.Boosts}}
|
||||
</a>
|
||||
|
||||
<p class="comment-callout">
|
||||
join the conversation on
|
||||
<a class="bluesky" href="{{.BlueskyURL}}">Bluesky 🦋</a>
|
||||
<!-- TODO: mastodon support -->
|
||||
<!--
|
||||
and
|
||||
<a class="mastodon" href="{{.MastodonURL}}">Mastodon 🐘</a>
|
||||
-->
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="comments">
|
||||
{{range .Comments}}
|
||||
{{template "comment" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script type="module" src="/script/blogpost.js"></script>
|
||||
</div>
|
||||
</main>
|
||||
{{end}}
|
||||
|
||||
{{define "comment"}}
|
||||
<article class="comment">
|
||||
<div class="comment-hover">
|
||||
<div class="comment-header">
|
||||
<a href="https://bsky.app/profile/{{.Post.Author.Handle}}" target="_blank">
|
||||
<img class="avatar" src="{{.Post.Author.Avatar}}" alt="{{.Post.Author.DisplayName}}'s avatar" width="32" height="32">
|
||||
<span class="display-name">{{.Post.Author.DisplayName}}</span>
|
||||
<span class="handle">@{{.Post.Author.Handle}}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="comment-body" target="_blank">
|
||||
<div>
|
||||
<p class="comment-text">{{.Post.Record.Text}}</p>
|
||||
{{if .Post.HasImage}}
|
||||
<p class="comment-images">
|
||||
{{range .Post.Embed.Media.Images}}
|
||||
<a href="{{.Fullsize}}" target="_blank">[image]</a>
|
||||
{{end}}
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="comment-footer">
|
||||
<span class="comment-footer-static">
|
||||
<span>{{.Post.LikeCount}} like{{if ne .Post.LikeCount 1}}s{{end}}</span>
|
||||
•
|
||||
<span>{{.Post.RepostCount}} boost{{if ne .Post.RepostCount 1}}s{{end}}</span>
|
||||
•
|
||||
</span>
|
||||
<a href="{{.Post.BskyURL}}" class="comment-date">{{.Post.Record.CreatedAtPrint}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comment-replies">
|
||||
{{range .Replies}}
|
||||
{{template "comment" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
|
|
@ -21,11 +21,10 @@
|
|||
<a href="/" preload="mouseover">home</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/music" preload="mouseover">music</a>
|
||||
<a href="/music/" preload="mouseover">music</a>
|
||||
</li>
|
||||
<li>
|
||||
<!-- coming later! -->
|
||||
<span title="coming later!">blog</span>
|
||||
<a href="/blog/" preload="mouseover">blog</a>
|
||||
</li>
|
||||
<li>
|
||||
<!-- coming later! -->
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@
|
|||
<h2>TRACKS</h2>
|
||||
{{range $i, $track := .Tracks}}
|
||||
<details>
|
||||
<summary class="album-track-title">{{$track.Add $i 1}}. {{$track.Title}}</summary>
|
||||
<summary class="album-track-title">{{add $i 1}}. {{$track.Title}}</summary>
|
||||
|
||||
{{if $track.Description}}
|
||||
<p class="album-track-subheading">DESCRIPTION</p>
|
||||
|
|
|
|||
|
|
@ -22,15 +22,24 @@ var musicHTML string
|
|||
var musicGatewayHTML string
|
||||
// //go:embed "html/404.html"
|
||||
// var error404HTML string
|
||||
//go:embed "html/blog.html"
|
||||
var blogHTML string
|
||||
//go:embed "html/blogpost.html"
|
||||
var blogPostHTML string
|
||||
|
||||
var BaseTemplate = template.Must(template.New("base").Parse(
|
||||
strings.Join([]string{
|
||||
var BaseTemplate = template.Must(
|
||||
template.New("base").Funcs(
|
||||
template.FuncMap{
|
||||
"add": func (a int, b int) int { return a + b },
|
||||
},
|
||||
).Parse(strings.Join([]string{
|
||||
layoutHTML,
|
||||
headerHTML,
|
||||
footerHTML,
|
||||
prideflagHTML,
|
||||
}, "\n"),
|
||||
))
|
||||
}, "\n")))
|
||||
var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(indexHTML))
|
||||
var MusicTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(musicHTML))
|
||||
var MusicGatewayTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(musicGatewayHTML))
|
||||
var BlogTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(blogHTML))
|
||||
var BlogPostTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(blogPostHTML))
|
||||
|
|
|
|||
185
view/blog.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"arimelody-web/controller"
|
||||
"arimelody-web/model"
|
||||
"arimelody-web/templates"
|
||||
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
type (
|
||||
BlogView struct {
|
||||
Collections []*BlogViewPostCollection
|
||||
}
|
||||
|
||||
BlogViewPostCollection struct {
|
||||
Name string
|
||||
Posts []*BlogPostView
|
||||
}
|
||||
|
||||
BlogPostView struct {
|
||||
*model.BlogPost
|
||||
Author *model.Account
|
||||
Comments []*model.ThreadViewPost
|
||||
Likes int
|
||||
Boosts int
|
||||
BlueskyURL string
|
||||
MastodonURL string
|
||||
}
|
||||
)
|
||||
|
||||
var mdRenderer = html.NewRenderer(html.RendererOptions{
|
||||
Flags: html.CommonFlags | html.HrefTargetBlank,
|
||||
})
|
||||
|
||||
func BlogHandler(app *model.AppState) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Count(r.URL.Path, "/") > 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if len(r.URL.Path) > 1 {
|
||||
ServeBlogPost(app, r.URL.Path[1:]).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
dbPosts, err := controller.GetBlogPosts(app.DB, true, -1, 0)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
collections := []*BlogViewPostCollection{}
|
||||
posts := []*BlogPostView{}
|
||||
collectionYear := 0
|
||||
for i, post := range dbPosts {
|
||||
author, err := controller.GetAccountByID(app.DB, post.AuthorID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve author of blog %s: %v\n", post.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
collectionYear = post.CreatedAt.Year()
|
||||
}
|
||||
|
||||
if post.CreatedAt.Year() != collectionYear || i == len(dbPosts) - 1 {
|
||||
if i == len(dbPosts) - 1 {
|
||||
posts = append(posts, &BlogPostView{
|
||||
BlogPost: post,
|
||||
Author: author,
|
||||
})
|
||||
}
|
||||
postsCopy := slices.Clone(posts)
|
||||
collections = append(collections, &BlogViewPostCollection{
|
||||
Name: strconv.Itoa(collectionYear),
|
||||
Posts: postsCopy,
|
||||
})
|
||||
posts = []*BlogPostView{}
|
||||
collectionYear = post.CreatedAt.Year()
|
||||
}
|
||||
|
||||
posts = append(posts, &BlogPostView{
|
||||
BlogPost: post,
|
||||
Author: author,
|
||||
})
|
||||
}
|
||||
|
||||
err = templates.BlogTemplate.Execute(w, BlogView{
|
||||
Collections: collections,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering blog post: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func ServeBlogPost(app *model.AppState, blogPostID string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
blog, err := controller.GetBlogPost(app.DB, blogPostID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !blog.Visible {
|
||||
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 || session.Account == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
author, err := controller.GetAccountByID(app.DB, blog.AuthorID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve author of blog %s: %v\n", blog.ID, err)
|
||||
}
|
||||
|
||||
// blog.Markdown += " <i class=\"end-mark\"></i>"
|
||||
|
||||
mdParser := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
|
||||
md := mdParser.Parse([]byte(blog.Markdown))
|
||||
blog.HTML = template.HTML(markdown.Render(md, mdRenderer))
|
||||
|
||||
comments := []*model.ThreadViewPost{}
|
||||
likeCount := 0
|
||||
boostCount := 0
|
||||
var blueskyURL string
|
||||
var blueskyPost *model.ThreadViewPost
|
||||
if blog.BlueskyActorID != nil && blog.BlueskyPostID != nil {
|
||||
blueskyPost, err = controller.FetchThreadViewPost(*blog.BlueskyActorID, *blog.BlueskyPostID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err)
|
||||
} else {
|
||||
comments = append(comments, blueskyPost.Replies...)
|
||||
likeCount += blueskyPost.Post.LikeCount
|
||||
boostCount += blueskyPost.Post.RepostCount
|
||||
blueskyURL = fmt.Sprintf("https://bsky.app/profile/%s/post/%s", blueskyPost.Post.Author.Handle, *blog.BlueskyPostID)
|
||||
}
|
||||
}
|
||||
|
||||
err = templates.BlogPostTemplate.Execute(w, BlogPostView{
|
||||
BlogPost: blog,
|
||||
Author: author,
|
||||
Comments: comments,
|
||||
Likes: likeCount,
|
||||
Boosts: boostCount,
|
||||
BlueskyURL: blueskyURL,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering blog post: %v\n", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
|
|||
err := templates.MusicGatewayTemplate.Execute(w, response)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)
|
||||
fmt.Fprintf(os.Stderr, "Error rendering music gateway for %s: %v\n", release.ID, err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
|||