Compare commits

...

4 commits

Author SHA1 Message Date
82fd17c836
early admin edit blog page 2025-11-07 02:47:40 +00:00
65366032fd
add blog index to admin dashboard 2025-11-07 01:04:10 +00:00
ee8bf6543e
Merge branch 'dev' into feature/blog
that was FAR LESS PAINFUL!
2025-11-06 22:28:11 +00:00
c547fca0d7
more admin dashboard polish, some code cleanup 2025-11-06 22:26:22 +00:00
39 changed files with 1059 additions and 326 deletions

133
admin/blog/blog.go Normal file
View file

@ -0,0 +1,133 @@
package blog
import (
"arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
"fmt"
"net/http"
"os"
"slices"
)
func Handler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux := http.NewServeMux()
mux.Handle("/{id}", serveBlogPost(app))
mux.Handle("/", serveBlogIndex(app))
mux.ServeHTTP(w, r)
})
}
type (
blogPost struct {
*model.BlogPost
Author *model.Account
}
blogPostGroup struct {
Year int
Posts []*blogPost
}
)
func serveBlogIndex(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
posts, err := controller.GetBlogPosts(app.DB, false, -1, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
collections := []*blogPostGroup{}
collectionPosts := []*blogPost{}
collectionYear := -1
for i, post := range posts {
author, err := controller.GetAccountByID(app.DB, post.AuthorID)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve author of blog %s: %v\n", post.ID, err)
continue
}
if collectionYear == -1 {
collectionYear = post.CreatedAt.Year()
}
authoredPost := blogPost{
BlogPost: post,
Author: author,
}
if post.CreatedAt.Year() != collectionYear || i == len(posts) - 1 {
if i == len(posts) - 1 {
collectionPosts = append(collectionPosts, &authoredPost)
}
collections = append(collections, &blogPostGroup{
Year: collectionYear,
Posts: slices.Clone(collectionPosts),
})
collectionPosts = []*blogPost{}
collectionYear = post.CreatedAt.Year()
}
collectionPosts = append(collectionPosts, &authoredPost)
}
type blogsData struct {
core.AdminPageData
TotalPosts int
Collections []*blogPostGroup
}
err = templates.BlogsTemplate.Execute(w, blogsData{
AdminPageData: core.AdminPageData{
Session: session,
},
TotalPosts: len(posts),
Collections: collections,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin blog index: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func serveBlogPost(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
blogID := r.PathValue("id")
post, err := controller.GetBlogPost(app.DB, blogID)
if err != nil {
fmt.Printf("can't find blog with ID %s\n", blogID)
http.NotFound(w, r)
return
}
type blogPostData struct {
core.AdminPageData
Post *model.BlogPost
}
err = templates.EditBlogTemplate.Execute(w, blogPostData{
AdminPageData: core.AdminPageData{
Session: session,
},
Post: post,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin edit page for blog %s: %v\n", blogID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}

View file

@ -7,6 +7,7 @@ import (
"arimelody-web/admin/account" "arimelody-web/admin/account"
"arimelody-web/admin/auth" "arimelody-web/admin/auth"
"arimelody-web/admin/blog"
"arimelody-web/admin/core" "arimelody-web/admin/core"
"arimelody-web/admin/logs" "arimelody-web/admin/logs"
"arimelody-web/admin/music" "arimelody-web/admin/music"
@ -24,8 +25,10 @@ func Handler(app *model.AppState) http.Handler {
mux.Handle("/totp", auth.LoginTOTPHandler(app)) mux.Handle("/totp", auth.LoginTOTPHandler(app))
mux.Handle("/logout", core.RequireAccount(auth.LogoutHandler(app))) mux.Handle("/logout", core.RequireAccount(auth.LogoutHandler(app)))
mux.Handle("/music/", core.RequireAccount(http.StripPrefix("/music", music.Handler(app))))
mux.Handle("/logs", core.RequireAccount(logs.Handler(app))) mux.Handle("/logs", core.RequireAccount(logs.Handler(app)))
mux.Handle("/music/", core.RequireAccount(http.StripPrefix("/music", music.Handler(app))))
mux.Handle("/blogs/", core.RequireAccount(http.StripPrefix("/blogs", blog.Handler(app))))
mux.Handle("/account/", core.RequireAccount(http.StripPrefix("/account", account.Handler(app)))) mux.Handle("/account/", core.RequireAccount(http.StripPrefix("/account", account.Handler(app))))
mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -42,13 +45,13 @@ func Handler(app *model.AppState) http.Handler {
view.ServeFiles("./admin/static"))).ServeHTTP(w, r) view.ServeFiles("./admin/static"))).ServeHTTP(w, r)
})) }))
mux.Handle("/", core.RequireAccount(AdminIndexHandler(app))) mux.Handle("/", core.RequireAccount(adminIndexHandler(app)))
// response wrapper to make sure a session cookie exists // response wrapper to make sure a session cookie exists
return core.EnforceSession(app, mux) return core.EnforceSession(app, mux)
} }
func AdminIndexHandler(app *model.AppState) http.Handler { func adminIndexHandler(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) http.NotFound(w, r)

View file

@ -156,7 +156,8 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
func serveNewCredit(app *model.AppState) http.Handler { func serveNewCredit(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artistID := strings.Split(r.URL.Path, "/")[3] split := strings.Split(r.URL.Path, "/")
artistID := split[len(split) - 1]
artist, err := controller.GetArtist(app.DB, artistID) artist, err := controller.GetArtist(app.DB, artistID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %s\n", artistID, err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %s\n", artistID, err)
@ -232,7 +233,8 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
func serveNewTrack(app *model.AppState) http.Handler { func serveNewTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
trackID := strings.Split(r.URL.Path, "/")[3] split := strings.Split(r.URL.Path, "/")
trackID := split[len(split) - 1]
track, err := controller.GetTrack(app.DB, trackID) track, err := controller.GetTrack(app.DB, trackID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %s\n", trackID, err) fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %s\n", trackID, err)

256
admin/releasehttp.go Normal file
View file

@ -0,0 +1,256 @@
package admin
import (
"fmt"
"net/http"
"os"
"strings"
"arimelody-web/admin/core"
"arimelody-web/admin/templates"
"arimelody-web/controller"
"arimelody-web/model"
)
func serveReleases(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/")
releaseID := slices[0]
var action string = ""
if len(slices) > 1 {
action = slices[1]
}
if len(releaseID) > 0 {
serveRelease(app, releaseID, action).ServeHTTP(w, r)
return
}
type ReleasesData struct {
core.AdminPageData
Releases []*model.Release
}
releases, err := controller.GetAllReleases(app.DB, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = templates.ReleasesTemplate.Execute(w, ReleasesData{
AdminPageData: core.AdminPageData{
Path: r.URL.Path,
Session: session,
},
Releases: releases,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func serveRelease(app *model.AppState, releaseID string, action string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*model.Session)
release, err := controller.GetRelease(app.DB, releaseID, true)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if len(action) > 0 {
switch action {
case "editcredits":
serveEditCredits(release).ServeHTTP(w, r)
return
case "addcredit":
serveAddCredit(app, release).ServeHTTP(w, r)
return
case "newcredit":
serveNewCredit(app).ServeHTTP(w, r)
return
case "editlinks":
serveEditLinks(release).ServeHTTP(w, r)
return
case "edittracks":
serveEditTracks(release).ServeHTTP(w, r)
return
case "addtrack":
serveAddTrack(app, release).ServeHTTP(w, r)
return
case "newtrack":
serveNewTrack(app).ServeHTTP(w, r)
return
}
http.NotFound(w, r)
return
}
type ReleaseResponse struct {
core.AdminPageData
Release *model.Release
}
for i, track := range release.Tracks { track.Number = i + 1 }
err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{
AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session },
Release: release,
})
if err != nil {
fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := templates.EditCreditsTemplate.Execute(w, release)
if err != nil {
fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type response struct {
ReleaseID string;
Artists []*model.Artist
}
w.Header().Set("Content-Type", "text/html")
err = templates.AddCreditTemplate.Execute(w, response{
ReleaseID: release.ID,
Artists: artists,
})
if err != nil {
fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveNewCredit(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
split := strings.Split(r.URL.Path, "/")
artistID := split[len(split) - 1]
artist, err := controller.GetArtist(app.DB, artistID)
if err != nil {
fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if artist == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
err = templates.NewCreditTemplate.Execute(w, artist)
if err != nil {
fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := templates.EditLinksTemplate.Execute(w, release)
if err != nil {
fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveEditTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
type editTracksData struct { Release *model.Release }
for i, track := range release.Tracks { track.Number = i + 1 }
err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release })
if err != nil {
fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
if err != nil {
fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type response struct {
ReleaseID string;
Tracks []*model.Track
}
w.Header().Set("Content-Type", "text/html")
err = templates.AddTrackTemplate.Execute(w, response{
ReleaseID: release.ID,
Tracks: tracks,
})
if err != nil {
fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}
func serveNewTrack(app *model.AppState) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
split := strings.Split(r.URL.Path, "/")
trackID := split[len(split) - 1]
track, err := controller.GetTrack(app.DB, trackID)
if err != nil {
fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if track == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
err = templates.NewTrackTemplate.Execute(w, track)
if err != nil {
fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}

View file

@ -111,7 +111,7 @@ body {
font-family: "Inter", sans-serif; font-family: "Inter", sans-serif;
font-size: 16px; font-size: 16px;
color: var(--fg-0); color: var(--fg-0);
background: var(--bg-0); background-color: var(--bg-0);
transition: background .1s ease-out, color .1s ease-out; transition: background .1s ease-out, color .1s ease-out;
} }
@ -252,12 +252,6 @@ a {
transition: color .1s ease-out, background-color .1s ease-out; transition: color .1s ease-out, background-color .1s ease-out;
} }
/*
a:hover {
text-decoration: underline;
}
*/
img.icon { img.icon {
height: .8em; height: .8em;
transition: filter .1s ease-out; transition: filter .1s ease-out;
@ -270,10 +264,6 @@ code {
border-radius: 4px; border-radius: 4px;
} }
h1 {
margin: 0 0 .5em 0;
}
.cards { .cards {
@ -287,7 +277,7 @@ h1 {
.card { .card {
flex-basis: 40em; flex-basis: 40em;
padding: 1em; padding: 1em;
background: var(--bg-1); background-color: var(--bg-1);
border-radius: 16px; border-radius: 16px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
@ -365,7 +355,7 @@ a.delete:not(.button) {
font-size: inherit; font-size: inherit;
color: inherit; color: inherit;
background: var(--bg-2); background-color: var(--bg-2);
border: none; border: none;
border-radius: 10em; border-radius: 10em;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@ -384,27 +374,27 @@ button:active, .button:active {
.button.new, button.new { .button.new, button.new {
color: var(--col-on-new); color: var(--col-on-new);
background: var(--col-new); background-color: var(--col-new);
} }
.button.save, button.save { .button.save, button.save {
color: var(--col-on-save); color: var(--col-on-save);
background: var(--col-save); background-color: var(--col-save);
} }
.button.delete, button.delete { .button.delete, button.delete {
color: var(--col-on-delete); color: var(--col-on-delete);
background: var(--col-delete); background-color: var(--col-delete);
} }
.button:hover, button:hover { .button:hover, button:hover {
color: var(--bg-3); color: var(--bg-3);
background: var(--fg-3); background-color: var(--fg-3);
} }
.button:active, button:active { .button:active, button:active {
color: var(--bg-2); color: var(--bg-2);
background: var(--fg-0); background-color: var(--fg-0);
} }
.button[disabled], button[disabled] { .button[disabled], button[disabled] {
color: var(--fg-0) !important; color: var(--fg-0) !important;
background: var(--bg-3) !important; background-color: var(--bg-3) !important;
opacity: .5; opacity: .5;
cursor: default !important; cursor: default !important;
} }

View file

@ -2,7 +2,7 @@
padding: .5em; padding: .5em;
color: var(--fg-3); color: var(--fg-3);
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
border-radius: 16px; border-radius: 16px;
text-align: center; text-align: center;
@ -12,7 +12,7 @@
} }
.artist:hover { .artist:hover {
background: var(--bg-1); background-color: var(--bg-1);
text-decoration: hover; text-decoration: hover;
} }

View file

@ -4,4 +4,29 @@ document.addEventListener("readystatechange", () => {
document.querySelectorAll(".artists-group .artist").forEach(el => { document.querySelectorAll(".artists-group .artist").forEach(el => {
hijackClickEvent(el, el.querySelector("a.artist-name")) hijackClickEvent(el, el.querySelector("a.artist-name"))
}); });
const newArtistBtn = document.getElementById("create-artist");
if (newArtistBtn) newArtistBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this artist:");
if (id == null || id == "") return;
fetch("/api/v1/artist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/artists/" + id;
} else {
alert(text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create artist. Check the console for details.");
console.error(err);
});
});
}); });

80
admin/static/blog.css Normal file
View file

@ -0,0 +1,80 @@
.blog-collection h2 {
margin: .5em 1em;
font-size: 1em;
text-transform: uppercase;
font-weight: 600;
color: var(--fg-0);
}
.blogpost {
padding: 1em;
display: block;
border-radius: 8px;
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
}
.blogpost .title {
margin: 0;
font-size: 1.5em;
}
.blogpost .title small {
display: inline-block;
font-size: .6em;
transform: translateY(-0.1em);
color: var(--fg-0);
}
.blogpost .description {
margin: .5em 0 .6em 0;
color: var(--fg-1);
}
.blogpost .meta {
margin: 0;
font-size: .8em;
color: var(--fg-0);
}
.blogpost .meta .author {
color: var(--fg-1);
}
.blogpost .meta .author img {
width: 1.3em;
height: 1.3em;
margin-right: .2em;
display: inline-block;
transform: translate(0, 4px);
border-radius: 4px;
}
.blogpost a:hover {
text-decoration: underline;
}
.blogpost .actions {
margin-top: .5em;
display: flex;
gap: .3em;
user-select: none;
color: var(--fg-3);
}
.blogpost .actions a {
padding: .3em .5em;
display: inline-block;
border-radius: 4px;
background-color: var(--bg-3);
box-shadow: var(--shadow-sm);
transition: color .1s ease-out, background-color .1s ease-out;
}
.blogpost .actions a:hover {
background-color: var(--bg-0);
color: var(--fg-3);
text-decoration: none;
}

0
admin/static/blog.js Normal file
View file

View file

@ -33,7 +33,7 @@ form#delete-account input {
justify-content: space-between; justify-content: space-between;
color: var(--fg-3); color: var(--fg-3);
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
border-radius: 16px; border-radius: 16px;
} }

View file

@ -6,7 +6,7 @@
gap: 1.2em; gap: 1.2em;
border-radius: 16px; border-radius: 16px;
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
@ -50,18 +50,11 @@ input[type="text"] {
font-family: inherit; font-family: inherit;
font-weight: inherit; font-weight: inherit;
color: inherit; color: inherit;
background: var(--bg-0); background-color: var(--bg-0);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
outline: none; outline: none;
} }
input[type="text"]:hover {
border-color: #80808080;
}
input[type="text"]:active,
input[type="text"]:focus {
border-color: #808080;
}
.artist-actions { .artist-actions {
margin-top: auto; margin-top: auto;
@ -84,7 +77,7 @@ input[type="text"]:focus {
align-items: center; align-items: center;
border-radius: 16px; border-radius: 16px;
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
cursor: pointer; cursor: pointer;
@ -92,7 +85,7 @@ input[type="text"]:focus {
} }
.credit:hover { .credit:hover {
background: var(--bg-1); background-color: var(--bg-1);
} }
.release-artwork { .release-artwork {

100
admin/static/edit-blog.css Normal file
View file

@ -0,0 +1,100 @@
input[type="text"] {
padding: .3em .5em;
font-size: inherit;
font-family: inherit;
border: none;
border-radius: 4px;
outline: none;
color: inherit;
background-color: var(--bg-1);
box-shadow: var(--shadow-sm);
}
#blogpost {
margin-bottom: 1em;
padding: 1.5em;
border-radius: 8px;
background-color: var(--bg-2);
box-shadow: var(--shadow-lg);
transition: background .1s ease-out, color .1s ease-out;
}
#blogpost label {
margin: 1.2em 0 .2em .1em;
display: block;
font-size: .8em;
text-transform: uppercase;
font-weight: 600;
}
#blogpost label:first-of-type {
margin-top: 0;
}
#blogpost h2 {
margin: 0;
font-size: 2em;
}
#blogpost #title {
width: 100%;
margin: 0 -.2em;
padding: 0 .2em;
resize: none;
font-family: inherit;
font-size: inherit;
font-weight: bold;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: var(--fg-3);
outline: none;
cursor: pointer;
transition: background .1s ease-out, border-color .1s ease-out;
/*position: relative; outline: none;*/
white-space: pre-wrap; overflow-wrap: break-word;
}
#blogpost #title:hover {
background-color: var(--bg-3);
border-color: var(--fg-0);
}
#blogpost #title:active,
#blogpost #title:focus {
background-color: var(--bg-3);
}
#blogpost textarea {
width: calc(100% - 2em);
margin: 0;
padding: 1em;
display: block;
border: none;
border-radius: 4px;
background-color: var(--bg-1);
color: var(--fg-3);
box-shadow: var(--shadow-md);
resize: vertical;
outline: none;
}
#blogpost #description {
font-family: inherit;
}
#blogpost select {
padding: .5em .8em;
font-size: inherit;
border: none;
border-radius: 10em;
color: var(--fg-3);
background-color: var(--bg-1);
box-shadow: var(--shadow-sm);
}
#blogpost .blog-actions {
margin-top: 1em;
}

63
admin/static/edit-blog.js Normal file
View file

@ -0,0 +1,63 @@
const blogID = document.getElementById("blogpost").dataset.id;
const titleInput = document.getElementById("title");
const descInput = document.getElementById("description");
const mdInput = document.getElementById("markdown");
const blueskyActorInput = document.getElementById("bluesky-actor");
const blueskyPostInput = document.getElementById("bluesky-post");
const visInput = document.getElementById("visibility");
const saveBtn = document.getElementById("save");
const deleteBtn = document.getElementById("delete");
saveBtn.addEventListener("click", () => {
fetch("/api/v1/blog/" + blogID, {
method: "PUT",
body: JSON.stringify({
title: titleInput.value,
description: descInput.value,
markdown: mdInput.value,
bluesky_actor: blueskyActorInput.value,
bluesky_post: blueskyPostInput.value,
visible: visInput.value === "true",
}),
headers: { "Content-Type": "application/json" }
}).then(res => {
if (!res.ok) {
res.text().then(error => {
console.error(error);
alert("Failed to update blog post: " + error);
});
return;
}
location = location;
});
});
deleteBtn.addEventListener("click", () => {
if (blogID != prompt(
"You are about to permanently delete " + blogID + ". " +
"This action is irreversible. " +
"Please enter \"" + blogID + "\" to continue.")) return;
fetch("/api/v1/blog/" + blogID, {
method: "DELETE",
}).then(res => {
if (!res.ok) {
res.text().then(error => {
console.error(error);
alert("Failed to delete blog post: " + error);
});
return;
}
location = "/admin";
});
});
[titleInput, descInput, mdInput, blueskyActorInput, blueskyPostInput, visInput].forEach(input => {
input.addEventListener("change", () => {
saveBtn.disabled = false;
});
input.addEventListener("keypress", () => {
saveBtn.disabled = false;
});
});

View file

@ -12,7 +12,7 @@ input[type="text"] {
gap: 1.2em; gap: 1.2em;
border-radius: 8px; border-radius: 8px;
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out; transition: background .1s ease-out, color .1s ease-out;
@ -33,7 +33,7 @@ input[type="text"] {
.release-artwork #remove-artwork { .release-artwork #remove-artwork {
margin-top: .5em; margin-top: .5em;
padding: .3em .6em; padding: .3em .6em;
background: var(--bg-3); background-color: var(--bg-3);
} }
.release-info { .release-info {
@ -62,13 +62,13 @@ input[type="text"] {
} }
#title:hover { #title:hover {
background: var(--bg-3); background-color: var(--bg-3);
border-color: var(--fg-0); border-color: var(--fg-0);
} }
#title:active, #title:active,
#title:focus { #title:focus {
background: var(--bg-3); background-color: var(--bg-3);
} }
.release-title small { .release-title small {
@ -93,7 +93,7 @@ input[type="text"] {
.release-info table tr td:not(:first-child) select:hover, .release-info table tr td:not(:first-child) select:hover,
.release-info table tr td:not(:first-child) input:hover, .release-info table tr td:not(:first-child) input:hover,
.release-info table tr td:not(:first-child) textarea:hover { .release-info table tr td:not(:first-child) textarea:hover {
background: var(--bg-3); background-color: var(--bg-3);
cursor: pointer; cursor: pointer;
} }
.release-info table td select, .release-info table td select,
@ -127,7 +127,7 @@ input[type="text"] {
.release-actions button, .release-actions button,
.release-actions .button { .release-actions .button {
color: var(--fg-2); color: var(--fg-2);
background: var(--bg-3); background-color: var(--bg-3);
} }
dialog { dialog {
@ -234,7 +234,7 @@ dialog div.dialog-actions {
gap: 1em; gap: 1em;
border-radius: 8px; border-radius: 8px;
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
@ -280,7 +280,7 @@ dialog div.dialog-actions {
border: none; border: none;
border-radius: 4px; border-radius: 4px;
color: var(--fg-2); color: var(--fg-2);
background: var(--bg-0); background-color: var(--bg-0);
} }
#editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { #editcredits .credit .credit-info .credit-attribute input[type="checkbox"] {
margin: 0 .3em; margin: 0 .3em;
@ -299,6 +299,7 @@ dialog div.dialog-actions {
#editcredits .credit .delete { #editcredits .credit .delete {
margin-right: .5em; margin-right: .5em;
cursor: pointer; cursor: pointer;
overflow: visible;
} }
#editcredits .credit .delete:hover { #editcredits .credit .delete:hover {
text-decoration: underline; text-decoration: underline;
@ -315,14 +316,17 @@ dialog div.dialog-actions {
display: flex; display: flex;
gap: .5em; gap: .5em;
cursor: pointer; cursor: pointer;
background-color: var(--bg-2);
} }
#addcredit ul li.new-artist:nth-child(even) { #addcredit ul li.new-artist:nth-child(even) {
background: #f0f0f0; background: #f0f0f0;
background-color: var(--bg-1);
} }
#addcredit ul li.new-artist:hover { #addcredit ul li.new-artist:hover {
background: #e0e0e0; background: #e0e0e0;
background-color: var(--bg-2);
} }
#addcredit .new-artist .artist-id { #addcredit .new-artist .artist-id {
@ -375,6 +379,8 @@ dialog div.dialog-actions {
#editlinks tr { #editlinks tr {
display: flex; display: flex;
background-color: var(--bg-1);
transition: background-color .1s ease-out;
} }
#editlinks th { #editlinks th {
@ -385,7 +391,7 @@ dialog div.dialog-actions {
} }
#editlinks tr:nth-child(odd) { #editlinks tr:nth-child(odd) {
background: #f8f8f8; background-color: var(--bg-2);
} }
#editlinks tr th, #editlinks tr th,
@ -416,6 +422,11 @@ dialog div.dialog-actions {
width: 1em; width: 1em;
pointer-events: none; pointer-events: none;
} }
@media (prefers-color-scheme: dark) {
#editlinks tr .grabber img {
filter: invert();
}
}
#editlinks tr .link-name { #editlinks tr .link-name {
width: 8em; width: 8em;
} }
@ -454,6 +465,7 @@ dialog div.dialog-actions {
} }
#edittracks .track { #edittracks .track {
background-color: var(--bg-2);
transition: transform .2s ease-out, opacity .2s; transition: transform .2s ease-out, opacity .2s;
} }
@ -476,7 +488,7 @@ dialog div.dialog-actions {
} }
#edittracks .track:nth-child(even) { #edittracks .track:nth-child(even) {
background: #f0f0f0; background-color: var(--bg-1);
} }
#edittracks .track-number { #edittracks .track-number {
@ -492,7 +504,6 @@ dialog div.dialog-actions {
#addtrack ul { #addtrack ul {
padding: 0; padding: 0;
list-style: none; list-style: none;
background: #f8f8f8;
} }
#addtrack ul li.new-track { #addtrack ul li.new-track {

