Compare commits

..

20 commits

Author SHA1 Message Date
94774352b3
admin home: display latest release, not first (oops) 2025-07-19 00:01:01 +01:00
31a02a12a5
Merge branch 'dev' into feature/blog 2025-07-18 23:47:41 +01:00
bd2dc806d5
refactor: move music admin to /admin/music; keep /admin generic 2025-07-15 16:41:34 +01:00
ddbf3444eb
blog: reading mode fixes, add highight.js for codeblocks 2025-07-05 15:11:51 +01:00
faf6095d16
blog visitor frontend (pretty much) done! 2025-06-24 01:32:30 +01:00
3d64333b4f
blog sidebar, some cosmetic changes 2025-06-23 20:38:28 +01:00
3da0249555
Merge branch 'dev' into feature/blog 2025-06-22 18:05:50 +01:00
053faeb493
Merge branch 'dev' into feature/blog 2025-06-22 18:01:31 +01:00
0596edc4b2
blog: nice comment tweaks 2025-05-21 18:39:33 +01:00
fece0f5da6
blog: display bluesky like and repost counts 2025-05-21 18:38:50 +01:00
486c9ae641
Merge branch 'dev' into feature/blog 2025-05-05 17:57:43 +01:00
82a4cde8c9
Merge branch 'dev' into feature/blog 2025-04-30 18:22:21 +01:00
bc90015a33
Merge branch 'main' into feature/blog 2025-04-29 23:42:56 +01:00
dd8e503b61
Merge branch 'dev' into feature/blog 2025-04-29 23:26:41 +01:00
99e5eb290f
Merge branch 'dev' into feature/blog 2025-04-29 22:06:37 +01:00
0796ea8fde
blog: css tweaks 2025-04-29 22:04:19 +01:00
5aa241e4d6
improve comment callout 2025-04-03 00:00:16 +01:00
835dd344ca
style improvements and bluesky comments! 2025-04-02 23:49:20 +01:00
1a8dc4d9ce
basic blog page! 2025-04-02 23:04:09 +01:00
8eb432539c
consolidate view and views directories 2025-04-02 21:45:28 +01:00
95 changed files with 4039 additions and 930 deletions

View file

