home to your local SPACEGIRL 💫 arimelody.space
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'main' from remote

+2003 -1106
+2 -2
.air.toml
··· 7 7 bin = "./tmp/main" 8 8 cmd = "go build -o ./tmp/main ." 9 9 delay = 1000 10 - exclude_dir = ["admin/static", "admin\\static", "public", "uploads", "test", "db", "res"] 10 + exclude_dir = ["uploads", "test", "db", "res"] 11 11 exclude_file = [] 12 12 exclude_regex = ["_test.go"] 13 13 exclude_unchanged = false 14 14 follow_symlink = false 15 15 full_bin = "" 16 16 include_dir = [] 17 - include_ext = ["go", "tpl", "tmpl", "html"] 17 + include_ext = ["go", "tpl", "tmpl", "html", "css"] 18 18 include_file = [] 19 19 kill_delay = "0s" 20 20 log = "build-errors.log"
+50
.forgejo/workflows/push-prod.yaml
··· 1 + on: 2 + push: 3 + branches: 4 + - main 5 + 6 + env: 7 + EXEC: arimelody-web 8 + REMOTE: ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 9 + PORT: ${{ secrets.SSH_PORT }} 10 + 11 + jobs: 12 + deploy: 13 + runs-on: docker 14 + steps: 15 + - name: Checkout repository 16 + uses: actions/checkout@v4 17 + 18 + - name: Set up Go 19 + uses: actions/setup-go@v4 20 + with: 21 + go-version: '^1.25.1' 22 + 23 + - name: Run tests 24 + run: go test -v ./model 25 + 26 + - name: Build binary 27 + run: make build 28 + 29 + - name: Bundle tarball 30 + run: make bundle 31 + 32 + - name: Set up SSH keys 33 + uses: webfactory/ssh-agent@v0.9.0 34 + with: 35 + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 36 + 37 + - name: Copy to production server 38 + run: | 39 + ssh-keyscan -p $PORT ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 40 + scp -P $PORT ./$EXEC.tar.gz $REMOTE:~/ 41 + 42 + - name: Restart production 43 + run: | 44 + ssh -o StrictHostKeyChecking=no $REMOTE -p $PORT << EOT 45 + cd ${{ secrets.DEPLOY_DIR }} 46 + tar xzf ~/$EXEC.tar.gz 47 + /bin/bash ~/restart.sh 48 + rm ~/$EXEC.tar.gz 49 + EOT 50 +
+3 -3
Makefile
··· 2 2 3 3 .PHONY: $(EXEC) 4 4 5 - $(EXEC): 5 + build: 6 6 GOOS=linux GOARCH=amd64 go build -o $(EXEC) 7 7 8 - bundle: $(EXEC) 9 - tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/ 8 + bundle: build 9 + tar czf $(EXEC).tar.gz --exclude ".DS_Store" $(EXEC) admin/static/ public/ 10 10 11 11 clean: 12 12 rm $(EXEC) $(EXEC).tar.gz
+49 -51
admin/accounthttp.go
··· 1 1 package admin 2 2 3 3 import ( 4 - "database/sql" 5 - "fmt" 6 - "net/http" 7 - "net/url" 8 - "os" 4 + "database/sql" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "os" 9 9 10 - "arimelody-web/controller" 11 - "arimelody-web/log" 12 - "arimelody-web/model" 10 + "arimelody-web/admin/templates" 11 + "arimelody-web/controller" 12 + "arimelody-web/log" 13 + "arimelody-web/model" 13 14 14 - "golang.org/x/crypto/bcrypt" 15 + "golang.org/x/crypto/bcrypt" 15 16 ) 16 17 17 18 func accountHandler(app *model.AppState) http.Handler { 18 19 mux := http.NewServeMux() 19 20 20 - mux.Handle("/totp-setup", totpSetupHandler(app)) 21 - mux.Handle("/totp-confirm", totpConfirmHandler(app)) 22 - mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) 21 + mux.Handle("/account/totp-setup", totpSetupHandler(app)) 22 + mux.Handle("/account/totp-confirm", totpConfirmHandler(app)) 23 + mux.Handle("/account/totp-delete", totpDeleteHandler(app)) 23 24 24 - mux.Handle("/password", changePasswordHandler(app)) 25 - mux.Handle("/delete", deleteAccountHandler(app)) 25 + mux.Handle("/account/password", changePasswordHandler(app)) 26 + mux.Handle("/account/delete", deleteAccountHandler(app)) 26 27 27 28 return mux 28 29 } ··· 44 45 } 45 46 46 47 accountResponse struct { 47 - Session *model.Session 48 + adminPageData 48 49 TOTPs []TOTP 49 50 } 50 51 ) ··· 64 65 session.Message = sessionMessage 65 66 session.Error = sessionError 66 67 67 - err = accountTemplate.Execute(w, accountResponse{ 68 - Session: session, 68 + err = templates.AccountTemplate.Execute(w, accountResponse{ 69 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 69 70 TOTPs: totps, 70 71 }) 71 72 if err != nil { ··· 169 170 } 170 171 171 172 type totpConfirmData struct { 172 - Session *model.Session 173 + adminPageData 173 174 TOTP *model.TOTP 174 175 NameEscaped string 175 176 QRBase64Image string ··· 178 179 func totpSetupHandler(app *model.AppState) http.Handler { 179 180 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 180 181 if r.Method == http.MethodGet { 181 - type totpSetupData struct { 182 - Session *model.Session 183 - } 184 - 185 182 session := r.Context().Value("session").(*model.Session) 186 183 187 - err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session }) 184 + err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session }) 188 185 if err != nil { 189 186 fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) 190 187 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 221 218 if err != nil { 222 219 fmt.Printf("WARN: Failed to create TOTP method: %s\n", err) 223 220 controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 224 - err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session }) 221 + err := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{ 222 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 223 + }) 225 224 if err != nil { 226 225 fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) 227 226 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 235 234 fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) 236 235 } 237 236 238 - err = totpConfirmTemplate.Execute(w, totpConfirmData{ 239 - Session: session, 237 + err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ 238 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 240 239 TOTP: &totp, 241 240 NameEscaped: url.PathEscape(totp.Name), 242 241 QRBase64Image: qrBase64Image, ··· 267 266 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 268 267 return 269 268 } 270 - code := r.FormValue("totp") 271 - if len(code) != controller.TOTP_CODE_LENGTH { 272 - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 273 - return 274 - } 275 269 276 270 totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) 277 271 if err != nil { ··· 291 285 fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) 292 286 } 293 287 288 + code := r.FormValue("totp") 294 289 confirmCode := controller.GenerateTOTP(totp.Secret, 0) 295 - if code != confirmCode { 296 - confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) 297 - if code != confirmCodeOffset { 298 - session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } 299 - err = totpConfirmTemplate.Execute(w, totpConfirmData{ 300 - Session: session, 301 - TOTP: totp, 302 - NameEscaped: url.PathEscape(totp.Name), 303 - QRBase64Image: qrBase64Image, 304 - }) 305 - if err != nil { 306 - fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err) 307 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 308 - } 309 - return 290 + confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) 291 + if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) { 292 + session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } 293 + err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ 294 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 295 + TOTP: totp, 296 + NameEscaped: url.PathEscape(totp.Name), 297 + QRBase64Image: qrBase64Image, 298 + }) 299 + if err != nil { 300 + fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err) 301 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 310 302 } 303 + return 311 304 } 312 305 313 306 err = controller.ConfirmTOTP(app.DB, session.Account.ID, name) ··· 328 321 329 322 func totpDeleteHandler(app *model.AppState) http.Handler { 330 323 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 331 - if r.Method != http.MethodGet { 324 + if r.Method != http.MethodPost { 332 325 http.NotFound(w, r) 333 326 return 334 327 } 335 328 336 - if len(r.URL.Path) < 2 { 329 + session := r.Context().Value("session").(*model.Session) 330 + 331 + err := r.ParseForm() 332 + if err != nil { 337 333 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 338 334 return 339 335 } 340 - name := r.URL.Path[1:] 341 - 342 - session := r.Context().Value("session").(*model.Session) 336 + name := r.FormValue("totp-name") 337 + if len(name) == 0 { 338 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 339 + return 340 + } 343 341 344 342 totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) 345 343 if err != nil {
+51 -18
admin/artisthttp.go
··· 1 1 package admin 2 2 3 3 import ( 4 - "fmt" 5 - "net/http" 6 - "strings" 4 + "fmt" 5 + "net/http" 6 + "strings" 7 7 8 - "arimelody-web/model" 9 - "arimelody-web/controller" 8 + "arimelody-web/admin/templates" 9 + "arimelody-web/controller" 10 + "arimelody-web/model" 10 11 ) 11 12 12 - func serveArtist(app *model.AppState) http.Handler { 13 + func serveArtists(app *model.AppState) http.Handler { 14 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 + session := r.Context().Value("session").(*model.Session) 16 + 17 + slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/artists")[1:], "/") 18 + artistID := slices[0] 19 + 20 + if len(artistID) > 0 { 21 + serveArtist(app, artistID).ServeHTTP(w, r) 22 + return 23 + } 24 + 25 + artists, err := controller.GetAllArtists(app.DB) 26 + if err != nil { 27 + fmt.Printf("WARN: Failed to fetch artists: %s\n", err) 28 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + type ArtistsResponse struct { 33 + adminPageData 34 + Artists []*model.Artist 35 + } 36 + 37 + err = templates.ArtistsTemplate.Execute(w, ArtistsResponse{ 38 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 39 + Artists: artists, 40 + }) 41 + if err != nil { 42 + fmt.Printf("WARN: Failed to serve admin artists page: %s\n", err) 43 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 44 + } 45 + }) 46 + } 47 + 48 + func serveArtist(app *model.AppState, artistID string) http.Handler { 13 49 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 - slices := strings.Split(r.URL.Path[1:], "/") 15 - id := slices[0] 16 - artist, err := controller.GetArtist(app.DB, id) 50 + session := r.Context().Value("session").(*model.Session) 51 + 52 + artist, err := controller.GetArtist(app.DB, artistID) 17 53 if err != nil { 18 54 if artist == nil { 19 55 http.NotFound(w, r) 20 56 return 21 57 } 22 - fmt.Printf("Error rendering admin artist page for %s: %s\n", id, err) 58 + fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err) 23 59 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 24 60 return 25 61 } 26 62 27 63 credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) 28 64 if err != nil { 29 - fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) 65 + fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) 30 66 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 31 67 return 32 68 } 33 69 34 70 type ArtistResponse struct { 35 - Session *model.Session 71 + adminPageData 36 72 Artist *model.Artist 37 73 Credits []*model.Credit 38 74 } 39 75 40 - session := r.Context().Value("session").(*model.Session) 41 - 42 - err = artistTemplate.Execute(w, ArtistResponse{ 43 - Session: session, 76 + err = templates.EditArtistTemplate.Execute(w, ArtistResponse{ 77 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 44 78 Artist: artist, 45 79 Credits: credits, 46 80 }) 47 81 if err != nil { 48 - fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) 82 + fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) 49 83 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 50 84 } 51 85 }) 52 86 } 53 -
+1 -1
admin/components/credits/addcredit.html admin/templates/html/components/credit/addcredit.html
··· 7 7 {{range $Artist := .Artists}} 8 8 <li class="new-artist" 9 9 data-id="{{$Artist.ID}}" 10 - hx-get="/admin/release/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}" 10 + hx-get="/admin/releases/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}" 11 11 hx-target="#editcredits ul" 12 12 hx-swap="beforeend" 13 13 >
+2 -2
admin/components/credits/editcredits.html admin/templates/html/components/credit/editcredits.html
··· 3 3 <h2>Editing: Credits</h2> 4 4 <a id="add-credit" 5 5 class="button new" 6 - href="/admin/release/{{.ID}}/addcredit" 7 - hx-get="/admin/release/{{.ID}}/addcredit" 6 + href="/admin/releases/{{.ID}}/addcredit" 7 + hx-get="/admin/releases/{{.ID}}/addcredit" 8 8 hx-target="body" 9 9 hx-swap="beforeend" 10 10 >Add</a>
admin/components/credits/newcredit.html admin/templates/html/components/credit/newcredit.html
admin/components/links/editlinks.html admin/templates/html/components/link/editlinks.html
+3 -3
admin/components/release/release-list-item.html admin/templates/html/components/release/release.html
··· 5 5 </div> 6 6 <div class="release-info"> 7 7 <h3 class="release-title"> 8 - <a href="/admin/release/{{.ID}}">{{.Title}}</a> 8 + <a href="/admin/releases/{{.ID}}">{{.Title}}</a> 9 9 <small> 10 10 <span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span> 11 11 {{if not .Visible}}(hidden){{end}} ··· 13 13 </h3> 14 14 <p class="release-artists">{{.PrintArtists true true}}</p> 15 15 <p class="release-type-single">{{.ReleaseType}} 16 - (<a href="/admin/release/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p> 16 + (<a href="/admin/releases/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p> 17 17 <div class="release-actions"> 18 - <a href="/admin/release/{{.ID}}">Edit</a> 18 + <a href="/admin/releases/{{.ID}}">Edit</a> 19 19 <a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a> 20 20 </div> 21 21 </div>
+1 -1
admin/components/tracks/addtrack.html admin/templates/html/components/track/addtrack.html
··· 8 8 </li> 9 9 <li class="new-track" 10 10 data-id="{{$Track.ID}}" 11 - hx-get="/admin/release/{{$.ReleaseID}}/newtrack/{{$Track.ID}}" 11 + hx-get="/admin/releases/{{$.ReleaseID}}/newtrack/{{$Track.ID}}" 12 12 hx-target="#edittracks ul" 13 13 hx-swap="beforeend" 14 14 >
+2 -2
admin/components/tracks/edittracks.html admin/templates/html/components/track/edittracks.html
··· 3 3 <h2>Editing: Tracks</h2> 4 4 <a id="add-track" 5 5 class="button new" 6 - href="/admin/release/{{.Release.ID}}/addtrack" 7 - hx-get="/admin/release/{{.Release.ID}}/addtrack" 6 + href="/admin/releases/{{.Release.ID}}/addtrack" 7 + hx-get="/admin/releases/{{.Release.ID}}/addtrack" 8 8 hx-target="body" 9 9 hx-swap="beforeend" 10 10 >Add</a>
admin/components/tracks/newtrack.html admin/templates/html/components/track/newtrack.html
+84 -51
admin/http.go
··· 1 1 package admin 2 2 3 3 import ( 4 - "context" 5 - "database/sql" 6 - "fmt" 7 - "net/http" 8 - "os" 9 - "path/filepath" 10 - "strings" 11 - "time" 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "net/http" 8 + "os" 9 + "strings" 10 + "time" 12 11 13 - "arimelody-web/controller" 14 - "arimelody-web/log" 15 - "arimelody-web/model" 12 + "arimelody-web/admin/templates" 13 + "arimelody-web/controller" 14 + "arimelody-web/log" 15 + "arimelody-web/model" 16 + "arimelody-web/view" 16 17 17 - "golang.org/x/crypto/bcrypt" 18 + "golang.org/x/crypto/bcrypt" 18 19 ) 20 + 21 + type adminPageData struct { 22 + Path string 23 + Session *model.Session 24 + } 19 25 20 26 func Handler(app *model.AppState) http.Handler { 21 27 mux := http.NewServeMux() ··· 38 44 mux.Handle("/register", registerAccountHandler(app)) 39 45 40 46 mux.Handle("/account", requireAccount(accountIndexHandler(app))) 41 - mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app)))) 47 + mux.Handle("/account/", requireAccount(accountHandler(app))) 42 48 43 49 mux.Handle("/logs", requireAccount(logsHandler(app))) 44 50 45 - mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app)))) 46 - mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app)))) 47 - mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app)))) 51 + mux.Handle("/releases", requireAccount(serveReleases(app))) 52 + mux.Handle("/releases/", requireAccount(serveReleases(app))) 53 + mux.Handle("/artists", requireAccount(serveArtists(app))) 54 + mux.Handle("/artists/", requireAccount(serveArtists(app))) 55 + mux.Handle("/tracks", requireAccount(serveTracks(app))) 56 + mux.Handle("/tracks/", requireAccount(serveTracks(app))) 48 57 49 - mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) 58 + mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 + if r.URL.Path == "/static/admin.css" { 60 + http.ServeFile(w, r, "./admin/static/admin.css") 61 + return 62 + } 63 + if r.URL.Path == "/static/admin.js" { 64 + http.ServeFile(w, r, "./admin/static/admin.js") 65 + return 66 + } 67 + requireAccount( 68 + http.StripPrefix("/static", 69 + view.ServeFiles("./admin/static"))).ServeHTTP(w, r) 70 + })) 50 71 51 72 mux.Handle("/", requireAccount(AdminIndexHandler(app))) 52 73 ··· 63 84 64 85 session := r.Context().Value("session").(*model.Session) 65 86 66 - releases, err := controller.GetAllReleases(app.DB, false, 0, true) 87 + releases, err := controller.GetAllReleases(app.DB, false, 3, true) 67 88 if err != nil { 68 89 fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) 69 90 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 70 91 return 71 92 } 93 + releaseCount, err := controller.GetReleaseCount(app.DB, false) 94 + if err != nil { 95 + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err) 96 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 97 + return 98 + } 72 99 73 100 artists, err := controller.GetAllArtists(app.DB) 74 101 if err != nil { ··· 76 103 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 77 104 return 78 105 } 106 + artistCount, err := controller.GetArtistCount(app.DB) 107 + if err != nil { 108 + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err) 109 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 110 + return 111 + } 79 112 80 113 tracks, err := controller.GetOrphanTracks(app.DB) 81 114 if err != nil { ··· 83 116 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 84 117 return 85 118 } 119 + trackCount, err := controller.GetTrackCount(app.DB) 120 + if err != nil { 121 + fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err) 122 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 123 + return 124 + } 86 125 87 126 type IndexData struct { 88 - Session *model.Session 89 - Releases []*model.Release 90 - Artists []*model.Artist 91 - Tracks []*model.Track 127 + adminPageData 128 + Releases []*model.Release 129 + ReleaseCount int 130 + Artists []*model.Artist 131 + ArtistCount int 132 + Tracks []*model.Track 133 + TrackCount int 92 134 } 93 135 94 - err = indexTemplate.Execute(w, IndexData{ 95 - Session: session, 136 + err = templates.IndexTemplate.Execute(w, IndexData{ 137 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 96 138 Releases: releases, 139 + ReleaseCount: releaseCount, 97 140 Artists: artists, 141 + ArtistCount: artistCount, 98 142 Tracks: tracks, 143 + TrackCount: trackCount, 99 144 }) 100 145 if err != nil { 101 146 fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) ··· 115 160 return 116 161 } 117 162 118 - type registerData struct { 119 - Session *model.Session 120 - } 121 - 122 163 render := func() { 123 - err := registerTemplate.Execute(w, registerData{ Session: session }) 164 + err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session }) 124 165 if err != nil { 125 166 fmt.Printf("WARN: Error rendering create account page: %s\n", err) 126 167 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 225 266 226 267 session := r.Context().Value("session").(*model.Session) 227 268 228 - type loginData struct { 229 - Session *model.Session 230 - } 231 - 232 269 render := func() { 233 - err := loginTemplate.Execute(w, loginData{ Session: session }) 270 + err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session }) 234 271 if err != nil { 235 272 fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) 236 273 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 341 378 return 342 379 } 343 380 344 - type loginTOTPData struct { 345 - Session *model.Session 346 - } 347 - 348 381 render := func() { 349 - err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session }) 382 + err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session }) 350 383 if err != nil { 351 384 fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) 352 385 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 440 473 Path: "/", 441 474 }) 442 475 443 - err = logoutTemplate.Execute(w, nil) 476 + err = templates.LogoutTemplate.Execute(w, nil) 444 477 if err != nil { 445 478 fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err) 446 479 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 460 493 }) 461 494 } 462 495 496 + /* 497 + //go:embed "static" 498 + var staticFS embed.FS 499 + 463 500 func staticHandler() http.Handler { 464 501 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 465 - info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path))) 466 - // does the file exist? 502 + uri := strings.TrimPrefix(r.URL.Path, "/static") 503 + file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri))) 467 504 if err != nil { 468 - if os.IsNotExist(err) { 469 - http.NotFound(w, r) 470 - return 471 - } 472 - } 473 - 474 - // is thjs a directory? (forbidden) 475 - if info.IsDir() { 476 505 http.NotFound(w, r) 477 506 return 478 507 } 479 508 480 - http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r) 509 + w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path))) 510 + w.WriteHeader(http.StatusOK) 511 + 512 + w.Write(file) 481 513 }) 482 514 } 515 + */ 483 516 484 517 func enforceSession(app *model.AppState, next http.Handler) http.Handler { 485 518 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+10 -9
admin/logshttp.go
··· 1 1 package admin 2 2 3 3 import ( 4 - "arimelody-web/log" 5 - "arimelody-web/model" 6 - "fmt" 7 - "net/http" 8 - "os" 9 - "strings" 4 + "arimelody-web/admin/templates" 5 + "arimelody-web/log" 6 + "arimelody-web/model" 7 + "fmt" 8 + "net/http" 9 + "os" 10 + "strings" 10 11 ) 11 12 12 13 func logsHandler(app *model.AppState) http.Handler { ··· 50 51 } 51 52 52 53 type LogsResponse struct { 53 - Session *model.Session 54 + adminPageData 54 55 Logs []*log.Log 55 56 } 56 57 57 - err = logsTemplate.Execute(w, LogsResponse{ 58 - Session: session, 58 + err = templates.LogsTemplate.Execute(w, LogsResponse{ 59 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 59 60 Logs: logs, 60 61 }) 61 62 if err != nil {
+80 -41
admin/releasehttp.go
··· 1 1 package admin 2 2 3 3 import ( 4 - "fmt" 5 - "net/http" 6 - "strings" 4 + "fmt" 5 + "net/http" 6 + "os" 7 + "strings" 7 8 8 - "arimelody-web/controller" 9 - "arimelody-web/model" 9 + "arimelody-web/admin/templates" 10 + "arimelody-web/controller" 11 + "arimelody-web/model" 10 12 ) 11 13 12 - func serveRelease(app *model.AppState) http.Handler { 14 + func serveReleases(app *model.AppState) http.Handler { 13 15 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 - slices := strings.Split(r.URL.Path[1:], "/") 16 + session := r.Context().Value("session").(*model.Session) 17 + 18 + slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/") 15 19 releaseID := slices[0] 16 20 21 + var action string = "" 22 + if len(slices) > 1 { 23 + action = slices[1] 24 + } 25 + 26 + if len(releaseID) > 0 { 27 + serveRelease(app, releaseID, action).ServeHTTP(w, r) 28 + return 29 + } 30 + 31 + type ReleasesData struct { 32 + adminPageData 33 + Releases []*model.Release 34 + } 35 + 36 + releases, err := controller.GetAllReleases(app.DB, false, 0, true) 37 + if err != nil { 38 + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err) 39 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 40 + return 41 + } 42 + 43 + err = templates.ReleasesTemplate.Execute(w, ReleasesData{ 44 + adminPageData: adminPageData{ 45 + Path: r.URL.Path, 46 + Session: session, 47 + }, 48 + Releases: releases, 49 + }) 50 + if err != nil { 51 + fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err) 52 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 53 + return 54 + } 55 + }) 56 + } 57 + 58 + func serveRelease(app *model.AppState, releaseID string, action string) http.Handler { 59 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 60 session := r.Context().Value("session").(*model.Session) 18 61 19 62 release, err := controller.GetRelease(app.DB, releaseID, true) ··· 22 65 http.NotFound(w, r) 23 66 return 24 67 } 25 - fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err) 68 + fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err) 26 69 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 27 70 return 28 71 } 29 72 30 - if len(slices) > 1 { 31 - switch slices[1] { 73 + if len(action) > 0 { 74 + switch action { 32 75 case "editcredits": 33 76 serveEditCredits(release).ServeHTTP(w, r) 34 77 return ··· 56 99 } 57 100 58 101 type ReleaseResponse struct { 59 - Session *model.Session 102 + adminPageData 60 103 Release *model.Release 61 104 } 62 105 63 - err = releaseTemplate.Execute(w, ReleaseResponse{ 64 - Session: session, 106 + for i, track := range release.Tracks { 107 + track.Number = i + 1 108 + } 109 + 110 + err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{ 111 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 65 112 Release: release, 66 113 }) 67 114 if err != nil { 68 - fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err) 115 + fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err) 69 116 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 70 117 } 71 118 }) ··· 74 121 func serveEditCredits(release *model.Release) http.Handler { 75 122 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 76 123 w.Header().Set("Content-Type", "text/html") 77 - err := editCreditsTemplate.Execute(w, release) 124 + err := templates.EditCreditsTemplate.Execute(w, release) 78 125 if err != nil { 79 - fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) 126 + fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err) 80 127 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 81 128 } 82 129 }) ··· 86 133 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 87 134 artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) 88 135 if err != nil { 89 - fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err) 136 + fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err) 90 137 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 91 138 return 92 139 } ··· 97 144 } 98 145 99 146 w.Header().Set("Content-Type", "text/html") 100 - err = addCreditTemplate.Execute(w, response{ 147 + err = templates.AddCreditTemplate.Execute(w, response{ 101 148 ReleaseID: release.ID, 102 149 Artists: artists, 103 150 }) 104 151 if err != nil { 105 - fmt.Printf("Error rendering add credits component for %s: %s\n", release.ID, err) 152 + fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err) 106 153 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 107 154 } 108 155 }) ··· 113 160 artistID := strings.Split(r.URL.Path, "/")[3] 114 161 artist, err := controller.GetArtist(app.DB, artistID) 115 162 if err != nil { 116 - fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err) 163 + fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err) 117 164 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 118 165 return 119 166 } ··· 123 170 } 124 171 125 172 w.Header().Set("Content-Type", "text/html") 126 - err = newCreditTemplate.Execute(w, artist) 173 + err = templates.NewCreditTemplate.Execute(w, artist) 127 174 if err != nil { 128 - fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) 175 + fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err) 129 176 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 130 177 } 131 178 }) ··· 134 181 func serveEditLinks(release *model.Release) http.Handler { 135 182 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 136 183 w.Header().Set("Content-Type", "text/html") 137 - err := editLinksTemplate.Execute(w, release) 184 + err := templates.EditLinksTemplate.Execute(w, release) 138 185 if err != nil { 139 - fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) 186 + fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err) 140 187 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 141 188 } 142 189 }) ··· 146 193 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 194 w.Header().Set("Content-Type", "text/html") 148 195 149 - type editTracksData struct { 150 - Release *model.Release 151 - Add func(a int, b int) int 152 - } 196 + type editTracksData struct { Release *model.Release } 153 197 154 - err := editTracksTemplate.Execute(w, editTracksData{ 155 - Release: release, 156 - Add: func(a, b int) int { return a + b }, 157 - }) 198 + err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release }) 158 199 if err != nil { 159 - fmt.Printf("Error rendering edit tracks component for %s: %s\n", release.ID, err) 200 + fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err) 160 201 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 161 202 } 162 203 }) ··· 166 207 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 167 208 tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) 168 209 if err != nil { 169 - fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err) 210 + fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err) 170 211 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 171 212 return 172 213 } ··· 177 218 } 178 219 179 220 w.Header().Set("Content-Type", "text/html") 180 - err = addTrackTemplate.Execute(w, response{ 221 + err = templates.AddTrackTemplate.Execute(w, response{ 181 222 ReleaseID: release.ID, 182 223 Tracks: tracks, 183 224 }) 184 225 if err != nil { 185 - fmt.Printf("Error rendering add tracks component for %s: %s\n", release.ID, err) 226 + fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err) 186 227 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 187 228 } 188 - return 189 229 }) 190 230 } 191 231 ··· 194 234 trackID := strings.Split(r.URL.Path, "/")[3] 195 235 track, err := controller.GetTrack(app.DB, trackID) 196 236 if err != nil { 197 - fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) 237 + fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err) 198 238 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 199 239 return 200 240 } ··· 204 244 } 205 245 206 246 w.Header().Set("Content-Type", "text/html") 207 - err = newTrackTemplate.Execute(w, track) 247 + err = templates.NewTrackTemplate.Execute(w, track) 208 248 if err != nil { 209 - fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) 249 + fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err) 210 250 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 211 251 } 212 - return 213 252 }) 214 253 }
+334 -74
admin/static/admin.css
··· 1 1 @import url("/style/prideflag.css"); 2 2 @import url("/font/inter/inter.css"); 3 3 4 + :root { 5 + --bg-0: #101010; 6 + --bg-1: #181818; 7 + --bg-2: #282828; 8 + --bg-3: #404040; 9 + 10 + --fg-0: #b0b0b0; 11 + --fg-1: #c0c0c0; 12 + --fg-2: #d0d0d0; 13 + --fg-3: #e0e0e0; 14 + 15 + --col-shadow-0: #0002; 16 + --col-shadow-1: #0004; 17 + --col-shadow-2: #0006; 18 + --col-highlight-0: #ffffff08; 19 + --col-highlight-1: #fff1; 20 + --col-highlight-2: #fff2; 21 + 22 + --col-new: #b3ee5b; 23 + --col-on-new: #1b2013; 24 + --col-save: #6fd7ff; 25 + --col-on-save: #283f48; 26 + --col-delete: #ff7171; 27 + --col-on-delete: #371919; 28 + 29 + --col-warn: #ffe86a; 30 + --col-on-warn: var(--bg-0); 31 + --col-warn-hover: #ffec81; 32 + 33 + --shadow-sm: 34 + 0 1px 2px var(--col-shadow-2), 35 + inset 0 1px 1px var(--col-highlight-2); 36 + --shadow-md: 37 + 0 2px 4px var(--col-shadow-1), 38 + inset 0 2px 2px var(--col-highlight-1); 39 + --shadow-lg: 40 + 0 4px 8px var(--col-shadow-0), 41 + inset 0 4px 4px var(--col-highlight-0); 42 + } 43 + 44 + @media (prefers-color-scheme: light) { 45 + :root { 46 + --bg-0: #e8e8e8; 47 + --bg-1: #f0f0f0; 48 + --bg-2: #f8f8f8; 49 + --bg-3: #ffffff; 50 + 51 + --fg-0: #606060; 52 + --fg-1: #404040; 53 + --fg-2: #303030; 54 + --fg-3: #202020; 55 + 56 + --col-shadow-0: #0002; 57 + --col-shadow-1: #0004; 58 + --col-shadow-2: #0008; 59 + --col-highlight-0: #fff2; 60 + --col-highlight-1: #fff4; 61 + --col-highlight-2: #fff8; 62 + 63 + --col-warn: #ffe86a; 64 + --col-on-warn: var(--fg-3); 65 + --col-warn-hover: #ffec81; 66 + } 67 + } 68 + 69 + @media (prefers-color-scheme: green) { 70 + :root { 71 + --bg-0: #d0d9c7; 72 + --bg-1: #e2e5de; 73 + --bg-2: #f1f1f1; 74 + --bg-3: #ffffff; 75 + --fg-0: #626f54; 76 + --fg-1: #505c43; 77 + --fg-2: #49523e; 78 + --fg-3: #2c3522; 79 + } 80 + } 81 + 82 + @media (prefers-color-scheme: purple) { 83 + :root { 84 + --bg-0: #15121c; 85 + --bg-1: #1e1a27; 86 + --bg-2: #302a3d; 87 + --bg-3: #4a4358; 88 + --fg-0: #9e8fbf; 89 + --fg-1: #a29bb3; 90 + --fg-2: #b9b0cd; 91 + --fg-3: #dcd5ec; 92 + } 93 + } 94 + 95 + @media (prefers-color-scheme: dark) { 96 + img.icon { 97 + -webkit-filter: invert(.9); 98 + filter: invert(.9); 99 + } 100 + } 101 + 4 102 body { 5 - width: 100%; 103 + width: calc(100% - 180px); 6 104 height: calc(100vh - 1em); 7 105 8 - margin: 0; 106 + margin: 0 0 0 180px; 9 107 padding: 0; 108 + display: flex; 109 + flex-direction: row; 10 110 11 111 font-family: "Inter", sans-serif; 12 112 font-size: 16px; 113 + color: var(--fg-0); 114 + background: var(--bg-0); 115 + 116 + transition: background .1s ease-out, color .1s ease-out; 117 + } 13 118 14 - color: #303030; 15 - background: #f0f0f0; 119 + h1, h2, h3, h4, h5, h6 { 120 + color: var(--fg-3); 16 121 } 17 122 123 + header { 124 + display: flex; 125 + justify-content: space-between; 126 + align-items: center; 127 + } 18 128 nav { 19 - width: min(720px, calc(100% - 2em)); 20 - height: 2em; 21 - margin: 1em auto; 129 + position: fixed; 130 + top: 0; 131 + left: 0; 132 + width: 180px; 133 + height: calc(100vh - 2em); 134 + margin: 0; 135 + padding: 1em 0; 22 136 display: flex; 23 - flex-direction: row; 137 + flex-direction: column; 24 138 justify-content: left; 25 139 26 - background: #f8f8f8; 27 - border-radius: 4px; 28 - border: 1px solid #808080; 140 + background-color: var(--bg-1); 141 + box-shadow: var(--shadow-md); 142 + transition: background .1s ease-out, color .1s ease-out; 143 + 144 + user-select: none; 29 145 } 30 146 nav .icon { 31 - height: 100%; 32 - } 33 - nav .title { 34 - width: auto; 35 - height: 100%; 36 - 37 - margin: 0 1em 0 0; 38 - 147 + width: fit-content; 148 + height: fit-content; 149 + padding: 0; 150 + margin: 0 auto 1em auto; 39 151 display: flex; 40 152 41 - line-height: 2em; 42 - text-decoration: none; 43 - 44 - color: inherit; 153 + border-radius: 100%; 154 + box-shadow: var(--shadow-sm); 155 + overflow: clip; 156 + } 157 + nav .icon img { 158 + width: 3em; 159 + height: 3em; 45 160 } 46 161 .nav-item { 47 - width: auto; 48 - height: 100%; 49 - 50 - margin: 0px; 51 - padding: 0 1em; 52 - 53 162 display: flex; 54 - 163 + color: var(--fg-2); 55 164 line-height: 2em; 165 + font-weight: 500; 166 + transition: color .1s ease-out, background-color .1s ease-out; 56 167 } 57 168 .nav-item:hover { 58 - background: #00000010; 169 + color: var(--bg-2); 170 + background-color: var(--fg-2); 59 171 text-decoration: none; 60 172 } 173 + .nav-item.active { 174 + border-left: 4px solid var(--fg-2); 175 + } 176 + .nav-item.active a { 177 + padding-left: calc(1em - 3.5px); 178 + } 61 179 nav a { 180 + padding: .2em 1em; 62 181 text-decoration: none; 63 182 color: inherit; 183 + width: 100%; 184 + } 185 + nav a.active { 186 + border-left: 5px solid var(--fg-0); 187 + padding-left: calc(1em - 5px); 188 + } 189 + nav hr { 190 + width: calc(100% - 2em); 191 + margin: .5em auto; 192 + border: none; 193 + border-bottom: 1px solid var(--fg-0); 64 194 } 65 - nav #logout { 66 - /* margin-left: auto; */ 195 + nav .section-label { 196 + margin: .6em 0 .1em 1.6em; 197 + font-size: .6em; 198 + text-transform: uppercase; 199 + font-weight: 600; 200 + } 201 + #toggle-nav { 202 + position: fixed; 203 + top: 16px; 204 + left: 16px; 205 + padding: 8px; 206 + width: 48px; 207 + height: 48px; 208 + display: none; 209 + justify-content: center; 210 + align-items: center; 211 + z-index: 1; 212 + } 213 + #toggle-nav img { 214 + width: 100%; 215 + height: 100%; 216 + object-fit: cover; 217 + transform: translate(1px, 1px); 218 + } 219 + #toggle-nav img:hover { 220 + -webkit-filter: invert(.9); 221 + filter: invert(.9); 222 + } 223 + @media (prefers-color-scheme: dark) { 224 + #toggle-nav img { 225 + -webkit-filter: invert(.9); 226 + filter: invert(.9); 227 + } 228 + #toggle-nav img:hover { 229 + -webkit-filter: none; 230 + filter: none; 231 + } 67 232 } 68 233 69 234 main { 70 - width: min(720px, calc(100% - 2em)); 235 + width: 720px; 236 + max-width: calc(100% - 2em); 237 + height: fit-content; 238 + min-height: calc(100vh - 2em); 71 239 margin: 0 auto; 72 240 padding: 1em; 73 241 } 242 + main.dashboard { 243 + width: 100%; 244 + } 74 245 75 246 a { 76 247 color: inherit; 77 248 text-decoration: none; 249 + overflow: hidden; 250 + text-overflow: ellipsis; 251 + white-space: nowrap; 252 + transition: color .1s ease-out, background-color .1s ease-out; 78 253 } 79 254 255 + /* 80 256 a:hover { 81 257 text-decoration: underline; 82 258 } 259 + */ 83 260 84 - a img.icon { 261 + img.icon { 85 262 height: .8em; 263 + transition: filter .1s ease-out; 86 264 } 87 265 88 266 code { ··· 94 272 95 273 96 274 275 + .cards { 276 + width: 100%; 277 + height: fit-content; 278 + display: flex; 279 + gap: 2em; 280 + flex-wrap: wrap; 281 + } 282 + 97 283 .card { 284 + flex-basis: 40em; 285 + padding: 1em; 286 + background: var(--bg-1); 287 + border-radius: 16px; 288 + box-shadow: var(--shadow-lg); 289 + 290 + transition: background .1s ease-out, color .1s ease-out; 291 + } 292 + main:not(.dashboard) .card { 98 293 margin-bottom: 1em; 99 294 } 100 295 ··· 102 297 margin: 0 0 .5em 0; 103 298 } 104 299 105 - .card-title { 300 + .card-header { 106 301 margin-bottom: 1em; 107 302 display: flex; 108 303 gap: 1em; ··· 110 305 align-items: center; 111 306 justify-content: space-between; 112 307 } 308 + .card-header h1, 309 + .card-header h2, 310 + .card-header h3 { 311 + margin: 0; 312 + } 313 + .card-header a:hover { 314 + text-decoration: underline; 315 + } 113 316 114 - .card-title h1, 115 - .card-title h2, 116 - .card-title h3 { 117 - margin: 0; 317 + header :is(h1, h2, h3) small, 318 + .card-header :is(h1, h2, h3) small { 319 + display: inline-block; 320 + font-size: .6em; 321 + transform: translateY(-0.1em); 322 + color: var(--fg-0); 118 323 } 119 324 120 325 .flex-fill { 121 326 flex-grow: 1; 122 327 } 123 328 124 - @media screen and (max-width: 520px) { 125 - body { 126 - font-size: 12px; 127 - } 329 + .artists-group { 330 + display: grid; 331 + grid-template-columns: repeat(5, 1fr); 332 + gap: 1em; 128 333 } 129 334 130 335 ··· 133 338 #error { 134 339 margin: 0 0 1em 0; 135 340 padding: 1em; 136 - border-radius: 4px; 341 + border-radius: 8px; 342 + color: #101010; 137 343 background: #ffffff; 138 - border: 1px solid #888; 139 344 } 140 345 #message { 141 346 background: #a9dfff; 142 - border-color: #599fdc; 143 347 } 144 348 #error { 145 349 background: #ffa9b8; 146 - border-color: #dc5959; 147 350 } 148 351 149 352 ··· 152 355 color: #d22828; 153 356 } 154 357 155 - button, .button { 358 + .button, button { 156 359 padding: .5em .8em; 157 360 font-family: inherit; 158 361 font-size: inherit; 159 - border-radius: 4px; 160 - border: 1px solid #a0a0a0; 161 - background: #f0f0f0; 362 + 162 363 color: inherit; 364 + background: var(--bg-2); 365 + border: none; 366 + border-radius: 10em; 367 + box-shadow: var(--shadow-sm); 368 + font-weight: 500; 369 + transition: background .1s ease-out, color .1s ease-out; 370 + 371 + cursor: pointer; 372 + user-select: none; 163 373 } 164 374 button:hover, .button:hover { 165 375 background: #fff; 166 - border-color: #d0d0d0; 167 376 } 168 377 button:active, .button:active { 169 378 background: #d0d0d0; 170 - border-color: #808080; 171 379 } 172 380 173 - .button, button { 174 - color: inherit; 175 - } 176 381 .button.new, button.new { 177 - background: #c4ff6a; 178 - border-color: #84b141; 382 + color: var(--col-on-new); 383 + background: var(--col-new); 179 384 } 180 385 .button.save, button.save { 181 - background: #6fd7ff; 182 - border-color: #6f9eb0; 386 + color: var(--col-on-save); 387 + background: var(--col-save); 183 388 } 184 389 .button.delete, button.delete { 185 - background: #ff7171; 186 - border-color: #7d3535; 390 + color: var(--col-on-delete); 391 + background: var(--col-delete); 187 392 } 188 393 .button:hover, button:hover { 189 - background: #fff; 190 - border-color: #d0d0d0; 394 + color: var(--bg-3); 395 + background: var(--fg-3); 191 396 } 192 397 .button:active, button:active { 193 - background: #d0d0d0; 194 - border-color: #808080; 398 + color: var(--bg-2); 399 + background: var(--fg-0); 195 400 } 196 401 .button[disabled], button[disabled] { 197 - background: #d0d0d0 !important; 198 - border-color: #808080 !important; 402 + color: var(--fg-0) !important; 403 + background: var(--bg-3) !important; 199 404 opacity: .5; 200 - cursor: not-allowed !important; 405 + cursor: default !important; 201 406 } 202 407 203 408 ··· 205 410 form { 206 411 width: 100%; 207 412 display: block; 413 + color: var(--fg-0); 208 414 } 209 415 form label { 210 416 width: 100%; 211 417 margin: 1rem 0 .5rem 0; 212 418 display: block; 213 - color: #10101080; 214 419 } 215 - form input { 216 - margin: .5rem 0; 217 - padding: .3rem .5rem; 420 + form input[type="text"], 421 + form input[type="password"] { 422 + width: 16em; 423 + max-width: 100%; 424 + margin: .5em 0; 425 + padding: .3em .5em; 218 426 display: block; 219 427 border-radius: 4px; 220 428 border: 1px solid #808080; 221 429 font-size: inherit; 222 430 font-family: inherit; 223 431 color: inherit; 432 + background-color: var(--bg-0); 224 433 } 225 434 input[disabled] { 226 435 opacity: .5; 227 436 cursor: not-allowed; 228 437 } 438 + 439 + @media screen and (max-width: 720px) { 440 + main { 441 + padding-top: 0; 442 + } 443 + 444 + body { 445 + width: 100%; 446 + margin: 0; 447 + font-size: 16px; 448 + } 449 + 450 + nav { 451 + width: 100%; 452 + left: -100%; 453 + font-size: 24px; 454 + z-index: 1; 455 + transition: transform .2s cubic-bezier(.2,0,.5,1); 456 + } 457 + nav.open { 458 + transform: translateX(100%); 459 + } 460 + 461 + #toggle-nav { 462 + display: flex; 463 + } 464 + 465 + main > h1:first-of-type { 466 + margin-left: 68px; 467 + overflow: hidden; 468 + white-space: nowrap; 469 + text-overflow: ellipsis; 470 + } 471 + main > header { 472 + margin-left: 68px; 473 + } 474 + main > header h1 { 475 + display: flex; 476 + flex-direction: column; 477 + font-size: 1.5em; 478 + } 479 + 480 + .card { 481 + flex-basis: 100%; 482 + max-width: calc(100vw - 4em); 483 + } 484 + 485 + .artists-group { 486 + grid-template-columns: repeat(3, 1fr); 487 + } 488 + }
+25
admin/static/admin.js
··· 69 69 if (callback) callback(); 70 70 }); 71 71 } 72 + 73 + export function hijackClickEvent(container, link) { 74 + container.addEventListener('click', event => { 75 + if (event.target.tagName.toLowerCase() === 'a') return; 76 + event.preventDefault(); 77 + link.dispatchEvent(new MouseEvent('click', { 78 + bubbles: true, 79 + cancelable: true, 80 + view: window, 81 + ctrlKey: event.ctrlKey, 82 + metaKey: event.metaKey, 83 + shiftKey: event.shiftKey, 84 + altKey: event.altKey, 85 + button: event.button, 86 + })); 87 + }); 88 + } 89 + 90 + document.addEventListener("readystatechange", () => { 91 + const navbar = document.getElementById("navbar"); 92 + document.getElementById("toggle-nav").addEventListener("click", event => { 93 + event.preventDefault(); 94 + navbar.classList.toggle("open"); 95 + }) 96 + });
+27
admin/static/artists.css
··· 1 + .artist { 2 + padding: .5em; 3 + 4 + color: var(--fg-3); 5 + background: var(--bg-2); 6 + box-shadow: var(--shadow-md); 7 + border-radius: 16px; 8 + text-align: center; 9 + 10 + cursor: pointer; 11 + transition: background .1s ease-out, color .1s ease-out; 12 + } 13 + 14 + .artist:hover { 15 + background: var(--bg-1); 16 + text-decoration: hover; 17 + } 18 + 19 + .artist .artist-avatar { 20 + width: 100%; 21 + object-fit: cover; 22 + border-radius: 8px; 23 + } 24 + 25 + .artist .artist-name { 26 + display: block; 27 + }
+7
admin/static/artists.js
··· 1 + import { hijackClickEvent } from "./admin.js"; 2 + 3 + document.addEventListener("readystatechange", () => { 4 + document.querySelectorAll(".artists-group .artist").forEach(el => { 5 + hijackClickEvent(el, el.querySelector("a.artist-name")) 6 + }); 7 + });
+21 -5
admin/static/edit-account.css
··· 11 11 align-items: center; 12 12 color: inherit; 13 13 } 14 - input { 15 - width: min(20rem, calc(100% - 1rem)); 14 + form#change-password input, 15 + form#delete-account input { 16 + width: 20em; 17 + min-width: auto; 18 + max-width: calc(100% - 1em - 2px); 16 19 margin: .5rem 0; 17 20 padding: .3rem .5rem; 18 21 display: block; ··· 25 28 26 29 .mfa-device { 27 30 padding: .75em; 28 - background: #f8f8f8f8; 29 - border: 1px solid #808080; 30 - border-radius: 8px; 31 31 margin-bottom: .5em; 32 32 display: flex; 33 33 justify-content: space-between; 34 + 35 + color: var(--fg-3); 36 + background: var(--bg-2); 37 + box-shadow: var(--shadow-md); 38 + border-radius: 16px; 34 39 } 35 40 36 41 .mfa-device div { ··· 46 51 .mfa-device .mfa-device-name { 47 52 font-weight: bold; 48 53 } 54 + 55 + .mfa-device form input { 56 + display: none !important; 57 + } 58 + 59 + .mfa-actions { 60 + display: flex; 61 + flex-direction: row; 62 + gap: .5em; 63 + flex-wrap: wrap; 64 + }
+25 -14
admin/static/edit-artist.css
··· 1 - h1 { 2 - margin: 0 0 1em 0; 3 - } 4 - 5 1 #artist { 6 2 margin-bottom: 1em; 7 3 padding: 1.5em; ··· 9 5 flex-direction: row; 10 6 gap: 1.2em; 11 7 12 - border-radius: 8px; 13 - background: #f8f8f8f8; 14 - border: 1px solid #808080; 8 + border-radius: 16px; 9 + background: var(--bg-2); 10 + box-shadow: var(--shadow-md); 15 11 } 16 12 17 13 .artist-avatar { ··· 27 23 cursor: pointer; 28 24 } 29 25 .artist-avatar #remove-avatar { 30 - padding: .3em .4em; 26 + margin-top: .5em; 27 + padding: .3em .6em; 31 28 } 32 29 33 30 .artist-info { ··· 53 50 font-family: inherit; 54 51 font-weight: inherit; 55 52 color: inherit; 56 - background: #ffffff; 57 - border: 1px solid transparent; 53 + background: var(--bg-0); 54 + border: none; 58 55 border-radius: 4px; 59 56 outline: none; 60 57 } ··· 74 71 justify-content: right; 75 72 } 76 73 77 - .card-title a.button { 74 + .card-header a.button { 78 75 text-decoration: none; 79 76 } 80 77 ··· 85 82 flex-direction: row; 86 83 gap: 1em; 87 84 align-items: center; 88 - background: #f8f8f8; 89 - border-radius: 8px; 90 - border: 1px solid #808080; 85 + 86 + border-radius: 16px; 87 + background: var(--bg-2); 88 + box-shadow: var(--shadow-md); 89 + 90 + cursor: pointer; 91 + transition: background .1s; 92 + } 93 + 94 + .credit:hover { 95 + background: var(--bg-1); 91 96 } 92 97 93 98 .release-artwork { ··· 96 101 border-radius: 4px; 97 102 } 98 103 104 + .credit-info { 105 + overflow: hidden; 106 + } 99 107 .credit-info h3, 100 108 .credit-info p { 101 109 margin: 0; 102 110 font-size: .9em; 111 + white-space: nowrap; 112 + overflow: hidden; 113 + text-overflow: ellipsis; 103 114 }
+8
admin/static/edit-artist.js
··· 1 + import { hijackClickEvent } from "./admin.js"; 2 + 1 3 const artistID = document.getElementById("artist").dataset.id; 2 4 const nameInput = document.getElementById("name"); 3 5 const avatarImg = document.getElementById("avatar"); ··· 77 79 avatarImg.src = "/img/default-avatar.png" 78 80 saveBtn.disabled = false; 79 81 }); 82 + 83 + document.addEventListener('readystatechange', () => { 84 + document.querySelectorAll('#releases .credit').forEach(el => { 85 + hijackClickEvent(el, el.querySelector('.credit-name a')); 86 + }); 87 + });
+96 -91
admin/static/edit-release.css
··· 12 12 gap: 1.2em; 13 13 14 14 border-radius: 8px; 15 - background: #f8f8f8f8; 16 - border: 1px solid #808080; 15 + background: var(--bg-2); 16 + box-shadow: var(--shadow-md); 17 + 18 + transition: background .1s ease-out, color .1s ease-out; 17 19 } 18 20 19 21 .release-artwork { ··· 29 31 cursor: pointer; 30 32 } 31 33 .release-artwork #remove-artwork { 32 - padding: .3em .4em; 34 + margin-top: .5em; 35 + padding: .3em .6em; 36 + background: var(--bg-3); 33 37 } 34 38 35 39 .release-info { ··· 54 58 background: transparent; 55 59 outline: none; 56 60 cursor: pointer; 61 + transition: background .1s ease-out, border-color .1s ease-out; 57 62 } 58 63 59 64 #title:hover { 60 - background: #ffffff; 61 - border-color: #80808080; 65 + background: var(--bg-3); 66 + border-color: var(--fg-0); 62 67 } 63 68 64 69 #title:active, 65 70 #title:focus { 66 - background: #ffffff; 67 - border-color: #808080; 71 + background: var(--bg-3); 68 72 } 69 73 70 74 .release-title small { ··· 75 79 width: 100%; 76 80 margin: .5em 0; 77 81 border-collapse: collapse; 82 + color: var(--fg-2); 78 83 } 79 84 .release-info table td { 80 85 padding: .2em; 81 - border-bottom: 1px solid #d0d0d0; 86 + border-bottom: 1px solid color-mix(in srgb, var(--fg-0) 25%, transparent); 87 + transition: background .1s ease-out, border-color .1s ease-out; 82 88 } 83 89 .release-info table tr td:first-child { 84 90 vertical-align: top; 85 - opacity: .66; 91 + opacity: .5; 86 92 } 87 93 .release-info table tr td:not(:first-child) select:hover, 88 94 .release-info table tr td:not(:first-child) input:hover, 89 95 .release-info table tr td:not(:first-child) textarea:hover { 90 - background: #e8e8e8; 96 + background: var(--bg-3); 91 97 cursor: pointer; 92 98 } 93 99 .release-info table td select, ··· 115 121 gap: .5em; 116 122 flex-direction: row; 117 123 justify-content: right; 124 + color: var(--fg-3); 125 + } 126 + 127 + .release-actions button, 128 + .release-actions .button { 129 + color: var(--fg-2); 130 + background: var(--bg-3); 118 131 } 119 132 120 133 dialog { 121 134 width: min(720px, calc(100% - 2em)); 122 135 padding: 2em; 123 - border: 1px solid #101010; 124 - border-radius: 8px; 136 + border: none; 137 + border-radius: 16px; 138 + color: var(--fg-0); 139 + background-color: var(--bg-0); 140 + box-shadow: var(--shadow-lg); 141 + 142 + transition: color .1s ease-out, background-color .1s ease-out; 125 143 } 126 144 127 145 dialog header { ··· 144 162 gap: .5em; 145 163 } 146 164 147 - .card-title a.button { 165 + .card-header a.button { 148 166 text-decoration: none; 149 167 } 150 168 ··· 152 170 * RELEASE CREDITS 153 171 */ 154 172 155 - .card.credits .credit { 173 + #credits .credit { 156 174 margin-bottom: .5em; 157 175 padding: .5em; 158 176 display: flex; ··· 160 178 align-items: center; 161 179 gap: 1em; 162 180 163 - border-radius: 8px; 164 - background: #f8f8f8f8; 165 - border: 1px solid #808080; 181 + border-radius: 16px; 182 + background-color: var(--bg-2); 183 + box-shadow: var(--shadow-md); 184 + 185 + cursor: pointer; 186 + transition: background .1s ease-out; 187 + } 188 + #credits .credit:hover { 189 + background-color: var(--bg-1); 166 190 } 167 191 168 - .card.credits .credit p { 192 + #credits .credit p { 169 193 margin: 0; 170 194 } 171 195 172 - .card.credits .credit .artist-avatar { 173 - border-radius: 8px; 196 + #credits .credit .artist-avatar { 197 + border-radius: 12px; 174 198 } 175 199 176 - .card.credits .credit .artist-name { 200 + #credits .credit .artist-name { 201 + color: var(--fg-3); 177 202 font-weight: bold; 178 203 } 179 204 180 - .card.credits .credit .artist-role small { 205 + #credits .credit .artist-role small { 181 206 font-size: inherit; 182 207 opacity: .66; 183 208 } 184 209 210 + #credits .credit .credit-info { 211 + overflow: hidden; 212 + } 213 + 214 + #credits .credit .credit-info :is(h3, p) { 215 + white-space: nowrap; 216 + overflow: hidden; 217 + text-overflow: ellipsis; 218 + } 219 + 220 + 221 + 185 222 #editcredits ul { 186 223 margin: 0; 187 224 padding: 0; ··· 197 234 gap: 1em; 198 235 199 236 border-radius: 8px; 200 - background: #f8f8f8f8; 201 - border: 1px solid #808080; 237 + background: var(--bg-2); 238 + box-shadow: var(--shadow-md); 202 239 } 203 240 204 241 #editcredits .credit { ··· 232 269 margin: 0; 233 270 display: flex; 234 271 align-items: center; 272 + color: inherit; 235 273 } 236 274 237 275 #editcredits .credit .credit-info .credit-attribute input[type="text"] { ··· 239 277 padding: .2em .4em; 240 278 flex-grow: 1; 241 279 font-family: inherit; 242 - border: 1px solid #8888; 280 + border: none; 243 281 border-radius: 4px; 244 - color: inherit; 282 + color: var(--fg-2); 283 + background: var(--bg-0); 245 284 } 246 285 #editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { 247 286 margin: 0 .3em; 248 287 } 249 288 250 289 #editcredits .credit .artist-name { 290 + color: var(--fg-2); 251 291 font-weight: bold; 252 292 } 253 293 ··· 256 296 opacity: .66; 257 297 } 258 298 259 - #editcredits .credit button.delete { 260 - margin-left: auto; 299 + #editcredits .credit .delete { 300 + margin-right: .5em; 301 + cursor: pointer; 302 + } 303 + #editcredits .credit .delete:hover { 304 + text-decoration: underline; 261 305 } 262 306 263 307 #addcredit ul { ··· 289 333 * RELEASE LINKS 290 334 */ 291 335 292 - .card.links { 336 + #links ul { 337 + padding: 0; 293 338 display: flex; 294 339 gap: .2em; 295 340 } 296 341 297 - .card.links a.button[data-name="spotify"] { 342 + #links a img.icon { 343 + -webkit-filter: none; 344 + filter: none; 345 + } 346 + 347 + #links a.button:hover { 348 + color: var(--bg-3) !important; 349 + background-color: var(--fg-3) !important; 350 + } 351 + 352 + #links a.button[data-name="spotify"] { 353 + color: #101010; 298 354 background-color: #8cff83 299 355 } 300 356 301 - .card.links a.button[data-name="apple music"] { 357 + #links a.button[data-name="apple music"] { 358 + color: #101010; 302 359 background-color: #8cd9ff 303 360 } 304 361 305 - .card.links a.button[data-name="soundcloud"] { 362 + #links a.button[data-name="soundcloud"] { 363 + color: #101010; 306 364 background-color: #fdaa6d 307 365 } 308 366 309 - .card.links a.button[data-name="youtube"] { 367 + #links a.button[data-name="youtube"] { 368 + color: #101010; 310 369 background-color: #ff6e6e 311 370 } 312 371 ··· 389 448 outline: 1px solid #808080; 390 449 } 391 450 392 - /* 393 - * RELEASE TRACKS 394 - */ 395 - 396 - .card.tracks .track { 397 - margin-bottom: 1em; 398 - padding: 1em; 399 - display: flex; 400 - flex-direction: column; 401 - gap: .5em; 402 - 403 - border-radius: 8px; 404 - background: #f8f8f8f8; 405 - border: 1px solid #808080; 406 - } 407 - 408 - .card.tracks .track h3, 409 - .card.tracks .track p { 410 - margin: 0; 411 - } 412 - 413 - .card.tracks h2.track-title { 414 - margin: 0; 415 - display: flex; 416 - gap: .5em; 417 - } 418 - 419 - .card.tracks h2.track-title .track-number { 420 - opacity: .5; 421 - } 422 - 423 - .card.tracks .track-album { 424 - margin-left: auto; 425 - font-style: italic; 426 - font-size: .75em; 427 - opacity: .5; 428 - } 429 - 430 - .card.tracks .track-album.empty { 431 - color: #ff2020; 432 - opacity: 1; 433 - } 434 - 435 - .card.tracks .track-description { 436 - font-style: italic; 437 - } 438 - 439 - .card.tracks .track-lyrics { 440 - max-height: 10em; 441 - overflow-y: scroll; 442 - } 443 - 444 - .card.tracks .track .empty { 445 - opacity: 0.75; 446 - } 447 - 448 451 #edittracks ul { 449 452 padding: 0; 450 453 list-style: none; ··· 496 499 padding: .5em; 497 500 display: flex; 498 501 gap: .5em; 502 + background-color: var(--bg-0); 499 503 cursor: pointer; 504 + transition: background-color .1s ease-out, color .1s ease-out; 500 505 } 501 506 502 507 #addtrack ul li.new-track:nth-child(even) { 503 - background: #f0f0f0; 508 + background: color-mix(in srgb, var(--bg-0) 95%, #fff); 504 509 } 505 510 506 511 #addtrack ul li.new-track:hover { 507 - background: #e0e0e0; 512 + background: color-mix(in srgb, var(--bg-0) 90%, #fff); 508 513 } 509 514 510 515 @media only screen and (max-width: 1105px) {
+8
admin/static/edit-release.js
··· 1 + import { hijackClickEvent } from "./admin.js"; 2 + 1 3 const releaseID = document.getElementById("release").dataset.id; 2 4 const titleInput = document.getElementById("title"); 3 5 const artworkImg = document.getElementById("artwork"); ··· 96 98 artworkData = ""; 97 99 saveBtn.disabled = false; 98 100 }); 101 + 102 + document.addEventListener("readystatechange", () => { 103 + document.querySelectorAll("#credits .credit").forEach(el => { 104 + hijackClickEvent(el, el.querySelector(".artist-name a")); 105 + }); 106 + });
+5 -8
admin/static/edit-track.css
··· 1 1 @import url("/admin/static/release-list-item.css"); 2 2 3 - h1 { 4 - margin: 0 0 .5em 0; 5 - } 6 - 7 3 #track { 8 4 margin-bottom: 1em; 9 5 padding: .5em 1.5em 1.5em 1.5em; ··· 11 7 flex-direction: row; 12 8 gap: 1.2em; 13 9 14 - border-radius: 8px; 15 - background: #f8f8f8f8; 16 - border: 1px solid #808080; 10 + border-radius: 16px; 11 + background: var(--bg-2); 12 + box-shadow: var(--shadow-md); 17 13 } 18 14 19 15 .track-info { ··· 49 45 font-weight: inherit; 50 46 font-family: inherit; 51 47 font-size: inherit; 52 - border: 1px solid transparent; 48 + background: var(--bg-0); 49 + border: none; 53 50 border-radius: 4px; 54 51 outline: none; 55 52 color: inherit;
-82
admin/static/index.css
··· 1 - @import url("/admin/static/release-list-item.css"); 2 - 3 - .artist { 4 - margin-bottom: .5em; 5 - padding: .5em; 6 - display: flex; 7 - flex-direction: row; 8 - align-items: center; 9 - gap: .5em; 10 - 11 - border-radius: 8px; 12 - background: #f8f8f8f8; 13 - border: 1px solid #808080; 14 - } 15 - 16 - .artist:hover { 17 - text-decoration: hover; 18 - } 19 - 20 - .artist-avatar { 21 - width: 32px; 22 - height: 32px; 23 - object-fit: cover; 24 - border-radius: 100%; 25 - } 26 - 27 - .track { 28 - margin-bottom: 1em; 29 - padding: 1em; 30 - display: flex; 31 - flex-direction: column; 32 - gap: .5em; 33 - 34 - border-radius: 8px; 35 - background: #f8f8f8f8; 36 - border: 1px solid #808080; 37 - } 38 - 39 - .track p { 40 - margin: 0; 41 - } 42 - 43 - .card h2.track-title { 44 - margin: 0; 45 - display: flex; 46 - flex-direction: row; 47 - justify-content: space-between; 48 - } 49 - 50 - .track-id { 51 - width: fit-content; 52 - font-family: "Monaspace Argon", monospace; 53 - font-size: .8em; 54 - font-style: italic; 55 - line-height: 1em; 56 - user-select: all; 57 - } 58 - 59 - .track-album { 60 - margin-left: auto; 61 - font-style: italic; 62 - font-size: .75em; 63 - opacity: .5; 64 - } 65 - 66 - .track-album.empty { 67 - color: #ff2020; 68 - opacity: 1; 69 - } 70 - 71 - .track-description { 72 - font-style: italic; 73 - } 74 - 75 - .track-lyrics { 76 - max-height: 10em; 77 - overflow-y: scroll; 78 - } 79 - 80 - .track .empty { 81 - opacity: 0.75; 82 - }
+3 -3
admin/static/index.js
··· 12 12 headers: { "Content-Type": "application/json" }, 13 13 body: JSON.stringify({id}) 14 14 }).then(res => { 15 - if (res.ok) location = "/admin/release/" + id; 15 + if (res.ok) location = "/admin/releases/" + id; 16 16 else { 17 17 res.text().then(err => { 18 18 alert("Request failed: " + err); ··· 37 37 }).then(res => { 38 38 res.text().then(text => { 39 39 if (res.ok) { 40 - location = "/admin/artist/" + id; 40 + location = "/admin/artists/" + id; 41 41 } else { 42 42 alert("Request failed: " + text); 43 43 console.error(text); ··· 61 61 }).then(res => { 62 62 res.text().then(text => { 63 63 if (res.ok) { 64 - location = "/admin/track/" + text; 64 + location = "/admin/tracks/" + text; 65 65 } else { 66 66 alert("Request failed: " + text); 67 67 console.error(text);
+36 -15
admin/static/logs.css
··· 2 2 width: min(1080px, calc(100% - 2em))!important 3 3 } 4 4 5 - form { 5 + form#search-form { 6 + width: calc(100% - 2em); 6 7 margin: 1em 0; 8 + padding: 1em; 9 + border-radius: 16px; 10 + color: var(--fg-0); 11 + background: var(--bg-2); 12 + box-shadow: var(--shadow-md); 7 13 } 8 14 9 15 div#search { ··· 12 18 13 19 #search input { 14 20 margin: 0; 21 + padding: .3em .8em; 15 22 flex-grow: 1; 16 - 17 - border-right: none; 18 - border-top-right-radius: 0; 19 - border-bottom-right-radius: 0; 23 + border: none; 24 + border-radius: 16px; 25 + color: var(--fg-1); 26 + background: var(--bg-0); 27 + box-shadow: var(--shadow-sm); 20 28 } 21 29 22 30 #search button { 23 - padding: 0 .5em; 24 - 25 - border-top-left-radius: 0; 26 - border-bottom-left-radius: 0; 31 + margin-left: .5em; 32 + padding: 0 .8em; 27 33 } 28 34 29 35 form #filters p { 30 36 margin: .5em 0 0 0; 31 37 } 32 38 form #filters label { 39 + color: inherit; 33 40 display: inline; 34 41 } 35 42 form #filters input { ··· 39 46 40 47 #logs { 41 48 width: 100%; 49 + overflow: scroll; 50 + } 51 + @media screen and (max-width: 720px) { 52 + #logs { 53 + font-size: 12px; 54 + } 55 + } 56 + 57 + #logs table{ 42 58 border-collapse: collapse; 43 59 } 44 60 ··· 57 73 padding: .4em .8em; 58 74 } 59 75 76 + #logs .log { 77 + color: var(--fg-2); 78 + } 79 + 60 80 td, th { 61 81 width: 1%; 62 82 text-align: left; ··· 74 94 white-space: collapse; 75 95 } 76 96 77 - .log:hover { 78 - background: #fff8; 97 + #logs .log:hover { 98 + background: color-mix(in srgb, var(--fg-3) 10%, transparent); 79 99 } 80 100 81 - .log.warn { 82 - background: #ffe86a; 101 + #logs .log.warn { 102 + color: var(--col-on-warn); 103 + background: var(--col-warn); 83 104 } 84 - .log.warn:hover { 85 - background: #ffec81; 105 + #logs .log.warn:hover { 106 + background: var(--col-warn-hover); 86 107 }
-87
admin/static/release-list-item.css
··· 1 - .release { 2 - margin-bottom: 1em; 3 - padding: 1em; 4 - display: flex; 5 - flex-direction: row; 6 - gap: 1em; 7 - 8 - border-radius: 8px; 9 - background: #f8f8f8f8; 10 - border: 1px solid #808080; 11 - } 12 - 13 - .release h3, 14 - .release p { 15 - margin: 0; 16 - } 17 - 18 - .release-artwork { 19 - width: 96px; 20 - 21 - display: flex; 22 - justify-content: center; 23 - align-items: center; 24 - } 25 - 26 - .release-artwork img { 27 - width: 100%; 28 - aspect-ratio: 1; 29 - } 30 - 31 - .release-title small { 32 - opacity: .75; 33 - } 34 - 35 - .release-links { 36 - margin: .5em 0; 37 - padding: 0; 38 - display: flex; 39 - flex-direction: row; 40 - list-style: none; 41 - flex-wrap: wrap; 42 - gap: .5em; 43 - } 44 - 45 - .release-links li { 46 - flex-grow: 1; 47 - } 48 - 49 - .release-links a { 50 - padding: .5em; 51 - display: block; 52 - 53 - border-radius: 8px; 54 - text-decoration: none; 55 - color: #f0f0f0; 56 - background: #303030; 57 - text-align: center; 58 - 59 - transition: color .1s, background .1s; 60 - } 61 - 62 - .release-links a:hover { 63 - color: #303030; 64 - background: #f0f0f0; 65 - } 66 - 67 - .release-actions { 68 - margin-top: .5em; 69 - } 70 - 71 - .release-actions a { 72 - margin-right: .3em; 73 - padding: .3em .5em; 74 - display: inline-block; 75 - 76 - border-radius: 4px; 77 - background: #e0e0e0; 78 - 79 - transition: color .1s, background .1s; 80 - } 81 - 82 - .release-actions a:hover { 83 - color: #303030; 84 - background: #f0f0f0; 85 - 86 - text-decoration: none; 87 - }
+80
admin/static/releases.css
··· 1 + .release { 2 + margin-bottom: 1em; 3 + padding: 1em; 4 + display: flex; 5 + flex-direction: row; 6 + gap: 1em; 7 + 8 + border-radius: 16px; 9 + background: var(--bg-2); 10 + box-shadow: var(--shadow-md); 11 + 12 + transition: background .1s ease-out, color .1s ease-out; 13 + } 14 + 15 + .release h3, 16 + .release p { 17 + margin: 0; 18 + overflow: hidden; 19 + white-space: nowrap; 20 + text-overflow: ellipsis; 21 + } 22 + 23 + .release .release-artwork { 24 + margin: auto 0; 25 + width: 96px; 26 + 27 + display: flex; 28 + justify-content: center; 29 + align-items: center; 30 + border-radius: 4px; 31 + overflow: hidden; 32 + box-shadow: var(--shadow-sm); 33 + } 34 + 35 + .release .release-artwork img { 36 + width: 100%; 37 + aspect-ratio: 1; 38 + } 39 + 40 + .release .release-info { 41 + max-width: calc(100% - 96px - 1em); 42 + } 43 + 44 + .release .release-title small { 45 + opacity: .75; 46 + } 47 + 48 + .release .release-links { 49 + margin: .5em 0; 50 + padding: 0; 51 + display: flex; 52 + flex-direction: row; 53 + list-style: none; 54 + flex-wrap: wrap; 55 + gap: .5em; 56 + } 57 + 58 + .release .release-actions { 59 + margin-top: .5em; 60 + user-select: none; 61 + color: var(--fg-3); 62 + } 63 + 64 + .release .release-actions a { 65 + margin-right: .3em; 66 + padding: .3em .5em; 67 + display: inline-block; 68 + 69 + border-radius: 4px; 70 + background: var(--bg-3); 71 + box-shadow: var(--shadow-sm); 72 + 73 + transition: color .1s ease-out, background .1s ease-out; 74 + } 75 + 76 + .release .release-actions a:hover { 77 + background: var(--bg-0); 78 + color: var(--fg-3); 79 + text-decoration: none; 80 + }
+127
admin/static/tracks.css
··· 1 + #tracks h2.track-title { 2 + margin: 0; 3 + display: flex; 4 + gap: .5em; 5 + } 6 + 7 + #tracks .track { 8 + margin-bottom: 1em; 9 + padding: 1em; 10 + display: flex; 11 + flex-direction: column; 12 + gap: .5em; 13 + 14 + border-radius: 16px; 15 + background: var(--bg-2); 16 + box-shadow: var(--shadow-md); 17 + 18 + transition: background .1s ease-out, color .1s ease-out; 19 + } 20 + 21 + #tracks .track h3, 22 + #tracks .track p { 23 + margin: 0; 24 + } 25 + 26 + #tracks h2.track-title { 27 + margin: 0; 28 + display: flex; 29 + gap: .5em; 30 + } 31 + 32 + #tracks h2.track-title .track-number { 33 + opacity: .5; 34 + } 35 + 36 + #tracks a:hover { 37 + text-decoration: underline; 38 + } 39 + 40 + #tracks .track-album { 41 + margin-left: auto; 42 + font-style: italic; 43 + font-size: .75em; 44 + opacity: .5; 45 + } 46 + 47 + #tracks .track-album.empty { 48 + color: #ff2020; 49 + opacity: 1; 50 + } 51 + 52 + #tracks .track-description { 53 + font-style: italic; 54 + } 55 + 56 + #tracks .track-lyrics { 57 + max-height: 10em; 58 + overflow-y: scroll; 59 + } 60 + 61 + #tracks .track .empty { 62 + opacity: 0.75; 63 + } 64 + 65 + 66 + .card h2.track-title { 67 + margin: 0; 68 + display: flex; 69 + flex-direction: row; 70 + /* 71 + justify-content: space-between; 72 + */ 73 + } 74 + 75 + /* 76 + .track { 77 + margin-bottom: 1em; 78 + padding: 1em; 79 + display: flex; 80 + flex-direction: column; 81 + gap: .5em; 82 + 83 + border-radius: 8px; 84 + background-color: var(--bg-2); 85 + box-shadow: var(--shadow-md); 86 + 87 + transition: color .1s ease-out, background-color .1s ease-out; 88 + } 89 + 90 + .track p { 91 + margin: 0; 92 + } 93 + 94 + .track-id { 95 + width: fit-content; 96 + font-family: "Monaspace Argon", monospace; 97 + font-size: .8em; 98 + font-style: italic; 99 + line-height: 1em; 100 + user-select: all; 101 + } 102 + 103 + .track-album { 104 + margin-left: auto; 105 + font-style: italic; 106 + font-size: .75em; 107 + opacity: .5; 108 + } 109 + 110 + .track-album.empty { 111 + color: #ff2020; 112 + opacity: 1; 113 + } 114 + 115 + .track-description { 116 + font-style: italic; 117 + } 118 + 119 + .track-lyrics { 120 + max-height: 10em; 121 + overflow-y: scroll; 122 + } 123 + 124 + .track .empty { 125 + opacity: 0.75; 126 + } 127 + */
-125
admin/templates.go
··· 1 - package admin 2 - 3 - import ( 4 - "arimelody-web/log" 5 - "fmt" 6 - "html/template" 7 - "path/filepath" 8 - "strings" 9 - "time" 10 - ) 11 - 12 - var indexTemplate = template.Must(template.ParseFiles( 13 - filepath.Join("admin", "views", "layout.html"), 14 - filepath.Join("views", "prideflag.html"), 15 - filepath.Join("admin", "components", "release", "release-list-item.html"), 16 - filepath.Join("admin", "views", "index.html"), 17 - )) 18 - 19 - var loginTemplate = template.Must(template.ParseFiles( 20 - filepath.Join("admin", "views", "layout.html"), 21 - filepath.Join("views", "prideflag.html"), 22 - filepath.Join("admin", "views", "login.html"), 23 - )) 24 - var loginTOTPTemplate = template.Must(template.ParseFiles( 25 - filepath.Join("admin", "views", "layout.html"), 26 - filepath.Join("views", "prideflag.html"), 27 - filepath.Join("admin", "views", "login-totp.html"), 28 - )) 29 - var registerTemplate = template.Must(template.ParseFiles( 30 - filepath.Join("admin", "views", "layout.html"), 31 - filepath.Join("views", "prideflag.html"), 32 - filepath.Join("admin", "views", "register.html"), 33 - )) 34 - var logoutTemplate = template.Must(template.ParseFiles( 35 - filepath.Join("admin", "views", "layout.html"), 36 - filepath.Join("views", "prideflag.html"), 37 - filepath.Join("admin", "views", "logout.html"), 38 - )) 39 - var accountTemplate = template.Must(template.ParseFiles( 40 - filepath.Join("admin", "views", "layout.html"), 41 - filepath.Join("views", "prideflag.html"), 42 - filepath.Join("admin", "views", "edit-account.html"), 43 - )) 44 - var totpSetupTemplate = template.Must(template.ParseFiles( 45 - filepath.Join("admin", "views", "layout.html"), 46 - filepath.Join("views", "prideflag.html"), 47 - filepath.Join("admin", "views", "totp-setup.html"), 48 - )) 49 - var totpConfirmTemplate = template.Must(template.ParseFiles( 50 - filepath.Join("admin", "views", "layout.html"), 51 - filepath.Join("views", "prideflag.html"), 52 - filepath.Join("admin", "views", "totp-confirm.html"), 53 - )) 54 - 55 - var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{ 56 - "parseLevel": func(level log.LogLevel) string { 57 - switch level { 58 - case log.LEVEL_INFO: 59 - return "INFO" 60 - case log.LEVEL_WARN: 61 - return "WARN" 62 - } 63 - return fmt.Sprintf("%d?", level) 64 - }, 65 - "titleCase": func(logType string) string { 66 - runes := []rune(logType) 67 - for i, r := range runes { 68 - if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' { 69 - runes[i] = r + ('A' - 'a') 70 - } 71 - } 72 - return string(runes) 73 - }, 74 - "lower": func(str string) string { return strings.ToLower(str) }, 75 - "prettyTime": func(t time.Time) string { 76 - // return t.Format("2006-01-02 15:04:05") 77 - // return t.Format("15:04:05, 2 Jan 2006") 78 - return t.Format("02 Jan 2006, 15:04:05") 79 - }, 80 - }).ParseFiles( 81 - filepath.Join("admin", "views", "layout.html"), 82 - filepath.Join("views", "prideflag.html"), 83 - filepath.Join("admin", "views", "logs.html"), 84 - )) 85 - 86 - var releaseTemplate = template.Must(template.ParseFiles( 87 - filepath.Join("admin", "views", "layout.html"), 88 - filepath.Join("views", "prideflag.html"), 89 - filepath.Join("admin", "views", "edit-release.html"), 90 - )) 91 - var artistTemplate = template.Must(template.ParseFiles( 92 - filepath.Join("admin", "views", "layout.html"), 93 - filepath.Join("views", "prideflag.html"), 94 - filepath.Join("admin", "views", "edit-artist.html"), 95 - )) 96 - var trackTemplate = template.Must(template.ParseFiles( 97 - filepath.Join("admin", "views", "layout.html"), 98 - filepath.Join("views", "prideflag.html"), 99 - filepath.Join("admin", "components", "release", "release-list-item.html"), 100 - filepath.Join("admin", "views", "edit-track.html"), 101 - )) 102 - 103 - var editCreditsTemplate = template.Must(template.ParseFiles( 104 - filepath.Join("admin", "components", "credits", "editcredits.html"), 105 - )) 106 - var addCreditTemplate = template.Must(template.ParseFiles( 107 - filepath.Join("admin", "components", "credits", "addcredit.html"), 108 - )) 109 - var newCreditTemplate = template.Must(template.ParseFiles( 110 - filepath.Join("admin", "components", "credits", "newcredit.html"), 111 - )) 112 - 113 - var editLinksTemplate = template.Must(template.ParseFiles( 114 - filepath.Join("admin", "components", "links", "editlinks.html"), 115 - )) 116 - 117 - var editTracksTemplate = template.Must(template.ParseFiles( 118 - filepath.Join("admin", "components", "tracks", "edittracks.html"), 119 - )) 120 - var addTrackTemplate = template.Must(template.ParseFiles( 121 - filepath.Join("admin", "components", "tracks", "addtrack.html"), 122 - )) 123 - var newTrackTemplate = template.Must(template.ParseFiles( 124 - filepath.Join("admin", "components", "tracks", "newtrack.html"), 125 - ))
+26
admin/templates/html/artists.html
··· 1 + {{define "head"}} 2 + <title>Artists - ari melody 💫</title> 3 + <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 + <link rel="stylesheet" href="/admin/static/artists.css"> 5 + {{end}} 6 + 7 + {{define "content"}} 8 + <main> 9 + <header> 10 + <h1>Artists <small>({{len .Artists}} total)</small></h2> 11 + <a class="button new" id="create-artist">Create New</a> 12 + </header> 13 + 14 + {{if .Artists}} 15 + <div class="artists-group"> 16 + {{range .Artists}} 17 + {{block "artist" .}}{{end}} 18 + {{end}} 19 + </div> 20 + {{else}} 21 + <p>There are no artists.</p> 22 + {{end}} 23 + </main> 24 + 25 + <script type="module" src="/admin/static/artists.js"></script> 26 + {{end}}
+6
admin/templates/html/components/artist/artist.html
··· 1 + {{define "artist"}} 2 + <div class="artist"> 3 + <img src="{{.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar"> 4 + <a href="/admin/artists/{{.ID}}" class="artist-name">{{.Name}}</a> 5 + </div> 6 + {{end}}
+24
admin/templates/html/components/track/track.html
··· 1 + {{define "track"}} 2 + <div class="track" data-id="{{.ID}}"> 3 + <h2 class="track-title"> 4 + {{if .Number}} 5 + <span class="track-number">{{.Number}}</span> 6 + {{end}} 7 + <a href="/admin/tracks/{{.ID}}">{{.Title}}</a> 8 + </h2> 9 + 10 + <h3>Description</h3> 11 + {{if .Description}} 12 + <p class="track-description">{{.GetDescriptionHTML}}</p> 13 + {{else}} 14 + <p class="track-description empty">No description provided.</p> 15 + {{end}} 16 + 17 + <h3>Lyrics</h3> 18 + {{if .Lyrics}} 19 + <p class="track-lyrics">{{.GetLyricsHTML}}</p> 20 + {{else}} 21 + <p class="track-lyrics empty">There are no lyrics.</p> 22 + {{end}} 23 + </div> 24 + {{end}}
+61
admin/templates/html/index.html
··· 1 + {{define "head"}} 2 + <title>Admin - ari melody 💫</title> 3 + <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 + <link rel="stylesheet" href="/admin/static/releases.css"> 5 + <link rel="stylesheet" href="/admin/static/artists.css"> 6 + <link rel="stylesheet" href="/admin/static/tracks.css"> 7 + {{end}} 8 + 9 + {{define "content"}} 10 + <main class="dashboard"> 11 + <h1>Dashboard</h1> 12 + 13 + <div class="cards"> 14 + <div class="card" id="releases"> 15 + <div class="card-header"> 16 + <h2><a href="/admin/releases/">Releases</a> <small>({{.ReleaseCount}} total)</small></h2> 17 + <a class="button new" id="create-release">Create New</a> 18 + </div> 19 + {{if .Artists}} 20 + {{range .Releases}} 21 + {{block "release" .}}{{end}} 22 + {{end}} 23 + {{else}} 24 + <p>There are no releases.</p> 25 + {{end}} 26 + </div> 27 + 28 + <div class="card" id="artists"> 29 + <div class="card-header"> 30 + <h2><a href="/admin/artists/">Artists</a> <small>({{.ArtistCount}} total)</small></h2> 31 + <a class="button new" id="create-artist">Create New</a> 32 + </div> 33 + {{if .Artists}} 34 + <div class="artists-group"> 35 + {{range .Artists}} 36 + {{block "artist" .}}{{end}} 37 + {{end}} 38 + </div> 39 + {{else}} 40 + <p>There are no artists.</p> 41 + {{end}} 42 + </div> 43 + 44 + <div class="card" id="tracks"> 45 + <div class="card-header"> 46 + <h2><a href="/admin/tracks/">Tracks</a> <small>({{.TrackCount}} total)</small></h2> 47 + <a class="button new" id="create-track">Create New</a> 48 + </div> 49 + <p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p> 50 + <br> 51 + {{range .Tracks}} 52 + {{block "track" .}}{{end}} 53 + {{end}} 54 + </div> 55 + </div> 56 + 57 + </main> 58 + 59 + <script type="module" src="/admin/static/artists.js"></script> 60 + <script type="module" src="/admin/static/index.js"></script> 61 + {{end}}
+71
admin/templates/html/layout.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta http-equiv="content-type" content="text/html; charset=UTF-8"> 6 + <meta charset="UTF-8"> 7 + <meta http-equiv="X-UA-Compatible" content="IE=edge"> 8 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 9 + 10 + {{block "head" .}}{{end}} 11 + 12 + <link rel="stylesheet" href="/admin/static/admin.css"> 13 + <script type="module" src="/admin/static/admin.js"></script> 14 + <script type="module" src="/script/vendor/htmx.min.js"></script> 15 + </head> 16 + 17 + <body> 18 + <header> 19 + <nav id="navbar"> 20 + <a href="/" class="nav icon" aria-label="ari melody" title="Return to Home"> 21 + <img src="/img/favicon.png" alt="" width="64" height="64"> 22 + </a> 23 + <div class="nav-item{{if eq .Path "/"}} active{{end}}"> 24 + <a href="/admin">home</a> 25 + </div> 26 + {{if .Session.Account}} 27 + <div class="nav-item{{if eq .Path "/logs"}} active{{end}}"> 28 + <a href="/admin/logs">logs</a> 29 + </div> 30 + <hr> 31 + <p class="section-label">music</p> 32 + <div class="nav-item{{if hasPrefix .Path "/releases"}} active{{end}}"> 33 + <a href="/admin/releases/">releases</a> 34 + </div> 35 + <div class="nav-item{{if hasPrefix .Path "/artists"}} active{{end}}"> 36 + <a href="/admin/artists/">artists</a> 37 + </div> 38 + <div class="nav-item{{if hasPrefix .Path "/tracks"}} active{{end}}"> 39 + <a href="/admin/tracks/">tracks</a> 40 + </div> 41 + {{end}} 42 + 43 + <div class="flex-fill"></div> 44 + 45 + {{if .Session.Account}} 46 + <div class="nav-item{{if eq .Path "/account"}} active{{end}}"> 47 + <a href="/admin/account">account ({{.Session.Account.Username}})</a> 48 + </div> 49 + <div class="nav-item"> 50 + <a href="/admin/logout" id="logout">log out</a> 51 + </div> 52 + {{else}} 53 + <div class="nav-item{{if eq .Path "/login"}} active{{end}}"> 54 + <a href="/admin/login" id="login">log in</a> 55 + </div> 56 + <div class="nav-item{{if eq .Path "/register"}} active{{end}}"> 57 + <a href="/admin/register" id="register">create account</a> 58 + </div> 59 + {{end}} 60 + </nav> 61 + <button type="button" id="toggle-nav" aria-label="Navigation toggle"> 62 + <img src="/img/hamburger.svg" alt=""> 63 + </button> 64 + </header> 65 + 66 + {{block "content" .}}{{end}} 67 + 68 + {{template "prideflag"}} 69 + </body> 70 + 71 + </html>
+24
admin/templates/html/releases.html
··· 1 + {{define "head"}} 2 + <title>Releases - ari melody 💫</title> 3 + <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 + <link rel="stylesheet" href="/admin/static/releases.css"> 5 + {{end}} 6 + 7 + {{define "content"}} 8 + <main> 9 + <header> 10 + <h1>Releases <small>({{len .Releases}} total)</small></h1> 11 + <a class="button new" id="create-release">Create New</a> 12 + </header> 13 + 14 + {{if .Releases}} 15 + <div id="releases"> 16 + {{range .Releases}} 17 + {{block "release" .}}{{end}} 18 + {{end}} 19 + </div> 20 + {{else}} 21 + <p>There are no releases.</p> 22 + {{end}} 23 + </main> 24 + {{end}}
+34
admin/templates/html/tracks.html
··· 1 + {{define "head"}} 2 + <title>Releases - ari melody 💫</title> 3 + <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 + <link rel="stylesheet" href="/admin/static/tracks.css"> 5 + {{end}} 6 + 7 + {{define "content"}} 8 + <main> 9 + <header> 10 + <h1>Tracks <small>({{len .Tracks}} total)</small></h1> 11 + <a class="button new" id="create-track">Create New</a> 12 + </header> 13 + 14 + <div id="tracks"> 15 + {{range $Track := .Tracks}} 16 + <div class="track"> 17 + <h2 class="track-title"> 18 + <a href="/admin/tracks/{{$Track.ID}}">{{$Track.Title}}</a> 19 + </h2> 20 + {{if $Track.Description}} 21 + <p class="track-description">{{$Track.GetDescriptionHTML}}</p> 22 + {{else}} 23 + <p class="track-description empty">No description provided.</p> 24 + {{end}} 25 + {{if $Track.Lyrics}} 26 + <p class="track-lyrics">{{$Track.GetLyricsHTML}}</p> 27 + {{else}} 28 + <p class="track-lyrics empty">There are no lyrics.</p> 29 + {{end}} 30 + </div> 31 + {{end}} 32 + </div> 33 + </main> 34 + {{end}}
+191
admin/templates/templates.go
··· 1 + package templates 2 + 3 + import ( 4 + "arimelody-web/log" 5 + _ "embed" 6 + "fmt" 7 + "html/template" 8 + "strings" 9 + "time" 10 + ) 11 + 12 + //go:embed "html/layout.html" 13 + var layoutHTML string 14 + //go:embed "html/prideflag.html" 15 + var prideflagHTML string 16 + 17 + //go:embed "html/index.html" 18 + var indexHTML string 19 + 20 + //go:embed "html/register.html" 21 + var registerHTML string 22 + //go:embed "html/login.html" 23 + var loginHTML string 24 + //go:embed "html/login-totp.html" 25 + var loginTotpHTML string 26 + //go:embed "html/totp-confirm.html" 27 + var totpConfirmHTML string 28 + //go:embed "html/totp-setup.html" 29 + var totpSetupHTML string 30 + //go:embed "html/logout.html" 31 + var logoutHTML string 32 + 33 + //go:embed "html/logs.html" 34 + var logsHTML string 35 + 36 + //go:embed "html/edit-account.html" 37 + var editAccountHTML string 38 + 39 + //go:embed "html/releases.html" 40 + var releasesHTML string 41 + //go:embed "html/artists.html" 42 + var artistsHTML string 43 + //go:embed "html/tracks.html" 44 + var tracksHTML string 45 + 46 + //go:embed "html/edit-release.html" 47 + var editReleaseHTML string 48 + //go:embed "html/edit-artist.html" 49 + var editArtistHTML string 50 + //go:embed "html/edit-track.html" 51 + var editTrackHTML string 52 + 53 + //go:embed "html/components/credit/newcredit.html" 54 + var componentNewCreditHTML string 55 + //go:embed "html/components/credit/addcredit.html" 56 + var componentAddCreditHTML string 57 + //go:embed "html/components/credit/editcredits.html" 58 + var componentEditCreditsHTML string 59 + 60 + //go:embed "html/components/link/editlinks.html" 61 + var componentEditLinksHTML string 62 + 63 + //go:embed "html/components/release/release.html" 64 + var componentReleaseHTML string 65 + //go:embed "html/components/artist/artist.html" 66 + var componentArtistHTML string 67 + //go:embed "html/components/track/track.html" 68 + var componentTrackHTML string 69 + 70 + //go:embed "html/components/track/newtrack.html" 71 + var componentNewTrackHTML string 72 + //go:embed "html/components/track/addtrack.html" 73 + var componentAddTrackHTML string 74 + //go:embed "html/components/track/edittracks.html" 75 + var componentEditTracksHTML string 76 + 77 + var BaseTemplate = template.Must( 78 + template.New("base").Funcs( 79 + template.FuncMap{ 80 + "hasPrefix": strings.HasPrefix, 81 + }, 82 + ).Parse(strings.Join([]string{ 83 + layoutHTML, 84 + prideflagHTML, 85 + }, "\n"))) 86 + 87 + var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( 88 + strings.Join([]string{ 89 + indexHTML, 90 + componentReleaseHTML, 91 + componentArtistHTML, 92 + componentTrackHTML, 93 + }, "\n"), 94 + )) 95 + 96 + 97 + 98 + var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML)) 99 + var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML)) 100 + var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML)) 101 + var LogoutTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(logoutHTML)) 102 + var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editAccountHTML)) 103 + var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML)) 104 + var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML)) 105 + 106 + 107 + 108 + var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{ 109 + "parseLevel": parseLevel, 110 + "titleCase": titleCase, 111 + "toLower": toLower, 112 + "prettyTime": prettyTime, 113 + }).Parse(logsHTML)) 114 + 115 + 116 + 117 + var ReleasesTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( 118 + strings.Join([]string{ 119 + releasesHTML, 120 + componentReleaseHTML, 121 + }, "\n"), 122 + )) 123 + var ArtistsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( 124 + strings.Join([]string{ 125 + artistsHTML, 126 + componentArtistHTML, 127 + }, "\n"), 128 + )) 129 + var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( 130 + strings.Join([]string{ 131 + tracksHTML, 132 + componentTrackHTML, 133 + }, "\n"), 134 + )) 135 + 136 + 137 + 138 + var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( 139 + strings.Join([]string{ 140 + editReleaseHTML, 141 + componentTrackHTML, 142 + }, "\n"), 143 + )) 144 + var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML)) 145 + var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( 146 + strings.Join([]string{ 147 + editTrackHTML, 148 + componentReleaseHTML, 149 + }, "\n"), 150 + )) 151 + 152 + 153 + 154 + var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML)) 155 + var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML)) 156 + var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML)) 157 + 158 + var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditLinksHTML)) 159 + 160 + var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML)) 161 + var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML)) 162 + var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewTrackHTML)) 163 + 164 + 165 + 166 + func parseLevel(level log.LogLevel) string { 167 + switch level { 168 + case log.LEVEL_INFO: 169 + return "INFO" 170 + case log.LEVEL_WARN: 171 + return "WARN" 172 + } 173 + return fmt.Sprintf("%d?", level) 174 + } 175 + func titleCase(logType string) string { 176 + runes := []rune(logType) 177 + for i, r := range runes { 178 + if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' { 179 + runes[i] = r + ('A' - 'a') 180 + } 181 + } 182 + return string(runes) 183 + } 184 + func toLower(str string) string { 185 + return strings.ToLower(str) 186 + } 187 + func prettyTime(t time.Time) string { 188 + // return t.Format("2006-01-02 15:04:05") 189 + // return t.Format("15:04:05, 2 Jan 2006") 190 + return t.Format("02 Jan 2006, 15:04:05") 191 + }
+51 -18
admin/trackhttp.go
··· 1 1 package admin 2 2 3 3 import ( 4 - "fmt" 5 - "net/http" 6 - "strings" 4 + "fmt" 5 + "net/http" 6 + "strings" 7 7 8 - "arimelody-web/model" 9 - "arimelody-web/controller" 8 + "arimelody-web/admin/templates" 9 + "arimelody-web/controller" 10 + "arimelody-web/model" 10 11 ) 11 12 12 - func serveTrack(app *model.AppState) http.Handler { 13 + func serveTracks(app *model.AppState) http.Handler { 14 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 + session := r.Context().Value("session").(*model.Session) 16 + 17 + slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/tracks")[1:], "/") 18 + trackID := slices[0] 19 + 20 + if len(trackID) > 0 { 21 + serveTrack(app, trackID).ServeHTTP(w, r) 22 + return 23 + } 24 + 25 + tracks, err := controller.GetAllTracks(app.DB) 26 + if err != nil { 27 + fmt.Printf("WARN: Failed to fetch tracks: %s\n", err) 28 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + type TracksResponse struct { 33 + adminPageData 34 + Tracks []*model.Track 35 + } 36 + 37 + err = templates.TracksTemplate.Execute(w, TracksResponse{ 38 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 39 + Tracks: tracks, 40 + }) 41 + if err != nil { 42 + fmt.Printf("WARN: Failed to serve admin tracks page: %s\n", err) 43 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 44 + } 45 + }) 46 + } 47 + 48 + func serveTrack(app *model.AppState, trackID string) http.Handler { 13 49 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 - slices := strings.Split(r.URL.Path[1:], "/") 15 - id := slices[0] 16 - track, err := controller.GetTrack(app.DB, id) 50 + session := r.Context().Value("session").(*model.Session) 51 + 52 + track, err := controller.GetTrack(app.DB, trackID) 17 53 if err != nil { 18 - fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) 54 + fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err) 19 55 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 20 56 return 21 57 } ··· 26 62 27 63 releases, err := controller.GetTrackReleases(app.DB, track.ID, true) 28 64 if err != nil { 29 - fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) 65 + fmt.Printf("WARN: Failed to fetch releases for track %s: %s\n", trackID, err) 30 66 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 31 67 return 32 68 } 33 69 34 70 type TrackResponse struct { 35 - Session *model.Session 71 + adminPageData 36 72 Track *model.Track 37 73 Releases []*model.Release 38 74 } 39 75 40 - session := r.Context().Value("session").(*model.Session) 41 - 42 - err = trackTemplate.Execute(w, TrackResponse{ 43 - Session: session, 76 + err = templates.EditTrackTemplate.Execute(w, TrackResponse{ 77 + adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, 44 78 Track: track, 45 79 Releases: releases, 46 80 }) 47 81 if err != nil { 48 - fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) 82 + fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err) 49 83 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 50 84 } 51 85 }) 52 86 } 53 -
+19 -12
admin/views/edit-account.html admin/templates/html/edit-account.html
··· 14 14 {{end}} 15 15 <h1>Account Settings ({{.Session.Account.Username}})</h1> 16 16 17 - <div class="card-title"> 18 - <h2>Change Password</h2> 19 - </div> 20 17 <div class="card"> 18 + <div class="card-header"> 19 + <h2>Change Password</h2> 20 + </div> 21 21 <form action="/admin/account/password" method="POST" id="change-password"> 22 22 <label for="current-password">Current Password</label> 23 23 <input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required> ··· 27 27 28 28 <label for="confirm-password">Confirm Password</label> 29 29 <input type="password" id="confirm-password" value="" autocomplete="new-password" required> 30 + 31 + <br> 30 32 31 33 <button type="submit" class="save">Change Password</button> 32 34 </form> 33 35 </div> 34 36 35 - <div class="card-title"> 36 - <h2>MFA Devices</h2> 37 - </div> 38 37 <div class="card mfa-devices"> 38 + <div class="card-header"> 39 + <h2>MFA Devices</h2> 40 + </div> 39 41 {{if .TOTPs}} 40 42 {{range .TOTPs}} 41 43 <div class="mfa-device"> ··· 44 46 <p class="mfa-device-date">Added: {{.CreatedAtString}}</p> 45 47 </div> 46 48 <div> 47 - <a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a> 49 + <form method="POST" action="/admin/account/totp-delete"> 50 + <input type="text" name="totp-name" value="{{.TOTP.Name}}" hidden> 51 + <button type="submit" class="delete">Delete</button> 52 + </form> 48 53 </div> 49 54 </div> 50 55 {{end}} ··· 52 57 <p>You have no MFA devices.</p> 53 58 {{end}} 54 59 55 - <div> 60 + <div class="mfa-actions"> 56 61 <button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button> 57 62 <a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a> 58 63 </div> 59 64 </div> 60 65 61 - <div class="card-title"> 62 - <h2>Danger Zone</h2> 63 - </div> 64 66 <div class="card danger"> 67 + <div class="card-header"> 68 + <h2>Danger Zone</h2> 69 + </div> 65 70 <p> 66 71 Clicking the button below will delete your account. 67 72 This action is <strong>irreversible</strong>. 68 73 You will need to enter your password and TOTP below. 69 74 </p> 70 - <form action="/admin/account/delete" method="POST"> 75 + <form action="/admin/account/delete" method="POST" id="delete-account"> 71 76 <label for="password">Password</label> 72 77 <input type="password" name="password" value="" autocomplete="current-password" required> 73 78 74 79 <label for="totp">TOTP</label> 75 80 <input type="text" name="totp" value="" autocomplete="one-time-code" required> 81 + 82 + <br> 76 83 77 84 <button type="submit" class="delete">Delete Account</button> 78 85 </form>
+10 -9
admin/views/edit-artist.html admin/templates/html/edit-artist.html
··· 2 2 <title>Editing {{.Artist.Name}} - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon"> 4 4 <link rel="stylesheet" href="/admin/static/edit-artist.css"> 5 + <link rel="stylesheet" href="/admin/static/artists.css"> 5 6 {{end}} 6 7 7 8 {{define "content"}} ··· 29 30 </div> 30 31 </div> 31 32 32 - <div class="card-title"> 33 - <h2>Featured in</h2> 34 - </div> 35 - <div class="card releases"> 33 + <div class="card" id="releases"> 34 + <div class="card-header"> 35 + <h2>Featured in</h2> 36 + </div> 36 37 {{if .Credits}} 37 38 {{range .Credits}} 38 39 <div class="credit"> 39 40 <img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork"> 40 41 <div class="credit-info"> 41 - <h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3> 42 + <h3 class="credit-name"><a href="/admin/releases/{{.Release.ID}}">{{.Release.Title}}</a></h3> 42 43 <p class="credit-artists">{{.Release.PrintArtists true true}}</p> 43 44 <p class="artist-role"> 44 45 Role: {{.Role}} ··· 54 55 {{end}} 55 56 </div> 56 57 57 - <div class="card-title"> 58 - <h2>Danger Zone</h2> 59 - </div> 60 - <div class="card danger"> 58 + <div class="card" id="danger"> 59 + <div class="card-header"> 60 + <h2>Danger Zone</h2> 61 + </div> 61 62 <p> 62 63 Clicking the button below will delete this artist. 63 64 This action is <strong>irreversible</strong>.
+44 -58
admin/views/edit-release.html admin/templates/html/edit-release.html
··· 2 2 <title>Editing {{.Release.Title}} - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon"> 4 4 <link rel="stylesheet" href="/admin/static/edit-release.css"> 5 + <link rel="stylesheet" href="/admin/static/releases.css"> 6 + <link rel="stylesheet" href="/admin/static/tracks.css"> 5 7 {{end}} 6 8 7 9 {{define "content"}} 8 10 <main> 11 + <h1>Editing {{.Release.Title}}</h1> 9 12 10 13 <div id="release" data-id="{{.Release.ID}}"> 11 14 <div class="release-artwork"> ··· 97 100 </div> 98 101 </div> 99 102 100 - <div class="card-title"> 101 - <h2>Credits ({{len .Release.Credits}})</h2> 102 - <a class="button edit" 103 - href="/admin/release/{{.Release.ID}}/editcredits" 104 - hx-get="/admin/release/{{.Release.ID}}/editcredits" 105 - hx-target="body" 106 - hx-swap="beforeend" 107 - >Edit</a> 108 - </div> 109 - <div class="card credits"> 103 + <div class="card" id="credits"> 104 + <div class="card-header"> 105 + <h2>Credits <small>({{len .Release.Credits}} total)</small></h2> 106 + <a class="button edit" 107 + href="/admin/releases/{{.Release.ID}}/editcredits" 108 + hx-get="/admin/releases/{{.Release.ID}}/editcredits" 109 + hx-target="body" 110 + hx-swap="beforeend" 111 + >Edit</a> 112 + </div> 110 113 {{range .Release.Credits}} 111 114 <div class="credit"> 112 115 <img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar"> 113 116 <div class="credit-info"> 114 - <p class="artist-name"><a href="/admin/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p> 117 + <p class="artist-name"><a href="/admin/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p> 115 118 <p class="artist-role"> 116 119 {{.Role}} 117 120 {{if .Primary}} ··· 126 129 {{end}} 127 130 </div> 128 131 129 - <div class="card-title"> 130 - <h2>Links ({{len .Release.Links}})</h2> 131 - <a class="button edit" 132 - href="/admin/release/{{.Release.ID}}/editlinks" 133 - hx-get="/admin/release/{{.Release.ID}}/editlinks" 134 - hx-target="body" 135 - hx-swap="beforeend" 136 - >Edit</a> 137 - </div> 138 - <div class="card links"> 139 - {{range .Release.Links}} 140 - <a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a> 141 - {{end}} 132 + <div class="card" id="links"> 133 + <div class="card-header"> 134 + <h2>Links ({{len .Release.Links}})</h2> 135 + <a class="button edit" 136 + href="/admin/releases/{{.Release.ID}}/editlinks" 137 + hx-get="/admin/releases/{{.Release.ID}}/editlinks" 138 + hx-target="body" 139 + hx-swap="beforeend" 140 + >Edit</a> 141 + </div> 142 + <ul> 143 + {{range .Release.Links}} 144 + <a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a> 145 + {{end}} 146 + </ul> 142 147 </div> 143 148 144 - <div class="card-title" id="tracks"> 145 - <h2>Tracklist ({{len .Release.Tracks}})</h2> 146 - <a class="button edit" 147 - href="/admin/release/{{.Release.ID}}/edittracks" 148 - hx-get="/admin/release/{{.Release.ID}}/edittracks" 149 - hx-target="body" 150 - hx-swap="beforeend" 151 - >Edit</a> 152 - </div> 153 - <div class="card tracks"> 149 + <div class="card" id="tracks"> 150 + <div class="card-header" id="tracks"> 151 + <h2>Tracklist ({{len .Release.Tracks}})</h2> 152 + <a class="button edit" 153 + href="/admin/releases/{{.Release.ID}}/edittracks" 154 + hx-get="/admin/releases/{{.Release.ID}}/edittracks" 155 + hx-target="body" 156 + hx-swap="beforeend" 157 + >Edit</a> 158 + </div> 154 159 {{range $i, $track := .Release.Tracks}} 155 - <div class="track" data-id="{{$track.ID}}"> 156 - <h2 class="track-title"> 157 - <span class="track-number">{{.Add $i 1}}</span> 158 - <a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a> 159 - </h2> 160 - 161 - <h3>Description</h3> 162 - {{if $track.Description}} 163 - <p class="track-description">{{$track.GetDescriptionHTML}}</p> 164 - {{else}} 165 - <p class="track-description empty">No description provided.</p> 166 - {{end}} 167 - 168 - <h3>Lyrics</h3> 169 - {{if $track.Lyrics}} 170 - <p class="track-lyrics">{{$track.GetLyricsHTML}}</p> 171 - {{else}} 172 - <p class="track-lyrics empty">There are no lyrics.</p> 173 - {{end}} 174 - </div> 160 + {{block "track" .}}{{end}} 175 161 {{end}} 176 162 </div> 177 163 178 - <div class="card-title"> 179 - <h2>Danger Zone</h2> 180 - </div> 181 - <div class="card danger"> 164 + <div class="card" id="danger"> 165 + <div class="card-header"> 166 + <h2>Danger Zone</h2> 167 + </div> 182 168 <p> 183 169 Clicking the button below will delete this release. 184 170 This action is <strong>irreversible</strong>.
+8 -6
admin/views/edit-track.html admin/templates/html/edit-track.html
··· 2 2 <title>Editing Track - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 4 <link rel="stylesheet" href="/admin/static/edit-track.css"> 5 + <link rel="stylesheet" href="/admin/static/tracks.css"> 6 + <link rel="stylesheet" href="/admin/static/releases.css"> 5 7 {{end}} 6 8 7 9 {{define "content"}} ··· 39 41 </div> 40 42 </div> 41 43 42 - <div class="card-title"> 43 - <h2>Featured in</h2> 44 - </div> 45 44 <div class="card releases"> 45 + <div class="card-header"> 46 + <h2>Featured in</h2> 47 + </div> 46 48 {{if .Releases}} 47 49 {{range .Releases}} 48 50 {{block "release" .}}{{end}} ··· 52 54 {{end}} 53 55 </div> 54 56 55 - <div class="card-title"> 56 - <h2>Danger Zone</h2> 57 - </div> 58 57 <div class="card danger"> 58 + <div class="card-header"> 59 + <h2>Danger Zone</h2> 60 + </div> 59 61 <p> 60 62 Clicking the button below will delete this track. 61 63 This action is <strong>irreversible</strong>.
-72
admin/views/index.html
··· 1 - {{define "head"}} 2 - <title>Admin - ari melody 💫</title> 3 - <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 - <link rel="stylesheet" href="/admin/static/index.css"> 5 - {{end}} 6 - 7 - {{define "content"}} 8 - <main> 9 - 10 - <div class="card-title"> 11 - <h1>Releases</h1> 12 - <a class="button new" id="create-release">Create New</a> 13 - </div> 14 - <div class="card releases"> 15 - {{range .Releases}} 16 - {{block "release" .}}{{end}} 17 - {{end}} 18 - {{if not .Releases}} 19 - <p>There are no releases.</p> 20 - {{end}} 21 - </div> 22 - 23 - <div class="card-title"> 24 - <h1>Artists</h1> 25 - <a class="button new" id="create-artist">Create New</a> 26 - </div> 27 - <div class="card artists"> 28 - {{range $Artist := .Artists}} 29 - <div class="artist"> 30 - <img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar"> 31 - <a href="/admin/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a> 32 - </div> 33 - {{end}} 34 - {{if not .Artists}} 35 - <p>There are no artists.</p> 36 - {{end}} 37 - </div> 38 - 39 - <div class="card-title"> 40 - <h1>Tracks</h1> 41 - <a class="button new" id="create-track">Create New</a> 42 - </div> 43 - <div class="card tracks"> 44 - <p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p> 45 - <br> 46 - {{range $Track := .Tracks}} 47 - <div class="track"> 48 - <h2 class="track-title"> 49 - <a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a> 50 - </h2> 51 - {{if $Track.Description}} 52 - <p class="track-description">{{$Track.GetDescriptionHTML}}</p> 53 - {{else}} 54 - <p class="track-description empty">No description provided.</p> 55 - {{end}} 56 - {{if $Track.Lyrics}} 57 - <p class="track-lyrics">{{$Track.GetLyricsHTML}}</p> 58 - {{else}} 59 - <p class="track-lyrics empty">There are no lyrics.</p> 60 - {{end}} 61 - </div> 62 - {{end}} 63 - {{if not .Artists}} 64 - <p>There are no artists.</p> 65 - {{end}} 66 - </div> 67 - 68 - </main> 69 - 70 - <script type="module" src="/admin/static/admin.js"></script> 71 - <script type="module" src="/admin/static/index.js"></script> 72 - {{end}}
-54
admin/views/layout.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - 4 - <head> 5 - <meta http-equiv="content-type" content="text/html; charset=UTF-8"> 6 - <meta charset="UTF-8"> 7 - <meta http-equiv="X-UA-Compatible" content="IE=edge"> 8 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 9 - 10 - {{block "head" .}}{{end}} 11 - 12 - <link rel="stylesheet" href="/admin/static/admin.css"> 13 - <script type="module" src="/script/vendor/htmx.min.js"></script> 14 - </head> 15 - 16 - <body> 17 - <header> 18 - <nav> 19 - <img src="/img/favicon.png" alt="" class="icon"> 20 - <div class="nav-item"> 21 - <a href="/">ari melody</a> 22 - </div> 23 - <div class="nav-item"> 24 - <a href="/admin">home</a> 25 - </div> 26 - {{if .Session.Account}} 27 - <div class="nav-item"> 28 - <a href="/admin/logs">logs</a> 29 - </div> 30 - {{end}} 31 - 32 - <div class="flex-fill"></div> 33 - 34 - {{if .Session.Account}} 35 - <div class="nav-item"> 36 - <a href="/admin/account">account ({{.Session.Account.Username}})</a> 37 - </div> 38 - <div class="nav-item"> 39 - <a href="/admin/logout" id="logout">log out</a> 40 - </div> 41 - {{else}} 42 - <div class="nav-item"> 43 - <a href="/admin/register" id="register">create account</a> 44 - </div> 45 - {{end}} 46 - </nav> 47 - </header> 48 - 49 - {{block "content" .}}{{end}} 50 - 51 - {{template "prideflag"}} 52 - </body> 53 - 54 - </html>
+9 -3
admin/views/login-totp.html admin/templates/html/login-totp.html
··· 1 1 {{define "head"}} 2 2 <title>Login - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 - <link rel="stylesheet" href="/admin/static/admin.css"> 5 4 <style> 6 5 form#login-totp { 7 6 width: 100%; ··· 18 17 margin-top: 1rem; 19 18 } 20 19 21 - input { 22 - width: calc(100% - 1rem - 2px); 20 + form input { 21 + width: calc(100% - 1rem - 2px) !important; 22 + } 23 + 24 + @media screen and (max-width: 720px) { 25 + h1 { 26 + margin-top: 3em; 27 + text-align: center; 28 + } 23 29 } 24 30 </style> 25 31 {{end}}
+9 -3
admin/views/login.html admin/templates/html/login.html
··· 1 1 {{define "head"}} 2 2 <title>Login - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 - <link rel="stylesheet" href="/admin/static/admin.css"> 5 4 <style> 5 + @media screen and (max-width: 720px) { 6 + h1 { 7 + margin-top: 3em; 8 + text-align: center; 9 + } 10 + } 11 + 6 12 form#login { 7 13 width: 100%; 8 14 display: flex; ··· 18 24 margin-top: 1rem; 19 25 } 20 26 21 - input { 22 - width: calc(100% - 1rem - 2px); 27 + form input { 28 + width: calc(100% - 1rem - 2px) !important; 23 29 } 24 30 </style> 25 31 {{end}}
admin/views/logout.html admin/templates/html/logout.html
+23 -22
admin/views/logs.html admin/templates/html/logs.html
··· 1 1 {{define "head"}} 2 2 <title>Audit Logs - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 - <link rel="stylesheet" href="/admin/static/admin.css"> 5 4 <link rel="stylesheet" href="/admin/static/logs.css"> 6 5 {{end}} 7 6 ··· 9 8 <main> 10 9 <h1>Audit Logs</h1> 11 10 12 - <form action="/admin/logs" method="GET"> 11 + <form action="/admin/logs" method="GET" id="search-form"> 13 12 <div id="search"> 14 13 <input type="text" name="q" value="" placeholder="Filter by message..."> 15 14 <button type="submit" class="save">Search</button> ··· 44 43 45 44 <hr> 46 45 47 - <table id="logs"> 48 - <thead> 49 - <tr> 50 - <th class="log-time">Time</th> 51 - <th class="log-level">Level</th> 52 - <th class="log-type">Type</th> 53 - <th class="log-content">Message</th> 54 - </tr> 55 - </thead> 56 - <tbody> 57 - {{range .Logs}} 58 - <tr class="log {{lower (parseLevel .Level)}}"> 59 - <td class="log-time">{{prettyTime .CreatedAt}}</td> 60 - <td class="log-level">{{parseLevel .Level}}</td> 61 - <td class="log-type">{{titleCase .Type}}</td> 62 - <td class="log-content">{{.Content}}</td> 63 - </tr> 64 - {{end}} 65 - </tbody> 66 - </table> 46 + <div id="logs"> 47 + <table> 48 + <thead> 49 + <tr> 50 + <th class="log-time">Time</th> 51 + <th class="log-level">Level</th> 52 + <th class="log-type">Type</th> 53 + <th class="log-content">Message</th> 54 + </tr> 55 + </thead> 56 + <tbody> 57 + {{range .Logs}} 58 + <tr class="log {{toLower (parseLevel .Level)}}"> 59 + <td class="log-time">{{prettyTime .CreatedAt}}</td> 60 + <td class="log-level">{{parseLevel .Level}}</td> 61 + <td class="log-type">{{titleCase .Type}}</td> 62 + <td class="log-content">{{.Content}}</td> 63 + </tr> 64 + {{end}} 65 + </tbody> 66 + </table> 67 + </div> 67 68 </main> 68 69 {{end}}
-1
admin/views/register.html admin/templates/html/register.html
··· 1 1 {{define "head"}} 2 2 <title>Register - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 - <link rel="stylesheet" href="/admin/static/admin.css"> 5 4 <style> 6 5 p a { 7 6 color: #2a67c8;
+17 -2
admin/views/totp-confirm.html admin/templates/html/totp-confirm.html
··· 1 1 {{define "head"}} 2 2 <title>TOTP Confirmation - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 - <link rel="stylesheet" href="/admin/static/admin.css"> 5 4 <style> 6 5 .qr-code { 7 6 border: 1px solid #8888; ··· 9 8 code { 10 9 user-select: all; 11 10 } 11 + #totp-setup input { 12 + width: 3.8em; 13 + min-width: auto; 14 + font-size: 32px; 15 + font-family: 'Monaspace Argon', monospace; 16 + text-align: center; 17 + } 12 18 </style> 13 19 {{end}} 14 20 15 21 {{define "content"}} 16 22 <main> 23 + <h1>Two-Factor Authentication</h1> 24 + 17 25 {{if .Session.Error.Valid}} 18 26 <p id="error">{{html .Session.Error.String}}</p> 19 27 {{end}} ··· 40 48 <p><code>{{.TOTP.Secret}}</code></p> 41 49 42 50 <label for="totp">TOTP:</label> 43 - <input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus> 51 + <input type="text" 52 + name="totp" 53 + value="" 54 + minlength="6" 55 + maxlength="6" 56 + autocomplete="one-time-code" 57 + required 58 + autofocus> 44 59 45 60 <button type="submit" class="new">Create</button> 46 61 </form>
+2 -1
admin/views/totp-setup.html admin/templates/html/totp-setup.html
··· 1 1 {{define "head"}} 2 2 <title>TOTP Setup - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon"> 4 - <link rel="stylesheet" href="/admin/static/admin.css"> 5 4 {{end}} 6 5 7 6 {{define "content"}} 8 7 <main> 8 + <h1>Two-Factor Authentication</h1> 9 + 9 10 {{if .Session.Error.Valid}} 10 11 <p id="error">{{html .Session.Error.String}}</p> 11 12 {{end}}
+2 -3
api/api.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "errors" 6 5 "fmt" 7 6 "net/http" 8 7 "os" ··· 173 172 // check cookies first 174 173 sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) 175 174 if err != nil && err != http.ErrNoCookie { 176 - return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v\n", err)) 175 + return nil, fmt.Errorf("Failed to retrieve session cookie: %v\n", err) 177 176 } 178 177 if sessionCookie != nil { 179 178 token = sessionCookie.Value ··· 188 187 session, err := controller.GetSession(app.DB, token) 189 188 190 189 if err != nil && !strings.Contains(err.Error(), "no rows") { 191 - return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v\n", err)) 190 + return nil, fmt.Errorf("Failed to retrieve session: %v\n", err) 192 191 } 193 192 194 193 if session != nil {
+1 -1
api/uploads.go
··· 50 50 return "", nil 51 51 } 52 52 53 - app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext) 53 + app.Log.Info(log.TYPE_FILES, "\"%s\" created.", imagePath) 54 54 55 55 return filename, nil 56 56 }
+5
controller/artist.go
··· 29 29 30 30 return artists, nil 31 31 } 32 + func GetArtistCount(db *sqlx.DB) (int, error) { 33 + var count int 34 + err := db.Get(&count, "SELECT count(*) FROM artist") 35 + return count, err 36 + } 32 37 33 38 func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) { 34 39 var artists = []*model.Artist{}
+1 -1
controller/controller.go
··· 5 5 func GenerateAlnumString(length int) []byte { 6 6 const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 7 7 res := []byte{} 8 - for i := 0; i < length; i++ { 8 + for range length { 9 9 res = append(res, CHARS[rand.Intn(len(CHARS))]) 10 10 } 11 11 return res
+9 -5
controller/migrator.go
··· 1 1 package controller 2 2 3 3 import ( 4 - "fmt" 5 - "os" 6 - "time" 4 + "embed" 5 + "fmt" 6 + "os" 7 + "time" 7 8 8 - "github.com/jmoiron/sqlx" 9 + "github.com/jmoiron/sqlx" 9 10 ) 10 11 11 12 const DB_VERSION int = 4 ··· 55 56 fmt.Printf("Database schema up to date.\n") 56 57 } 57 58 59 + //go:embed "schema-migration" 60 + var schemaFS embed.FS 61 + 58 62 func ApplyMigration(db *sqlx.DB, scriptFile string) { 59 63 fmt.Printf("Applying schema migration %s...\n", scriptFile) 60 64 61 - bytes, err := os.ReadFile("schema-migration/" + scriptFile + ".sql") 65 + bytes, err := schemaFS.ReadFile("schema-migration/" + scriptFile + ".sql") 62 66 if err != nil { 63 67 fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err) 64 68 os.Exit(1)
+34 -35
controller/qr.go
··· 2 2 3 3 import ( 4 4 "encoding/base64" 5 - "image" 6 - "image/color" 5 + // "image" 6 + // "image/color" 7 7 8 8 "github.com/skip2/go-qrcode" 9 9 ) ··· 18 18 } 19 19 20 20 // vvv DEPRECATED vvv 21 - 22 - const margin = 4 23 - 24 - type QRCodeECCLevel int64 25 - const ( 26 - LOW QRCodeECCLevel = iota 27 - MEDIUM 28 - QUARTILE 29 - HIGH 30 - ) 31 - 32 - func drawLargeAlignmentSquare(x int, y int, img *image.Gray) { 33 - for yi := range 7 { 34 - for xi := range 7 { 35 - if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) { 36 - img.Set(x + xi, y + yi, color.Black) 37 - } else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) { 38 - img.Set(x + xi, y + yi, color.Black) 39 - } 40 - } 41 - } 42 - } 43 - 44 - func drawSmallAlignmentSquare(x int, y int, img *image.Gray) { 45 - for yi := range 5 { 46 - for xi := range 5 { 47 - if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) { 48 - img.Set(x + xi, y + yi, color.Black) 49 - } 50 - } 51 - } 52 - img.Set(x + 2, y + 2, color.Black) 53 - } 21 + // const margin = 4 22 + // 23 + // type QRCodeECCLevel int64 24 + // const ( 25 + // LOW QRCodeECCLevel = iota 26 + // MEDIUM 27 + // QUARTILE 28 + // HIGH 29 + // ) 30 + // 31 + // func drawLargeAlignmentSquare(x int, y int, img *image.Gray) { 32 + // for yi := range 7 { 33 + // for xi := range 7 { 34 + // if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) { 35 + // img.Set(x + xi, y + yi, color.Black) 36 + // } else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) { 37 + // img.Set(x + xi, y + yi, color.Black) 38 + // } 39 + // } 40 + // } 41 + // } 42 + // 43 + // func drawSmallAlignmentSquare(x int, y int, img *image.Gray) { 44 + // for yi := range 5 { 45 + // for xi := range 5 { 46 + // if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) { 47 + // img.Set(x + xi, y + yi, color.Black) 48 + // } 49 + // } 50 + // } 51 + // img.Set(x + 2, y + 2, color.Black) 52 + // }
+17 -7
controller/release.go
··· 1 1 package controller 2 2 3 3 import ( 4 - "errors" 5 4 "fmt" 6 5 7 6 "arimelody-web/model" ··· 21 20 // get credits 22 21 credits, err := GetReleaseCredits(db, id) 23 22 if err != nil { 24 - return nil, errors.New(fmt.Sprintf("Credits: %s", err)) 23 + return nil, fmt.Errorf("Credits: %s", err) 25 24 } 26 25 for _, credit := range credits { 27 26 release.Credits = append(release.Credits, credit) ··· 30 29 // get tracks 31 30 tracks, err := GetReleaseTracks(db, id) 32 31 if err != nil { 33 - return nil, errors.New(fmt.Sprintf("Tracks: %s", err)) 32 + return nil, fmt.Errorf("Tracks: %s", err) 34 33 } 35 34 for _, track := range tracks { 36 35 release.Tracks = append(release.Tracks, track) ··· 39 38 // get links 40 39 links, err := GetReleaseLinks(db, id) 41 40 if err != nil { 42 - return nil, errors.New(fmt.Sprintf("Links: %s", err)) 41 + return nil, fmt.Errorf("Links: %s", err) 43 42 } 44 43 for _, link := range links { 45 44 release.Links = append(release.Links, link) ··· 71 70 // get credits 72 71 credits, err := GetReleaseCredits(db, release.ID) 73 72 if err != nil { 74 - return nil, errors.New(fmt.Sprintf("Credits: %s", err)) 73 + return nil, fmt.Errorf("Credits: %s", err) 75 74 } 76 75 for _, credit := range credits { 77 76 release.Credits = append(release.Credits, credit) ··· 81 80 // get tracks 82 81 tracks, err := GetReleaseTracks(db, release.ID) 83 82 if err != nil { 84 - return nil, errors.New(fmt.Sprintf("Tracks: %s", err)) 83 + return nil, fmt.Errorf("Tracks: %s", err) 85 84 } 86 85 for _, track := range tracks { 87 86 release.Tracks = append(release.Tracks, track) ··· 90 89 // get links 91 90 links, err := GetReleaseLinks(db, release.ID) 92 91 if err != nil { 93 - return nil, errors.New(fmt.Sprintf("Links: %s", err)) 92 + return nil, fmt.Errorf("Links: %s", err) 94 93 } 95 94 for _, link := range links { 96 95 release.Links = append(release.Links, link) ··· 99 98 } 100 99 101 100 return releases, nil 101 + } 102 + func GetReleaseCount(db *sqlx.DB, onlyVisible bool) (int, error) { 103 + query := "SELECT count(*) FROM musicrelease" 104 + if onlyVisible { 105 + query += " WHERE visible=true" 106 + } 107 + 108 + var count int 109 + err := db.Get(&count, query) 110 + 111 + return count, err 102 112 } 103 113 104 114 func CreateRelease(db *sqlx.DB, release *model.Release) error {
+2 -3
controller/session.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "errors" 6 5 "fmt" 7 6 "net/http" 8 7 "strings" ··· 19 18 func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) { 20 19 sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) 21 20 if err != nil && err != http.ErrNoCookie { 22 - return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err)) 21 + return nil, fmt.Errorf("Failed to retrieve session cookie: %v", err) 23 22 } 24 23 25 24 var session *model.Session ··· 29 28 session, err = GetSession(app.DB, sessionCookie.Value) 30 29 31 30 if err != nil && !strings.Contains(err.Error(), "no rows") { 32 - return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err)) 31 + return nil, fmt.Errorf("Failed to retrieve session: %v", err) 33 32 } 34 33 35 34 if session != nil {
+5
controller/track.go
··· 29 29 30 30 return tracks, nil 31 31 } 32 + func GetTrackCount(db *sqlx.DB) (int, error) { 33 + var count int 34 + err := db.Get(&count, "SELECT count(*) FROM musictrack") 35 + return count, err 36 + } 32 37 33 38 func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) { 34 39 var tracks = []*model.Track{}
+30 -25
main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "bufio" 5 - "errors" 6 - "fmt" 7 - stdLog "log" 8 - "math" 9 - "math/rand" 10 - "net" 11 - "net/http" 12 - "os" 13 - "path/filepath" 14 - "strconv" 15 - "strings" 16 - "time" 4 + "bufio" 5 + "embed" 6 + "errors" 7 + "fmt" 8 + stdLog "log" 9 + "math" 10 + "math/rand" 11 + "net" 12 + "net/http" 13 + "os" 14 + "path/filepath" 15 + "strconv" 16 + "strings" 17 + "time" 17 18 18 - "arimelody-web/admin" 19 - "arimelody-web/api" 20 - "arimelody-web/colour" 21 - "arimelody-web/controller" 22 - "arimelody-web/cursor" 23 - "arimelody-web/log" 24 - "arimelody-web/model" 25 - "arimelody-web/view" 19 + "arimelody-web/admin" 20 + "arimelody-web/api" 21 + "arimelody-web/colour" 22 + "arimelody-web/controller" 23 + "arimelody-web/cursor" 24 + "arimelody-web/log" 25 + "arimelody-web/model" 26 + "arimelody-web/view" 26 27 27 - "github.com/jmoiron/sqlx" 28 - _ "github.com/lib/pq" 29 - "golang.org/x/crypto/bcrypt" 28 + "github.com/jmoiron/sqlx" 29 + _ "github.com/lib/pq" 30 + "golang.org/x/crypto/bcrypt" 30 31 ) 31 32 32 33 // used for database migrations ··· 35 36 const DEFAULT_PORT int64 = 8080 36 37 const HRT_DATE int64 = 1756478697 37 38 39 + //go:embed "public" 40 + var publicFS embed.FS 41 + 38 42 func main() { 39 43 fmt.Printf("made with <3 by ari melody\n\n") 40 44 41 45 app := model.AppState{ 42 46 Config: controller.GetConfig(), 43 47 Twitch: nil, 48 + PublicFS: publicFS, 44 49 } 45 50 46 51 // initialise database connection ··· 526 531 mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app))) 527 532 mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app))) 528 533 mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app))) 529 - mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) 534 + mux.Handle("/uploads/", http.StripPrefix("/uploads", view.ServeFiles(filepath.Join(app.Config.DataDirectory, "uploads")))) 530 535 mux.Handle("/cursor-ws", cursor.Handler(app)) 531 536 mux.Handle("/", view.IndexHandler(app)) 532 537
+5 -2
model/appstate.go
··· 1 1 package model 2 2 3 3 import ( 4 - "github.com/jmoiron/sqlx" 4 + "embed" 5 5 6 - "arimelody-web/log" 6 + "github.com/jmoiron/sqlx" 7 + 8 + "arimelody-web/log" 7 9 ) 8 10 9 11 type ( ··· 43 45 Config Config 44 46 Log log.Logger 45 47 Twitch *TwitchState 48 + PublicFS embed.FS 46 49 } 47 50 )
+1 -1
model/release.go
··· 39 39 // GETTERS 40 40 41 41 func (release Release) GetDescriptionHTML() template.HTML { 42 - return template.HTML(strings.Replace(release.Description, "\n", "<br>", -1)) 42 + return template.HTML(strings.ReplaceAll(release.Description, "\n", "<br>")) 43 43 } 44 44 45 45 func (release Release) TextReleaseDate() string {
+3 -3
model/track.go
··· 13 13 Lyrics string `json:"lyrics" db:"lyrics"` 14 14 PreviewURL string `json:"previewURL" db:"preview_url"` 15 15 16 - Number int 16 + Number int `json:"-"` 17 17 } 18 18 ) 19 19 20 20 func (track Track) GetDescriptionHTML() template.HTML { 21 - return template.HTML(strings.Replace(track.Description, "\n", "<br>", -1)) 21 + return template.HTML(strings.ReplaceAll(track.Description, "\n", "<br>")) 22 22 } 23 23 24 24 func (track Track) GetLyricsHTML() template.HTML { 25 - return template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)) 25 + return template.HTML(strings.ReplaceAll(track.Lyrics, "\n", "<br>")) 26 26 } 27 27 28 28 // this function is stupid and i hate that i need it
public/img/buttons/wangleline.png

This is a binary file and will not be displayed.

+1
public/img/hamburger.svg
··· 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 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Artboard1" x="0" y="0" width="15.36" height="15.36" style="fill:none;"/><g id="Artboard11" serif:id="Artboard1"><path d="M13.44,7.68c0,0.53 -0.43,0.96 -0.96,0.96l-9.6,0c-0.53,0 -0.96,-0.43 -0.96,-0.96c-0,-0.53 0.43,-0.96 0.96,-0.96l9.6,-0c0.53,-0 0.96,0.43 0.96,0.96Z"/><path d="M13.44,11.52c0,0.53 -0.43,0.96 -0.96,0.96l-9.6,0c-0.53,0 -0.96,-0.43 -0.96,-0.96c-0,-0.53 0.43,-0.96 0.96,-0.96l9.6,0c0.53,0 0.96,0.43 0.96,0.96Z"/><path d="M13.44,3.84c0,0.53 -0.43,0.96 -0.96,0.96l-9.6,0c-0.53,0 -0.96,-0.43 -0.96,-0.96c-0,-0.53 0.43,-0.96 0.96,-0.96l9.6,0c0.53,0 0.96,0.43 0.96,0.96Z"/></g></svg>
+1 -2
public/script/music-gateway.js
··· 20 20 const info_container = document.getElementById("info") 21 21 info_container.addEventListener("scroll", update_extras_buttons); 22 22 const info_rect = info_container.getBoundingClientRect(); 23 - const info_y = info_rect.y; 24 23 const font_size = parseFloat(getComputedStyle(document.documentElement).fontSize); 25 24 let current = extras_pairs[0]; 26 25 extras_pairs.forEach(pair => { ··· 53 52 function bind_share_btn() { 54 53 const share_btn = document.getElementById("share"); 55 54 if (navigator.clipboard === undefined) { 56 - share_btn.onclick = event => { 55 + share_btn.onclick = () => { 57 56 console.error("clipboard is not supported by this browser!"); 58 57 }; 59 58 return;
+1 -1
public/style/music-gateway.css
··· 333 333 background-color: #fff; 334 334 text-align: center; 335 335 text-decoration: none; 336 - transition: filter .1s,-webkit-filter .1s 336 + transition: filter .1s ease-out, -webkit-filter .1s ease-out; 337 337 } 338 338 339 339 #buylink {
schema-migration/000-init.sql controller/schema-migration/000-init.sql
schema-migration/001-pre-versioning.sql controller/schema-migration/001-pre-versioning.sql
schema-migration/002-audit-logs.sql controller/schema-migration/002-audit-logs.sql
schema-migration/003-fail-lock.sql controller/schema-migration/003-fail-lock.sql
+21
templates/html/prideflag.html
··· 1 + {{define "prideflag"}} 2 + <a href="https://github.com/arimelody/prideflag" target="_blank" id="prideflag"> 3 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true"> 4 + <path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/> 5 + <path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/> 6 + <path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/> 7 + <path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/> 8 + <path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/> 9 + <path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/> 10 + 11 + <rect id="black" x="60" width="60" height="60" style="fill:#010101"/> 12 + <rect id="brown" x="70" width="50" height="50" style="fill:#603814"/> 13 + <rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/> 14 + <rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/> 15 + <rect id="white" x="100" width="20" height="20" style="fill:#fff"/> 16 + 17 + <rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/> 18 + <circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/> 19 + </svg> 20 + </a> 21 + {{end}}
+30 -22
templates/templates.go
··· 1 1 package templates 2 2 3 3 import ( 4 - "html/template" 5 - "path/filepath" 4 + _ "embed" 5 + "html/template" 6 + "strings" 6 7 ) 7 8 8 - var IndexTemplate = template.Must(template.ParseFiles( 9 - filepath.Join("views", "layout.html"), 10 - filepath.Join("views", "header.html"), 11 - filepath.Join("views", "footer.html"), 12 - filepath.Join("views", "prideflag.html"), 13 - filepath.Join("views", "index.html"), 14 - )) 15 - var MusicTemplate = template.Must(template.ParseFiles( 16 - filepath.Join("views", "layout.html"), 17 - filepath.Join("views", "header.html"), 18 - filepath.Join("views", "footer.html"), 19 - filepath.Join("views", "prideflag.html"), 20 - filepath.Join("views", "music.html"), 21 - )) 22 - var MusicGatewayTemplate = template.Must(template.ParseFiles( 23 - filepath.Join("views", "layout.html"), 24 - filepath.Join("views", "header.html"), 25 - filepath.Join("views", "footer.html"), 26 - filepath.Join("views", "prideflag.html"), 27 - filepath.Join("views", "music-gateway.html"), 9 + //go:embed "html/layout.html" 10 + var layoutHTML string 11 + //go:embed "html/header.html" 12 + var headerHTML string 13 + //go:embed "html/footer.html" 14 + var footerHTML string 15 + //go:embed "html/prideflag.html" 16 + var prideflagHTML string 17 + //go:embed "html/index.html" 18 + var indexHTML string 19 + //go:embed "html/music.html" 20 + var musicHTML string 21 + //go:embed "html/music-gateway.html" 22 + var musicGatewayHTML string 23 + // //go:embed "html/404.html" 24 + // var error404HTML string 25 + 26 + var BaseTemplate = template.Must(template.New("base").Parse( 27 + strings.Join([]string{ 28 + layoutHTML, 29 + headerHTML, 30 + footerHTML, 31 + prideflagHTML, 32 + }, "\n"), 28 33 )) 34 + var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(indexHTML)) 35 + var MusicTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(musicHTML)) 36 + var MusicGatewayTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(musicGatewayHTML))
+48
view/files.go
··· 1 + package view 2 + 3 + import ( 4 + "embed" 5 + "errors" 6 + "mime" 7 + "net/http" 8 + "os" 9 + "path" 10 + "path/filepath" 11 + ) 12 + 13 + func ServeEmbedFS(fs embed.FS, dir string) http.Handler { 14 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 + file, err := fs.ReadFile(filepath.Join(dir, filepath.Clean(r.URL.Path))) 16 + if err != nil { 17 + http.NotFound(w, r) 18 + return 19 + } 20 + 21 + w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path))) 22 + w.WriteHeader(http.StatusOK) 23 + 24 + w.Write(file) 25 + }) 26 + } 27 + 28 + func ServeFiles(directory string) http.Handler { 29 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 + info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) 31 + 32 + // does the file exist? 33 + if err != nil { 34 + if errors.Is(err, os.ErrNotExist) { 35 + http.NotFound(w, r) 36 + return 37 + } 38 + } 39 + 40 + // is thjs a directory? (forbidden) 41 + if info.IsDir() { 42 + http.NotFound(w, r) 43 + return 44 + } 45 + 46 + http.FileServer(http.Dir(directory)).ServeHTTP(w, r) 47 + }) 48 + }
+14 -15
view/index.go
··· 16 16 return 17 17 } 18 18 19 - type IndexData struct { 20 - TwitchStatus *model.TwitchStreamInfo 21 - } 22 - 23 - var err error 24 - var twitchStatus *model.TwitchStreamInfo = nil 25 - if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 { 26 - twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster) 27 - if err != nil { 28 - fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err) 19 + if r.URL.Path == "/" || r.URL.Path == "/index.html" { 20 + type IndexData struct { 21 + TwitchStatus *model.TwitchStreamInfo 22 + } 23 + var err error 24 + var twitchStatus *model.TwitchStreamInfo = nil 25 + if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 { 26 + twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster) 27 + if err != nil { 28 + fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err) 29 + } 29 30 } 30 - } 31 - 32 - if r.URL.Path == "/" || r.URL.Path == "/index.html" { 33 - err := templates.IndexTemplate.Execute(w, IndexData{ 31 + err = templates.IndexTemplate.Execute(w, IndexData{ 34 32 TwitchStatus: twitchStatus, 35 33 }) 36 34 if err != nil { ··· 40 38 return 41 39 } 42 40 43 - StaticHandler("public").ServeHTTP(w, r) 41 + http.FileServer(http.Dir("./public")).ServeHTTP(w, r) 42 + //ServeEmbedFS(app.PublicFS, "public").ServeHTTP(w, r) 44 43 }) 45 44 }
-31
view/static.go
··· 1 - package view 2 - 3 - import ( 4 - "errors" 5 - "net/http" 6 - "os" 7 - "path/filepath" 8 - ) 9 - 10 - func StaticHandler(directory string) http.Handler { 11 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 - info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) 13 - 14 - // does the file exist? 15 - if err != nil { 16 - if errors.Is(err, os.ErrNotExist) { 17 - http.NotFound(w, r) 18 - return 19 - } 20 - } 21 - 22 - // is thjs a directory? (forbidden) 23 - if info.IsDir() { 24 - http.NotFound(w, r) 25 - return 26 - } 27 - 28 - http.FileServer(http.Dir(directory)).ServeHTTP(w, r) 29 - }) 30 - } 31 -
views/404.html templates/html/404.html
views/footer.html templates/html/footer.html
views/header.html templates/html/header.html
+3
views/index.html templates/html/index.html
··· 213 213 <a href="https://girlthi.ng/~thermia/"> 214 214 <img src="/img/buttons/thermia.gif" alt="thermia web button" width="88" height="31"> 215 215 </a> 216 + <a href="https://wangleline.com"> 217 + <img src="/img/buttons/wangleline.png" alt="WangleLine button" width="88" height="31"> 218 + </a> 216 219 <a href="https://elke.cafe"> 217 220 <img src="/img/buttons/elke.gif" alt="elke web button" width="88" height="31"> 218 221 </a>
views/layout.html templates/html/layout.html
views/music-gateway.html templates/html/music-gateway.html
views/music.html templates/html/music.html
views/prideflag.html admin/templates/html/prideflag.html