View file

@ -8,7 +8,7 @@
gap: 1.2em; gap: 1.2em;
border-radius: 16px; border-radius: 16px;
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
@ -45,25 +45,13 @@
font-weight: inherit; font-weight: inherit;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
background: var(--bg-0); background-color: var(--bg-0);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
outline: none; outline: none;
color: inherit; color: inherit;
} }
.track-info input[type="text"]:hover,
.track-info textarea:hover {
border-color: #80808080;
}
.track-info input[type="text"]:active,
.track-info textarea:active,
.track-info input[type="text"]:focus,
.track-info textarea:focus {
border-color: #808080;
}
.track-actions { .track-actions {
margin-top: 1em; margin-top: 1em;
display: flex; display: flex;

View file

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

View file

@ -8,7 +8,7 @@ form#search-form {
padding: 1em; padding: 1em;
border-radius: 16px; border-radius: 16px;
color: var(--fg-0); color: var(--fg-0);
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
@ -23,7 +23,7 @@ div#search {
border: none; border: none;
border-radius: 16px; border-radius: 16px;
color: var(--fg-1); color: var(--fg-1);
background: var(--bg-0); background-color: var(--bg-0);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@ -100,8 +100,8 @@ td.log-content {
#logs .log.warn { #logs .log.warn {
color: var(--col-on-warn); color: var(--col-on-warn);
background: var(--col-warn); background-color: var(--col-warn);
} }
#logs .log.warn:hover { #logs .log.warn:hover {
background: var(--col-warn-hover); background-color: var(--col-warn-hover);
} }