@ -1,22 +1,25 @@
package admin
package account
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"os"
"database/sql"
"fmt"
"net/http"
"net/url"
"os"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"golang.org/x/crypto/bcrypt"
"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("/totp-setup", totpSetupHandler(app))
mux.Handle("/totp-confirm", totpConfirmHandler(app))
mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app)))
@ -64,7 +67,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
session.Message = sessionMessage
session.Error = sessionError
err = accountTemplate.Execute(w, accountResponse{
err = templates.AccountTemplate.Execute(w, accountResponse{
Session: session,
TOTPs: totps,
})
@ -92,7 +95,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
currentPassword := r.Form.Get("current-password")
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil {
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -102,7 +105,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -111,7 +114,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -119,7 +122,7 @@ func changePasswordHandler(app *model.AppState) http.Handler {
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}
@ -147,7 +150,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Incorrect password.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -155,7 +158,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -184,7 +187,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session })
err := templates.TotpSetupTemplate.Execute(w, totpSetupData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -221,7 +224,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
err := templates.TotpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
if err != nil {
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -235,7 +238,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
}
err = totpConfirmTemplate.Execute(w, totpConfirmData{
err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{
Session: session,
TOTP: &totp,
NameEscaped: url.PathEscape(totp.Name),
@ -277,7 +280,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 {
@ -296,7 +299,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
if code != confirmCodeOffset {
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
err = totpConfirmTemplate.Execute(w, totpConfirmData{
err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{
Session: session,
TOTP: totp,
NameEscaped: url.PathEscape(totp.Name),
@ -314,7 +317,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
}
@ -322,7 +325,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)
})
}
@ -345,7 +348,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
if totp == nil {
@ -357,7 +360,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
if err != nil {
fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
return
}
@ -365,6 +368,6 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
controller.SetSessionError(app.DB, session, "")
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
http.Redirect(w, r, "/admin/account", http.StatusFound)
http.Redirect(w, r, "/admin/account/", http.StatusFound)
})
}

386
admin/auth/authhttp.go Normal file
View file

@ -0,0 +1,386 @@
package auth
import (
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"database/sql"
"fmt"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func RegisterAccountHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
type registerData struct {
Session *model.Session
}
render := func() {
err := templates.RegisterTemplate.Execute(w, registerData{ Session: session })
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{
Username: r.Form.Get("username"),
Email: r.Form.Get("email"),
Password: r.Form.Get("password"),
Invite: r.Form.Get("invite"),
}
// make sure invite code exists in DB
invite, err := controller.GetInvite(app.DB, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if invite == nil || time.Now().After(invite.ExpiresAt) {
if invite != nil {
err := controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
}
controller.SetSessionError(app.DB, session, "Invalid invite code.")
render()
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: sql.NullString{ String: credentials.Email, Valid: true },
AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
}
err = controller.CreateAccount(app.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
controller.SetSessionError(app.DB, session, "An account with that username already exists.")
render()
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
err = controller.DeleteInvite(app.DB, invite.Code)
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
}
// registration success!
controller.SetSessionAccount(app.DB, session, &account)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func LoginHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
type loginData struct {
Session *model.Session
}
render := func() {
err := templates.LoginTemplate.Execute(w, loginData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
render()
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if !r.Form.Has("username") || !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account == nil {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account.Locked {
controller.SetSessionError(app.DB, session, "This account is locked.")
render()
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, account, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
} else {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
}
render()
return
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if len(totps) > 0 {
err = controller.SetSessionAttemptAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin/totp", http.StatusFound)
return
}
// login success!
// TODO: log login activity to user
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
err = controller.SetSessionAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func LoginTOTPHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.AttemptAccount == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
type loginTOTPData struct {
Session *model.Session
}
render := func() {
err := templates.LoginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
r.ParseForm()
if !r.Form.Has("totp") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
totpCode := r.FormValue("totp")
if len(totpCode) != controller.TOTP_CODE_LENGTH {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if totpMethod == nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
controller.SetSessionAttemptAccount(app.DB, session, nil)
http.Redirect(w, r, "/admin", http.StatusFound)
} else {
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
}
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func LogoutHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteSession(app.DB, session.Token)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Expires: time.Now(),
Path: "/",
})
err = templates.LogoutTemplate.Execute(w, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
locked, err := controller.IncrementAccountFails(app.DB, account.ID)
if err != nil {
fmt.Fprintf(
os.Stderr,
"WARN: Failed to increment login failures for \"%s\": %v\n",
account.Username,
err,
)
app.Log.Warn(
log.TYPE_ACCOUNT,
"Failed to increment login failures for \"%s\"",
account.Username,
)
}
if locked {
app.Log.Warn(
log.TYPE_ACCOUNT,
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
account.Username,
model.MAX_LOGIN_FAIL_ATTEMPTS,
controller.ResolveIP(app, r),
)
}
return locked
}

View file

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

View file

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

View file

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

View file

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

View file

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

56
admin/core/funcs.go Normal file
View 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))
})
}

View file

@ -1,57 +1,38 @@
package admin
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fmt"
"net/http"
"os"
"path/filepath"
"arimelody-web/controller"
"arimelody-web/log"
"arimelody-web/model"
"golang.org/x/crypto/bcrypt"
"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/model"
)
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(http.StripPrefix("/account", accountHandler(app))))
mux.Handle("/logs", requireAccount(logsHandler(app)))
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app))))
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app))))
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(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.StripPrefix("/static", staticHandler()))
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 {
@ -63,39 +44,21 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
latestRelease, err := controller.GetLatestRelease(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
tracks, err := controller.GetOrphanTracks(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
fmt.Fprintf(os.Stderr, "WARN: Failed to pull latest release: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type IndexData struct {
Session *model.Session
Releases []*model.Release
Artists []*model.Artist
Tracks []*model.Track
Session *model.Session
LatestRelease *model.Release
}
err = indexTemplate.Execute(w, IndexData{
err = templates.IndexTemplate.Execute(w, IndexData{
Session: session,
Releases: releases,
Artists: artists,
Tracks: tracks,
LatestRelease: latestRelease,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
@ -105,361 +68,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
}
type registerData struct {
Session *model.Session
}
render := func() {
err := registerTemplate.Execute(w, registerData{ Session: session })
if err != nil {
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
credentials := RegisterRequest{
Username: r.Form.Get("username"),
Email: r.Form.Get("email"),
Password: r.Form.Get("password"),
Invite: r.Form.Get("invite"),
}
// make sure invite code exists in DB
invite, err := controller.GetInvite(app.DB, credentials.Invite)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if invite == nil || time.Now().After(invite.ExpiresAt) {
if invite != nil {
err := controller.DeleteInvite(app.DB, invite.Code)
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
}
controller.SetSessionError(app.DB, session, "Invalid invite code.")
render()
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
account := model.Account{
Username: credentials.Username,
Password: string(hashedPassword),
Email: sql.NullString{ String: credentials.Email, Valid: true },
AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true },
}
err = controller.CreateAccount(app.DB, &account)
if err != nil {
if strings.HasPrefix(err.Error(), "pq: duplicate key") {
controller.SetSessionError(app.DB, session, "An account with that username already exists.")
render()
return
}
fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
err = controller.DeleteInvite(app.DB, invite.Code)
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
}
// registration success!
controller.SetSessionAccount(app.DB, session, &account)
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func loginHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
type loginData struct {
Session *model.Session
}
render := func() {
err := loginTemplate.Execute(w, loginData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
if session.Account != nil {
// user is already logged in
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
render()
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if !r.Form.Has("username") || !r.Form.Has("password") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
account, err := controller.GetAccountByUsername(app.DB, username)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account == nil {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
render()
return
}
if account.Locked {
controller.SetSessionError(app.DB, session, "This account is locked.")
render()
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, account, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
} else {
controller.SetSessionError(app.DB, session, "Invalid username or password.")
}
render()
return
}
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if len(totps) > 0 {
err = controller.SetSessionAttemptAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin/totp", http.StatusFound)
return
}
// login success!
// TODO: log login activity to user
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
err = controller.SetSessionAccount(app.DB, session, account)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func loginTOTPHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
if session.AttemptAccount == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
type loginTOTPData struct {
Session *model.Session
}
render := func() {
err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
if r.Method == http.MethodGet {
render()
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
r.ParseForm()
if !r.Form.Has("totp") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
totpCode := r.FormValue("totp")
if len(totpCode) != controller.TOTP_CODE_LENGTH {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
render()
return
}
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
if totpMethod == nil {
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
controller.SetSessionAttemptAccount(app.DB, session, nil)
http.Redirect(w, r, "/admin", http.StatusFound)
} else {
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
}
render()
return
}
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
render()
return
}
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
}
controller.SetSessionMessage(app.DB, session, "")
controller.SetSessionError(app.DB, session, "")
http.Redirect(w, r, "/admin", http.StatusFound)
})
}
func logoutHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
session := r.Context().Value("session").(*model.Session)
err := controller.DeleteSession(app.DB, session.Token)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Expires: time.Now(),
Path: "/",
})
err = 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)
})
}
func staticHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
@ -480,63 +88,3 @@ func staticHandler() http.Handler {
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
})
}
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := controller.GetSessionFromRequest(app, r)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if session == nil {
// create a new session
session, err = controller.CreateSession(app.DB, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: model.COOKIE_TOKEN,
Value: session.Token,
Expires: session.ExpiresAt,
Secure: strings.HasPrefix(app.Config.BaseUrl, "https"),
HttpOnly: true,
Path: "/",
})
}
ctx := context.WithValue(r.Context(), "session", session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
locked, err := controller.IncrementAccountFails(app.DB, account.ID)
if err != nil {
fmt.Fprintf(
os.Stderr,
"WARN: Failed to increment login failures for \"%s\": %v\n",
account.Username,
err,
)
app.Log.Warn(
log.TYPE_ACCOUNT,
"Failed to increment login failures for \"%s\"",
account.Username,
)
}
if locked {
app.Log.Warn(
log.TYPE_ACCOUNT,
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
account.Username,
model.MAX_LOGIN_FAIL_ATTEMPTS,
controller.ResolveIP(app, r),
)
}
return locked
}