View file

@ -6,7 +6,7 @@
gap: 1em; gap: 1em;
border-radius: 16px; border-radius: 16px;
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out; transition: background .1s ease-out, color .1s ease-out;
@ -67,14 +67,14 @@
display: inline-block; display: inline-block;
border-radius: 4px; border-radius: 4px;
background: var(--bg-3); background-color: var(--bg-3);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: color .1s ease-out, background .1s ease-out; transition: color .1s ease-out, background .1s ease-out;
} }
.release .release-actions a:hover { .release .release-actions a:hover {
background: var(--bg-0); background-color: var(--bg-0);
color: var(--fg-3); color: var(--fg-3);
text-decoration: none; text-decoration: none;
} }

25
admin/static/releases.js Normal file
View file

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

View file

@ -12,7 +12,7 @@
gap: .5em; gap: .5em;
border-radius: 16px; border-radius: 16px;
background: var(--bg-2); background-color: var(--bg-2);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
transition: background .1s ease-out, color .1s ease-out; transition: background .1s ease-out, color .1s ease-out;
@ -44,11 +44,6 @@
opacity: .5; opacity: .5;
} }
#tracks .track-album.empty {
color: #ff2020;
opacity: 1;
}
#tracks .track-description { #tracks .track-description {
font-style: italic; font-style: italic;
} }
@ -67,61 +62,4 @@
margin: 0; margin: 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
/*
justify-content: space-between;
*/
} }
/*
.track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: 8px;
background-color: var(--bg-2);
box-shadow: var(--shadow-md);
transition: color .1s ease-out, background-color .1s ease-out;
}
.track p {
margin: 0;
}
.track-id {
width: fit-content;
font-family: "Monaspace Argon", monospace;
font-size: .8em;
font-style: italic;
line-height: 1em;
user-select: all;
}
.track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.track-album.empty {
color: #ff2020;
opacity: 1;
}
.track-description {
font-style: italic;
}
.track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.track .empty {
opacity: 0.75;
}
*/

24
admin/static/tracks.js Normal file
View file

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

View file

@ -0,0 +1,33 @@
{{define "head"}}
<title>Blog - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/blog.css">
{{end}}
{{define "content"}}
<main>
<header>
<h1>Blog Posts <small>({{.TotalPosts}} total)</small></h2>
<a class="button new" id="create-post">Create New</a>
</header>
{{if .Collections}}
<div class="blog-group">
{{range .Collections}}
{{if .Posts}}
<div class="blog-collection">
<h2>{{.Year}}</h2>
{{range .Posts}}
{{block "blogpost" .}}{{end}}
{{end}}
</div>
{{end}}
{{end}}
</div>
{{else}}
<p>There are no blog posts.</p>
{{end}}
</main>
<script type="module" src="/admin/static/blog.js"></script>
{{end}}

View file

@ -0,0 +1,14 @@
{{define "blogpost"}}
<div class="blogpost">
<h3 class="title"><a href="/admin/blogs/{{.ID}}">{{.Title}}</a>{{if not .Visible}} <small>(Not published)</small>{{end}}</h3>
<p class="meta">
<span class="author"><img src="/img/favicon.png" alt="{{.Author.Username}}'s avatar" width="32" height="32"/> {{.Author.Username}}</span>
<span class="date">&bull; {{.PrintDate}}</span>
</p>
<p class="description">{{.Description}}</p>
<div class="actions">
<a href="/admin/blogs/{{.ID}}">Edit</a>
<a href="/blog/{{.ID}}">View</a>
</div>
</div>
{{end}}