View file

@ -1,15 +1,16 @@
package admin
package logs
import (
"arimelody-web/log"
"arimelody-web/model"
"fmt"
"net/http"
"os"
"strings"
"arimelody-web/admin/templates"
"arimelody-web/log"
"arimelody-web/model"
"fmt"
"net/http"
"os"
"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)
@ -54,7 +55,7 @@ func logsHandler(app *model.AppState) http.Handler {
Logs []*log.Log
}
err = logsTemplate.Execute(w, LogsResponse{
err = templates.LogsTemplate.Execute(w, LogsResponse{
Session: session,
Logs: logs,
})

View file

@ -1,12 +1,13 @@
package admin
package music
import (
"fmt"
"net/http"
"strings"
"fmt"
"net/http"
"strings"
"arimelody-web/model"
"arimelody-web/controller"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
)
func serveArtist(app *model.AppState) http.Handler {
@ -39,7 +40,7 @@ func serveArtist(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
err = artistTemplate.Execute(w, ArtistResponse{
err = templates.ArtistTemplate.Execute(w, ArtistResponse{
Session: session,
Artist: artist,
Credits: credits,

69
admin/music/musichttp.go Normal file
View file

@ -0,0 +1,69 @@
package music
import (
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
"fmt"
"net/http"
"os"
)
func Handler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux := http.NewServeMux()
mux.Handle("/release/", http.StripPrefix("/release", serveRelease(app)))
mux.Handle("/artist/", http.StripPrefix("/artist", serveArtist(app)))
mux.Handle("/track/", http.StripPrefix("/track", serveTrack(app)))
mux.Handle("/", musicHandler(app))
mux.ServeHTTP(w, r)
})
}
func musicHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artists, err := controller.GetAllArtists(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
tracks, err := controller.GetOrphanTracks(app.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type MusicData struct {
Session *model.Session
Releases []*model.Release
Artists []*model.Artist
Tracks []*model.Track
}
err = templates.MusicTemplate.Execute(w, MusicData{
Session: session,
Releases: releases,
Artists: artists,
Tracks: tracks,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}

View file

@ -1,12 +1,13 @@
package admin
package music
import (
"fmt"
"net/http"
"strings"
"fmt"
"net/http"
"strings"
"arimelody-web/controller"
"arimelody-web/model"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
)
func serveRelease(app *model.AppState) http.Handler {
@ -60,7 +61,7 @@ func serveRelease(app *model.AppState) http.Handler {
Release *model.Release
}
err = releaseTemplate.Execute(w, ReleaseResponse{
err = templates.ReleaseTemplate.Execute(w, ReleaseResponse{
Session: session,
Release: release,
})
@ -74,7 +75,7 @@ func serveRelease(app *model.AppState) http.Handler {
func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := editCreditsTemplate.Execute(w, release)
err := templates.EditCreditsTemplate.Execute(w, release)
if err != nil {
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -97,7 +98,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = addCreditTemplate.Execute(w, response{
err = templates.AddCreditTemplate.Execute(w, response{
ReleaseID: release.ID,
Artists: artists,
})
@ -123,7 +124,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = newCreditTemplate.Execute(w, artist)
err = templates.NewCreditTemplate.Execute(w, artist)
if err != nil {
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -134,7 +135,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := editLinksTemplate.Execute(w, release)
err := templates.EditLinksTemplate.Execute(w, release)
if err != nil {
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -151,7 +152,7 @@ func serveEditTracks(release *model.Release) http.Handler {
Add func(a int, b int) int
}
err := editTracksTemplate.Execute(w, editTracksData{
err := templates.EditTracksTemplate.Execute(w, editTracksData{
Release: release,
Add: func(a, b int) int { return a + b },
})
@ -177,7 +178,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = addTrackTemplate.Execute(w, response{
err = templates.AddTrackTemplate.Execute(w, response{
ReleaseID: release.ID,
Tracks: tracks,
})
@ -204,7 +205,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
}
w.Header().Set("Content-Type", "text/html")
err = newTrackTemplate.Execute(w, track)
err = templates.NewTrackTemplate.Execute(w, track)
if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -1,12 +1,13 @@
package admin
package music
import (
"fmt"
"net/http"
"strings"
"fmt"
"net/http"
"strings"
"arimelody-web/model"
"arimelody-web/controller"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
)
func serveTrack(app *model.AppState) http.Handler {
@ -39,7 +40,7 @@ func serveTrack(app *model.AppState) http.Handler {
session := r.Context().Value("session").(*model.Session)
err = trackTemplate.Execute(w, TrackResponse{
err = templates.TrackTemplate.Execute(w, TrackResponse{
Session: session,
Track: track,
Releases: releases,

View file

@ -27,7 +27,10 @@ nav {
border-radius: 4px;
border: 1px solid #808080;
}
nav .icon {
.nav-item.icon {
padding: 0;
}
.nav-item.icon img {
height: 100%;
}
nav .title {
@ -92,6 +95,10 @@ code {
border-radius: 4px;
}
h1 {
margin: 0 0 .5em 0;
}
.card {

View file

@ -1,82 +1 @@
@import url("/admin/static/release-list-item.css");
.artist {
margin-bottom: .5em;
padding: .5em;
display: flex;
flex-direction: row;
align-items: center;
gap: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.artist:hover {
text-decoration: hover;
}
.artist-avatar {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 100%;
}
.track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.track p {
margin: 0;
}
.card h2.track-title {
margin: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.track-id {
width: fit-content;
font-family: "Monaspace Argon", monospace;
font-size: .8em;
font-style: italic;
line-height: 1em;
user-select: all;
}
.track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.track-album.empty {
color: #ff2020;
opacity: 1;
}
.track-description {
font-style: italic;
}
.track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.track .empty {
opacity: 0.75;
}

View file

@ -1,74 +0,0 @@
const newReleaseBtn = document.getElementById("create-release");
const newArtistBtn = document.getElementById("create-artist");
const newTrackBtn = document.getElementById("create-track");
newReleaseBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this release:");
if (id == null || id == "") return;
fetch("/api/v1/music", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
if (res.ok) location = "/admin/release/" + id;
else {
res.text().then(err => {
alert("Request failed: " + err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to create release. Check the console for details.");
console.error(err);
});
});
newArtistBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this artist:");
if (id == null || id == "") return;
fetch("/api/v1/artist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/artist/" + id;
} else {
alert("Request failed: " + text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create artist. Check the console for details.");
console.error(err);
});
});
newTrackBtn.addEventListener("click", event => {
event.preventDefault();
const title = prompt("Enter an title for this track:");
if (title == null || title == "") return;
fetch("/api/v1/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({title})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/track/" + text;
} else {
alert("Request failed: " + text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create track. Check the console for details.");
console.error(err);
});
});

82
admin/static/music.css Normal file
View 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
View file

@ -0,0 +1,74 @@
const newReleaseBtn = document.getElementById("create-release");
const newArtistBtn = document.getElementById("create-artist");
const newTrackBtn = document.getElementById("create-track");
newReleaseBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this release:");
if (id == null || id == "") return;
fetch("/api/v1/music", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
if (res.ok) location = "/admin/music/release/" + id;
else {
res.text().then(err => {
alert("Request failed: " + err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to create release. Check the console for details.");
console.error(err);
});
});
newArtistBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this artist:");
if (id == null || id == "") return;
fetch("/api/v1/artist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/music/artist/" + id;
} else {
alert("Request failed: " + text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create artist. Check the console for details.");
console.error(err);
});
});
newTrackBtn.addEventListener("click", event => {
event.preventDefault();
const title = prompt("Enter an title for this track:");
if (title == null || title == "") return;
fetch("/api/v1/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({title})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/music/track/" + text;
} else {
alert("Request failed: " + text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create track. Check the console for details.");
console.error(err);
});
});

View file

@ -1,125 +0,0 @@
package admin
import (
"arimelody-web/log"
"fmt"
"html/template"
"path/filepath"
"strings"
"time"
)
var indexTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "index.html"),
))
var loginTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "login.html"),
))
var loginTOTPTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "login-totp.html"),
))
var registerTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "register.html"),
))
var logoutTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "logout.html"),
))
var accountTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-account.html"),
))
var totpSetupTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "totp-setup.html"),
))
var totpConfirmTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "totp-confirm.html"),
))
var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{
"parseLevel": func(level log.LogLevel) string {
switch level {
case log.LEVEL_INFO:
return "INFO"
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)
},
"titleCase": func(logType string) string {
runes := []rune(logType)
for i, r := range runes {
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
runes[i] = r + ('A' - 'a')
}
}
return string(runes)
},
"lower": func(str string) string { return strings.ToLower(str) },
"prettyTime": func(t time.Time) string {
// return t.Format("2006-01-02 15:04:05")
// return t.Format("15:04:05, 2 Jan 2006")
return t.Format("02 Jan 2006, 15:04:05")
},
}).ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "logs.html"),
))
var releaseTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-release.html"),
))
var artistTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-artist.html"),
))
var trackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "edit-track.html"),
))
var editCreditsTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "credits", "editcredits.html"),
))
var addCreditTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "credits", "addcredit.html"),
))
var newCreditTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "credits", "newcredit.html"),
))
var editLinksTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "links", "editlinks.html"),
))
var editTracksTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "tracks", "edittracks.html"),
))
var addTrackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "tracks", "addtrack.html"),
))
var newTrackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "tracks", "newtrack.html"),
))