View file

@ -49,7 +49,6 @@
deleteBtn.addEventListener("click", e => { deleteBtn.addEventListener("click", e => {
e.preventDefault(); e.preventDefault();
if (!confirm("Are you sure you want to remove " + trackTitle + "?")) return;
trackItem.remove(); trackItem.remove();
refreshTrackNumbers(); refreshTrackNumbers();
}); });

View file

@ -0,0 +1,87 @@
{{define "head"}}
<title>Editing {{.Post.Title}} - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-blog.css">
{{end}}
{{define "content"}}
<main>
<h1>Editing Blog Post</h1>
<div id="blogpost" data-id="{{.Post.ID}}">
<label for="title">Title</label>
<h2 id="blog-title">
<div
id="title"
name="title"
role="textbox"
aria-multiline="true"
spellcheck="true"
aria-haspopup="listbox"
aria-invalid="false"
aria-autocomplete="list"
autocorrect="off"
contenteditable="true"
zindex="-1"
>{{.Post.Title}}</div>
</h2>
<label for="description">Description</label>
<textarea
id="description"
name="description"
value="{{.Post.Description}}"
placeholder="No description provided."
rows="3"
>{{.Post.Description}}</textarea>
<label for="markdown">Markdown</label>
<textarea
id="markdown"
name="markdown"
value="{{.Post.Markdown}}"
rows="30"
>{{.Post.Markdown}}</textarea>
<label for="bluesky-actor">Bluesky Author DID</label>
<input
type="text"
name="bluesky-actor"
id="bluesky-actor"
placeholder="did:plc:1234abcd..."
value="{{if .Post.BlueskyActorID}}{{.Post.BlueskyActorID}}{{end}}">
<label for="bluesky-post">Bluesky Post ID</label>
<input
type="text"
name="bluesky-post"
id="bluesky-post"
placeholder="3m109a03..."
value="{{if .Post.BlueskyPostID}}{{.Post.BlueskyPostID}}{{end}}">
<label for="visibility">Visibility</label>
<select name="visibility" id="visibility">
<option value="true"{{if .Post.Visible}} selected{{end}}>Visible</option>
<option value="false"{{if not .Post.Visible}} selected{{end}}>Hidden</option>
</select>
<div class="blog-actions">
<a href="/blog/{{.Post.ID}}" class="button">View</a>
<button type="submit" class="save" id="save" disabled>Save</button>
</div>
</div>
<div class="card" id="danger">
<div class="card-header">
<h2>Danger Zone</h2>
</div>
<p>
Clicking the button below will delete this blog post.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
</p>
<button class="delete" id="delete">Delete Release</button>
</div>
</main>
<script type="module" src="/admin/static/edit-blog.js"></script>
{{end}}