13
admin/templates/index.go Normal file
View file

@ -0,0 +1,13 @@
package templates
import (
"html/template"
"path/filepath"
)
var IndexTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "index.html"),
))

42
admin/templates/login.go Normal file
View file

@ -0,0 +1,42 @@
package templates
import (
"html/template"
"path/filepath"
)
var LoginTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "login.html"),
))
var LoginTOTPTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "login-totp.html"),
))
var RegisterTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "register.html"),
))
var LogoutTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "logout.html"),
))
var AccountTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "edit-account.html"),
))
var TotpSetupTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "totp-setup.html"),
))
var TotpConfirmTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "totp-confirm.html"),
))

41
admin/templates/logs.go Normal file
View file

@ -0,0 +1,41 @@
package templates
import (
"arimelody-web/log"
"fmt"
"html/template"
"path/filepath"
"strings"
"time"
)
var LogsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{
"parseLevel": func(level log.LogLevel) string {
switch level {
case log.LEVEL_INFO:
return "INFO"
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)
},
"titleCase": func(logType string) string {
runes := []rune(logType)
for i, r := range runes {
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
runes[i] = r + ('A' - 'a')
}
}
return string(runes)
},
"lower": func(str string) string { return strings.ToLower(str) },
"prettyTime": func(t time.Time) string {
// return t.Format("2006-01-02 15:04:05")
// return t.Format("15:04:05, 2 Jan 2006")
return t.Format("02 Jan 2006, 15:04:05")
},
}).ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "logs.html"),
))