View file

@ -8,7 +8,7 @@
{{define "content"}} {{define "content"}}
<main> <main>
<h1>Editing {{.Release.Title}}</h1> <h1>Editing Release</h1>
<div id="release" data-id="{{.Release.ID}}"> <div id="release" data-id="{{.Release.ID}}">
<div class="release-artwork"> <div class="release-artwork">
@ -126,7 +126,7 @@
</div> </div>
{{end}} {{end}}
{{if not .Release.Credits}} {{if not .Release.Credits}}
<p>There are no credits.</p> <p>This release has no credits.</p>
{{end}} {{end}}
</div> </div>
@ -141,11 +141,15 @@
>Edit</a> >Edit</a>
</div> </div>
{{if .Release.Links}}
<ul> <ul>
{{range .Release.Links}} {{range .Release.Links}}
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a> <a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
{{end}} {{end}}
</ul> </ul>
{{else}}
<p>This release has no links.</p>
{{end}}
</div> </div>
<div id="tracks" class="card"> <div id="tracks" class="card">
@ -162,6 +166,9 @@
{{range .Release.Tracks}} {{range .Release.Tracks}}
{{block "track" .}}{{end}} {{block "track" .}}{{end}}
{{end}} {{end}}
{{if not .Release.Tracks}}
<p>This release has no tracks.</p>
{{end}}
</div> </div>
<div class="card" id="danger"> <div class="card" id="danger">

View file

@ -56,6 +56,7 @@
</main> </main>
<script type="module" src="/admin/static/releases.js"></script>
<script type="module" src="/admin/static/artists.js"></script> <script type="module" src="/admin/static/artists.js"></script>
<script type="module" src="/admin/static/index.js"></script> <script type="module" src="/admin/static/tracks.js"></script>
{{end}} {{end}}

View file

@ -27,7 +27,9 @@
<div class="nav-item{{if eq .Path "/logs"}} active{{end}}"> <div class="nav-item{{if eq .Path "/logs"}} active{{end}}">
<a href="/admin/logs">logs</a> <a href="/admin/logs">logs</a>
</div> </div>
<hr> <hr>
<p class="section-label">music</p> <p class="section-label">music</p>
<div class="nav-item{{if hasPrefix .Path "/releases"}} active{{end}}"> <div class="nav-item{{if hasPrefix .Path "/releases"}} active{{end}}">
<a href="/admin/music/releases/">releases</a> <a href="/admin/music/releases/">releases</a>
@ -38,6 +40,13 @@
<div class="nav-item{{if hasPrefix .Path "/tracks"}} active{{end}}"> <div class="nav-item{{if hasPrefix .Path "/tracks"}} active{{end}}">
<a href="/admin/music/tracks/">tracks</a> <a href="/admin/music/tracks/">tracks</a>
</div> </div>
<hr>
<p class="section-label">blog</p>
<div class="nav-item{{if hasPrefix .Path "/blogs"}} active{{end}}">
<a href="/admin/blogs/">blog</a>
</div>
{{end}} {{end}}
<div class="flex-fill"></div> <div class="flex-fill"></div>

View file

@ -21,4 +21,6 @@
<p>There are no releases.</p> <p>There are no releases.</p>
{{end}} {{end}}
</main> </main>
<script type="module" src="/admin/static/releases.js"></script>
{{end}} {{end}}

View file