54
admin/templates/music.go Normal file
View file

@ -0,0 +1,54 @@
package templates
import (
"html/template"
"path/filepath"
)
var MusicTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "music.html"),
))
var ReleaseTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "edit-release.html"),
))
var ArtistTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "views", "edit-artist.html"),
))
var TrackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("admin", "components", "release", "release-list-item.html"),
filepath.Join("admin", "views", "edit-track.html"),
))
var EditCreditsTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "credits", "editcredits.html"),
))
var AddCreditTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "credits", "addcredit.html"),
))
var NewCreditTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "credits", "newcredit.html"),
))
var EditLinksTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "links", "editlinks.html"),
))
var EditTracksTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "tracks", "edittracks.html"),
))
var AddTrackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "tracks", "addtrack.html"),
))
var NewTrackTemplate = template.Must(template.ParseFiles(
filepath.Join("admin", "components", "tracks", "newtrack.html"),
))

View file

@ -38,7 +38,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/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<h3 class="credit-name"><a href="/admin/music/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
<p class="artist-role">
Role: {{.Role}}

View file

@ -100,8 +100,8 @@
<div class="card-title">
<h2>Credits ({{len .Release.Credits}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/editcredits"
hx-get="/admin/release/{{.Release.ID}}/editcredits"
href="/admin/music/release/{{.Release.ID}}/editcredits"
hx-get="/admin/music/release/{{.Release.ID}}/editcredits"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
@ -111,7 +111,7 @@
<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/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-name"><a href="/admin/music/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-role">
{{.Role}}
{{if .Primary}}
@ -129,8 +129,8 @@
<div class="card-title">
<h2>Links ({{len .Release.Links}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/editlinks"
hx-get="/admin/release/{{.Release.ID}}/editlinks"
href="/admin/music/release/{{.Release.ID}}/editlinks"
hx-get="/admin/music/release/{{.Release.ID}}/editlinks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
@ -144,8 +144,8 @@
<div class="card-title" id="tracks">
<h2>Tracklist ({{len .Release.Tracks}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/edittracks"
hx-get="/admin/release/{{.Release.ID}}/edittracks"
href="/admin/music/release/{{.Release.ID}}/edittracks"
hx-get="/admin/music/release/{{.Release.ID}}/edittracks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
@ -155,7 +155,7 @@
<div class="track" data-id="{{$track.ID}}">
<h2 class="track-title">
<span class="track-number">{{.Add $i 1}}</span>
<a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a>
<a href="/admin/music/track/{{$track.ID}}">{{$track.Title}}</a>
</h2>
<h3>Description</h3>

View file

@ -6,65 +6,19 @@
{{define "content"}}
<main>
<h1>Admin Dashboard</h1>
<div class="card-title">
<h1>Releases</h1>
<a class="button new" id="create-release">Create New</a>
<h2>Music</h2>
<a class="button" href="/admin/music/">Browse All</a>
</div>
<div class="card releases">
{{range .Releases}}
{{block "release" .}}{{end}}
{{end}}
{{if not .Releases}}
<div class="card" id="music">
{{if .LatestRelease}}
<h3>Latest Release</h3>
{{block "release" .LatestRelease}}{{end}}
{{else}}
<p>There are no releases.</p>
{{end}}
</div>
<div class="card-title">
<h1>Artists</h1>
<a class="button new" id="create-artist">Create New</a>
</div>
<div class="card artists">
{{range $Artist := .Artists}}
<div class="artist">
<img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<a href="/admin/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a>
</div>
{{end}}
{{if not .Artists}}
<p>There are no artists.</p>
{{end}}
</div>
<div class="card-title">
<h1>Tracks</h1>
<a class="button new" id="create-track">Create New</a>
</div>
<div class="card tracks">
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
<br>
{{range $Track := .Tracks}}
<div class="track">
<h2 class="track-title">
<a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a>
</h2>
{{if $Track.Description}}
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}
{{if not .Artists}}
<p>There are no artists.</p>
{{end}}
</div>
</main>
<script type="module" src="/admin/static/admin.js"></script>

View file

@ -16,14 +16,19 @@
<body>
<header>
<nav>
<img src="/img/favicon.png" alt="" class="icon">
<div class="nav-item">
<a href="/">arimelody.me</a>
<div class="nav-item icon" title="return to arimelody.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>
@ -33,7 +38,7 @@
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
<a href="/admin/account/">account ({{.Session.Account.Username}})</a>
</div>
<div class="nav-item">
<a href="/admin/logout" id="logout">log out</a>

73
admin/views/music.html Normal file
View file

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

83
controller/blog.go Normal file
View file

@ -0,0 +1,83 @@
package controller
import (
"arimelody-web/model"
"github.com/jmoiron/sqlx"
)
func GetBlogPost(db *sqlx.DB, id string) (*model.BlogPost, error) {
var blog = model.BlogPost{}
err := db.Get(&blog, "SELECT * FROM blogpost WHERE id=$1", id)
if err != nil {
return nil, err
}
return &blog, nil
}
func GetBlogPosts(db *sqlx.DB, onlyVisible bool, limit int, offset int) ([]*model.BlogPost, error) {
var blogs = []*model.BlogPost{}
query := "SELECT * FROM blogpost ORDER BY created_at"
if onlyVisible {
query = "SELECT * FROM blogpost WHERE visible=true ORDER BY created_at"
}
var err error
if limit < 0 {
err = db.Select(&blogs, query)
} else {
err = db.Select(&blogs, query + " LIMIT $1 OFFSET $2", limit, offset)
}
if err != nil {
return nil, err
}
// for range 4 {
// blog := *blogs[len(blogs)-1]
// blog.CreatedAt = blog.CreatedAt.Add(time.Hour * -5000)
// blogs = append(blogs, &blog)
// }
return blogs, nil
}
func CreateBlogPost(db *sqlx.DB, post *model.BlogPost) error {
_, err := db.Exec(
"INSERT INTO blogpost (id,title,description,visible,author,markdown,html,bluesky_actor,bluesky_post) " +
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)",
post.ID,
post.Title,
post.Description,
post.Visible,
post.AuthorID,
post.Markdown,
post.HTML,
post.BlueskyActorID,
post.BlueskyPostID,
)
return err
}
func UpdateBlogPost(db *sqlx.DB, postID string, post *model.BlogPost) error {
_, err := db.Exec(
"UPDATE blogpost SET " +
"id=$2,title=$3,description=$4,visible=$5,author=$6,markdown=$7,html=$8,bluesky_actor=$9,bluesky_post=$10,modified_at=CURRENT_TIMESTAMP " +
"WHERE id=$1",
postID,
post.ID,
post.Title,
post.Description,
post.Visible,
post.AuthorID,
post.Markdown,
post.HTML,
post.BlueskyActorID,
post.BlueskyPostID,
)
return err
}

48
controller/bluesky.go Normal file
View file

@ -0,0 +1,48 @@
package controller
import (
"arimelody-web/model"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
const BSKY_API_BASE = "https://public.api.bsky.app"
func FetchThreadViewPost(actorID string, postID string) (*model.ThreadViewPost, error) {
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", actorID, postID)
req, err := http.NewRequest(
http.MethodGet,
strings.Join([]string{BSKY_API_BASE, "xrpc", "app.bsky.feed.getPostThread"}, "/"),
nil,
)
if err != nil { panic(err) }
req.URL.RawQuery = url.Values{
"uri": { uri },
}.Encode()
req.Header.Set("User-Agent", "ari melody [https://arimelody.me]")
req.Header.Set("Accept", "application/json")
client := &http.Client{}
res, err := client.Do(req)
if err != nil {
return nil, errors.New(fmt.Sprintf("Failed to call Bluesky API: %v", err))
}
type Data struct {
Thread model.ThreadViewPost `json:"thread"`
}
data := Data{}
err = json.NewDecoder(res.Body).Decode(&data)
if err != nil {
return nil, errors.New(fmt.Sprintf("Invalid response from server: %v", err))
}
return &data.Thread, nil
}

View file

@ -8,7 +8,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")
@ -49,6 +49,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
ApplyMigration(db, "003-fail-lock")
oldDBVersion = 4
case 4:
ApplyMigration(db, "004-blog")
oldDBVersion = 5
}
}

View file

@ -1,12 +1,13 @@
package controller
import (
"errors"
"fmt"
"errors"
"fmt"
"strings"
"arimelody-web/model"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx"
)
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
@ -101,6 +102,47 @@ func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*mod
return releases, nil
}
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, errors.New(fmt.Sprintf("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, errors.New(fmt.Sprintf("Tracks: %s", err))
}
for _, track := range tracks {
release.Tracks = append(release.Tracks, track)
}
// get links
links, err := GetReleaseLinks(db, release.ID)
if err != nil {
return nil, errors.New(fmt.Sprintf("Links: %s", err))
}
for _, link := range links {
release.Links = append(release.Links, link)
}
return &release, nil
}
func CreateRelease(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec(
"INSERT INTO musicrelease "+

1
go.mod
View file

@ -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
View file

@ -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=

View file

@ -525,6 +525,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.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
mux.Handle("/cursor-ws", cursor.Handler(app))
mux.Handle("/", view.IndexHandler(app))

51
model/blog.go Normal file
View 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
View 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(),
)
}

BIN
public/img/aridoodle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

15
public/script/blog.js Normal file
View 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
View 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
View 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
View 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;
}
}