@ -12,8 +12,8 @@
</header> </header>
<div id="tracks"> <div id="tracks">
{{range $Track := .Tracks}} {{range .Tracks}}
{{block "track" $Track}}{{end}} {{block "track" .}}{{end}}
{{end}} {{end}}
</div> </div>
</main> </main>

View file

@ -17,63 +17,9 @@ var prideflagHTML string
//go:embed "html/index.html" //go:embed "html/index.html"
var indexHTML string var indexHTML string
//go:embed "html/register.html"
var registerHTML string
//go:embed "html/login.html"
var loginHTML string
//go:embed "html/login-totp.html"
var loginTotpHTML string
//go:embed "html/totp-confirm.html"
var totpConfirmHTML string
//go:embed "html/totp-setup.html"
var totpSetupHTML string
//go:embed "html/logout.html"
var logoutHTML string
//go:embed "html/logs.html" //go:embed "html/logs.html"
var logsHTML string var logsHTML string
//go:embed "html/edit-account.html"
var editAccountHTML string
//go:embed "html/releases.html"
var releasesHTML string
//go:embed "html/artists.html"
var artistsHTML string
//go:embed "html/tracks.html"
var tracksHTML string
//go:embed "html/edit-release.html"
var editReleaseHTML string
//go:embed "html/edit-artist.html"
var editArtistHTML string
//go:embed "html/edit-track.html"
var editTrackHTML string
//go:embed "html/components/credit/newcredit.html"
var componentNewCreditHTML string
//go:embed "html/components/credit/addcredit.html"
var componentAddCreditHTML string
//go:embed "html/components/credit/editcredits.html"
var componentEditCreditsHTML string
//go:embed "html/components/link/editlinks.html"
var componentEditLinksHTML string
//go:embed "html/components/release/release.html"
var componentReleaseHTML string
//go:embed "html/components/artist/artist.html"
var componentArtistHTML string
//go:embed "html/components/track/track.html"
var componentTrackHTML string
//go:embed "html/components/track/newtrack.html"
var componentNewTrackHTML string
//go:embed "html/components/track/addtrack.html"
var componentAddTrackHTML string
//go:embed "html/components/track/edittracks.html"
var componentEditTracksHTML string
var BaseTemplate = template.Must( var BaseTemplate = template.Must(
template.New("base").Funcs( template.New("base").Funcs(
template.FuncMap{ template.FuncMap{
@ -84,6 +30,13 @@ var BaseTemplate = template.Must(
prideflagHTML, prideflagHTML,
}, "\n"))) }, "\n")))
//go:embed "html/components/release/release.html"
var componentReleaseHTML string
//go:embed "html/components/artist/artist.html"
var componentArtistHTML string
//go:embed "html/components/track/track.html"
var componentTrackHTML string
var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{ strings.Join([]string{
indexHTML, indexHTML,
@ -95,37 +48,83 @@ var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
//go:embed "html/login.html"
var loginHTML string
var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML)) var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML))
//go:embed "html/login-totp.html"
var loginTotpHTML string
var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML)) var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML))
//go:embed "html/register.html"
var registerHTML string
var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML)) var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML))
//go:embed "html/logout.html"
var logoutHTML string
var LogoutTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(logoutHTML)) var LogoutTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(logoutHTML))
//go:embed "html/edit-account.html"
var editAccountHTML string
var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editAccountHTML)) var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editAccountHTML))
//go:embed "html/totp-setup.html"
var totpSetupHTML string
var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML)) var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML))
//go:embed "html/totp-confirm.html"
var totpConfirmHTML string
var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML)) var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML))
var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{ var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{
"parseLevel": parseLevel, "parseLevel": func (level log.LogLevel) string {
"titleCase": titleCase, switch level {
"toLower": toLower, case log.LEVEL_INFO:
"prettyTime": prettyTime, return "INFO"
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)
},
"titleCase": func (logType string) string {
runes := []rune(logType)
for i, r := range runes {
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
runes[i] = r + ('A' - 'a')
}
}
return string(runes)
},
"toLower": func (str string) string {
return strings.ToLower(str)
},
"prettyTime": func (t time.Time) string {
return t.Format("02 Jan 2006, 15:04:05")
},
}).Parse(logsHTML)) }).Parse(logsHTML))
//go:embed "html/releases.html"
var releasesHTML string
var ReleasesTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( var ReleasesTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{ strings.Join([]string{
releasesHTML, releasesHTML,
componentReleaseHTML, componentReleaseHTML,
}, "\n"), }, "\n"),
)) ))
//go:embed "html/artists.html"
var artistsHTML string
var ArtistsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( var ArtistsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{ strings.Join([]string{
artistsHTML, artistsHTML,
componentArtistHTML, componentArtistHTML,
}, "\n"), }, "\n"),
)) ))
//go:embed "html/tracks.html"
var tracksHTML string
var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{ strings.Join([]string{
tracksHTML, tracksHTML,
@ -135,13 +134,21 @@ var TracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
//go:embed "html/edit-release.html"
var editReleaseHTML string
var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{ strings.Join([]string{
editReleaseHTML, editReleaseHTML,
componentTrackHTML, componentTrackHTML,
}, "\n"), }, "\n"),
)) ))
//go:embed "html/edit-artist.html"
var editArtistHTML string
var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML)) var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML))
//go:embed "html/edit-track.html"
var editTrackHTML string
var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{ strings.Join([]string{
editTrackHTML, editTrackHTML,
@ -151,41 +158,54 @@ var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
//go:embed "html/components/credit/newcredit.html"
var componentNewCreditHTML string
var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML)) var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML))
//go:embed "html/components/credit/addcredit.html"
var componentAddCreditHTML string
var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML)) var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML))
//go:embed "html/components/credit/editcredits.html"
var componentEditCreditsHTML string
var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML)) var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML))
//go:embed "html/components/link/editlinks.html"
var componentEditLinksHTML string
var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditLinksHTML)) var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditLinksHTML))
//go:embed "html/components/track/newtrack.html"
var componentNewTrackHTML string
var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML)) var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML))
//go:embed "html/components/track/addtrack.html"
var componentAddTrackHTML string
var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML)) var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML))
//go:embed "html/components/track/edittracks.html"
var componentEditTracksHTML string
var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewTrackHTML)) var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewTrackHTML))
func parseLevel(level log.LogLevel) string { //go:embed "html/blogs.html"
switch level { var blogsHTML string
case log.LEVEL_INFO: //go:embed "html/components/blog/blogpost.html"
return "INFO" var componentBlogPostHTML string
case log.LEVEL_WARN: var BlogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
return "WARN" strings.Join([]string{
} blogsHTML,
return fmt.Sprintf("%d?", level) componentBlogPostHTML,
} }, "\n"),
func titleCase(logType string) string { ))
runes := []rune(logType)
for i, r := range runes { //go:embed "html/edit-blog.html"
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' { var editBlogHTML string
runes[i] = r + ('A' - 'a') var EditBlogTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
} strings.Join([]string{
} editBlogHTML,
return string(runes) componentBlogPostHTML,
} }, "\n"),
func toLower(str string) string { ))
return strings.ToLower(str)
}
func prettyTime(t time.Time) string {
// return t.Format("2006-01-02 15:04:05")
// return t.Format("15:04:05, 2 Jan 2006")
return t.Format("02 Jan 2006, 15:04:05")
}