View file

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

View file

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

View file

@ -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
View 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
View 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
View file

@ -0,0 +1,45 @@
# Highlight.js CDN Assets
[![install size](https://packagephobia.now.sh/badge?p=highlight.js)](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, doesnt 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

File diff suppressed because one or more lines are too long

View 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)})();

View 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)})();

View 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)
})();

File diff suppressed because one or more lines are too long

View 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)
})();

View 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)})();

View 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)})();

View 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)})();

View 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)})();

View 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)})();

View 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)
})();

View 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)})();

View 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)})();

File diff suppressed because one or more lines are too long

View 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)})();

View 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)})();

View 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)})();

View file

@ -0,0 +1,5 @@
*.css
*.jpg
*.png
base16
!ari.css

109
public/vendor/highlight/styles/ari.css vendored Normal file
View 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
}

View file

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

View 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;

View file

@ -6,23 +6,37 @@ import (
)
var IndexTemplate = template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "index.html"),
filepath.Join("view", "layout.html"),
filepath.Join("view", "header.html"),
filepath.Join("view", "footer.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("view", "index.html"),
))
var MusicTemplate = template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music.html"),
filepath.Join("view", "layout.html"),
filepath.Join("view", "header.html"),
filepath.Join("view", "footer.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("view", "music.html"),
))
var MusicGatewayTemplate = template.Must(template.ParseFiles(
filepath.Join("views", "layout.html"),
filepath.Join("views", "header.html"),
filepath.Join("views", "footer.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("views", "music-gateway.html"),
filepath.Join("view", "layout.html"),
filepath.Join("view", "header.html"),
filepath.Join("view", "footer.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("view", "music-gateway.html"),
))
var BlogTemplate = template.Must(template.ParseFiles(
filepath.Join("view", "layout.html"),
filepath.Join("view", "header.html"),
filepath.Join("view", "footer.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("view", "blog.html"),
))
var BlogPostTemplate = template.Must(template.ParseFiles(
filepath.Join("view", "layout.html"),
filepath.Join("view", "header.html"),
filepath.Join("view", "footer.html"),
filepath.Join("view", "prideflag.html"),
filepath.Join("view", "blogpost.html"),
))

185
view/blog.go Normal file
View 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
}
})
}

51
view/blog.html Normal file
View 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">&bull; {{.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
view/blogpost.html Normal file
View 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">&bull; 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>
&bull;
<span>{{.Post.RepostCount}} boost{{if ne .Post.RepostCount 1}}s{{end}}</span>
&bull;
</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}}

View file

@ -21,11 +21,11 @@
<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! -->

View file

@ -241,9 +241,6 @@
<a href="https://isabelroses.com/">
<img src="/img/buttons/isabelroses.gif" alt="isabel roses web button" width="88" height="31">
</a>
<a href="https://bubblegum.girlonthemoon.xyz/">
<img src="/img/buttons/girlonthemoon.png" alt="sweet like bubblegum web button" width="88" height="31">
</a>
<hr>

View file

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