View file

@ -324,6 +324,10 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs) err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Release cannot have duplicate tracks", http.StatusBadRequest)
return
}
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -366,14 +370,14 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
err = controller.UpdateReleaseCredits(app.DB, release.ID, credits) err = controller.UpdateReleaseCredits(app.DB, release.ID, credits)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest) http.Error(w, "Artists may only be credited once", http.StatusBadRequest)
return return
} }
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) fmt.Printf("WARN: Failed to update credits for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
@ -394,6 +398,10 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
err = controller.UpdateReleaseLinks(app.DB, release.ID, links) err = controller.UpdateReleaseLinks(app.DB, release.ID, links)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Release cannot have duplicate link names", http.StatusBadRequest)
return
}
if strings.Contains(err.Error(), "no rows") { if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r) http.NotFound(w, r)
return return

View file

@ -153,7 +153,7 @@ header ul li a:hover {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
border-bottom: 1px solid #888; border-bottom: 1px solid #888;
background: var(--background); background-color: var(--background);
display: none; display: none;
} }

View file

@ -16,7 +16,7 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
background: var(--background); background-color: var(--background);
color: var(--on-background); color: var(--on-background);
font-family: "Monaspace Argon", monospace; font-family: "Monaspace Argon", monospace;
font-size: 18px; font-size: 18px;
@ -165,7 +165,7 @@ a#backtotop:hover {
@keyframes list-item-fadein { @keyframes list-item-fadein {
from { from {
opacity: 1; opacity: 1;
background: var(--links); background-color: var(--links);
} }
to { to {

View file

@ -30,7 +30,7 @@ header {
background-size: cover; background-size: cover;
background-position: center; background-position: center;
filter: blur(25px) saturate(25%) brightness(0.5); filter: blur(25px) saturate(25%) brightness(0.5);
-webkit-filter: blur(25px) saturate(25%) brightness(0.5);; -webkit-filter: blur(25px) saturate(25%) brightness(0.5);
animation: background-init .5s forwards,background-loop 30s ease-in-out infinite animation: background-init .5s forwards,background-loop 30s ease-in-out infinite
} }

View file

@ -83,9 +83,9 @@
{{else if .IsSingle}} {{else if .IsSingle}}
{{$Track := index .Tracks 0}} {{index .Tracks 0}}
{{if $Track.Description}} {{if .Description}}
<p id="description">{{$Track.Description}}</p> <p id="description">{{.Description}}</p>
{{end}} {{end}}
{{end}} {{end}}
@ -132,18 +132,18 @@
{{else if .Tracks}} {{else if .Tracks}}
<div id="tracks"> <div id="tracks">
<h2>TRACKS</h2> <h2>TRACKS</h2>
{{range $i, $track := .Tracks}} {{range .Tracks}}
<details> <details>
<summary class="album-track-title">{{add $i 1}}. {{$track.Title}}</summary> <summary class="album-track-title">{{.Number}}. {{.Title}}</summary>
{{if $track.Description}} {{if .Description}}
<p class="album-track-subheading">DESCRIPTION</p> <p class="album-track-subheading">DESCRIPTION</p>
{{$track.Description}} {{.Description}}
{{end}} {{end}}
<p class="album-track-subheading">LYRICS</p> <p class="album-track-subheading">LYRICS</p>
{{if $track.Lyrics}} {{if .Lyrics}}
{{$track.GetLyricsHTML}} {{.GetLyricsHTML}}
{{else}} {{else}}
<span class="empty">No lyrics.</span> <span class="empty">No lyrics.</span>
{{end}} {{end}}

View file

@ -28,11 +28,7 @@ var blogHTML string
var blogPostHTML string var blogPostHTML string
var BaseTemplate = template.Must( var BaseTemplate = template.Must(
template.New("base").Funcs( template.New("base").Parse(strings.Join([]string{
template.FuncMap{
"add": func (a int, b int) int { return a + b },
},
).Parse(strings.Join([]string{
layoutHTML, layoutHTML,
headerHTML, headerHTML,
footerHTML, footerHTML,

View file

@ -78,15 +78,15 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
} }
} }
response := *release if !release.IsReleased() && !privileged {
release.Tracks = nil
if release.IsReleased() || privileged { release.Credits = nil
response.Tracks = release.Tracks release.Links = nil
response.Credits = release.Credits
response.Links = release.Links
} }
err := templates.MusicGatewayTemplate.Execute(w, response) for i, track := range release.Tracks { track.Number = i + 1 }
err := templates.MusicGatewayTemplate.Execute(w, release)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error rendering music gateway for %s: %v\n", release.ID, err) fmt.Fprintf(os.Stderr, "Error rendering music gateway for %s: %v\n", release.ID, err)