diff --git a/admin/blog/blog.go b/admin/blog/blog.go new file mode 100644 index 0000000..806f34c --- /dev/null +++ b/admin/blog/blog.go @@ -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 + } + }) +} diff --git a/admin/http.go b/admin/http.go index 35bb153..4bbc9b6 100644 --- a/admin/http.go +++ b/admin/http.go @@ -7,6 +7,7 @@ import ( "arimelody-web/admin/account" "arimelody-web/admin/auth" + "arimelody-web/admin/blog" "arimelody-web/admin/core" "arimelody-web/admin/logs" "arimelody-web/admin/music" @@ -24,8 +25,10 @@ func Handler(app *model.AppState) http.Handler { mux.Handle("/totp", auth.LoginTOTPHandler(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("/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("/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) })) - mux.Handle("/", core.RequireAccount(AdminIndexHandler(app))) + mux.Handle("/", core.RequireAccount(adminIndexHandler(app))) // response wrapper to make sure a session cookie exists return core.EnforceSession(app, mux) } -func AdminIndexHandler(app *model.AppState) http.Handler { +func adminIndexHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) diff --git a/admin/music/releasehttp.go b/admin/music/releasehttp.go index de77655..978dacd 100644 --- a/admin/music/releasehttp.go +++ b/admin/music/releasehttp.go @@ -156,7 +156,8 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { func serveNewCredit(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artistID := strings.Split(r.URL.Path, "/")[3] + split := strings.Split(r.URL.Path, "/") + artistID := split[len(split) - 1] artist, err := controller.GetArtist(app.DB, artistID) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %s\n", artistID, err) @@ -194,7 +195,7 @@ func serveEditTracks(release *model.Release) http.Handler { type editTracksData struct { Release *model.Release } - for i, track := range release.Tracks { track.Number = i + 1 } + for i, track := range release.Tracks { track.Number = i + 1 } err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release }) if err != nil { @@ -232,7 +233,8 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { func serveNewTrack(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - trackID := strings.Split(r.URL.Path, "/")[3] + split := strings.Split(r.URL.Path, "/") + trackID := split[len(split) - 1] track, err := controller.GetTrack(app.DB, trackID) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %s\n", trackID, err) diff --git a/admin/releasehttp.go b/admin/releasehttp.go new file mode 100644 index 0000000..b0d6d3c --- /dev/null +++ b/admin/releasehttp.go @@ -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) + } + }) +} diff --git a/admin/static/admin.css b/admin/static/admin.css index a8e17c3..821d294 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -111,7 +111,7 @@ body { font-family: "Inter", sans-serif; font-size: 16px; color: var(--fg-0); - background: var(--bg-0); + background-color: var(--bg-0); transition: background .1s ease-out, color .1s ease-out; } @@ -252,12 +252,6 @@ a { transition: color .1s ease-out, background-color .1s ease-out; } -/* -a:hover { - text-decoration: underline; -} -*/ - img.icon { height: .8em; transition: filter .1s ease-out; @@ -270,10 +264,6 @@ code { border-radius: 4px; } -h1 { - margin: 0 0 .5em 0; -} - .cards { @@ -287,7 +277,7 @@ h1 { .card { flex-basis: 40em; padding: 1em; - background: var(--bg-1); + background-color: var(--bg-1); border-radius: 16px; box-shadow: var(--shadow-lg); @@ -365,7 +355,7 @@ a.delete:not(.button) { font-size: inherit; color: inherit; - background: var(--bg-2); + background-color: var(--bg-2); border: none; border-radius: 10em; box-shadow: var(--shadow-sm); @@ -384,27 +374,27 @@ button:active, .button:active { .button.new, button.new { color: var(--col-on-new); - background: var(--col-new); + background-color: var(--col-new); } .button.save, button.save { color: var(--col-on-save); - background: var(--col-save); + background-color: var(--col-save); } .button.delete, button.delete { color: var(--col-on-delete); - background: var(--col-delete); + background-color: var(--col-delete); } .button:hover, button:hover { color: var(--bg-3); - background: var(--fg-3); + background-color: var(--fg-3); } .button:active, button:active { color: var(--bg-2); - background: var(--fg-0); + background-color: var(--fg-0); } .button[disabled], button[disabled] { color: var(--fg-0) !important; - background: var(--bg-3) !important; + background-color: var(--bg-3) !important; opacity: .5; cursor: default !important; } diff --git a/admin/static/artists.css b/admin/static/artists.css index 516a998..faa5888 100644 --- a/admin/static/artists.css +++ b/admin/static/artists.css @@ -2,7 +2,7 @@ padding: .5em; color: var(--fg-3); - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); border-radius: 16px; text-align: center; @@ -12,7 +12,7 @@ } .artist:hover { - background: var(--bg-1); + background-color: var(--bg-1); text-decoration: hover; } diff --git a/admin/static/artists.js b/admin/static/artists.js index 29eab22..e3a2d5a 100644 --- a/admin/static/artists.js +++ b/admin/static/artists.js @@ -4,4 +4,29 @@ document.addEventListener("readystatechange", () => { document.querySelectorAll(".artists-group .artist").forEach(el => { hijackClickEvent(el, el.querySelector("a.artist-name")) }); + + const newArtistBtn = document.getElementById("create-artist"); + if (newArtistBtn) newArtistBtn.addEventListener("click", event => { + event.preventDefault(); + const id = prompt("Enter an ID for this artist:"); + if (id == null || id == "") return; + + fetch("/api/v1/artist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({id}) + }).then(res => { + res.text().then(text => { + if (res.ok) { + location = "/admin/artists/" + id; + } else { + alert(text); + console.error(text); + } + }) + }).catch(err => { + alert("Failed to create artist. Check the console for details."); + console.error(err); + }); + }); }); diff --git a/admin/static/blog.css b/admin/static/blog.css new file mode 100644 index 0000000..a7f281b --- /dev/null +++ b/admin/static/blog.css @@ -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; +} diff --git a/admin/static/blog.js b/admin/static/blog.js new file mode 100644 index 0000000..e69de29 diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index c43d6e9..8e89cbe 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -33,7 +33,7 @@ form#delete-account input { justify-content: space-between; color: var(--fg-3); - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); border-radius: 16px; } diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 7bf146b..0bb85c0 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -6,7 +6,7 @@ gap: 1.2em; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); } @@ -50,18 +50,11 @@ input[type="text"] { font-family: inherit; font-weight: inherit; color: inherit; - background: var(--bg-0); + background-color: var(--bg-0); border: none; border-radius: 4px; outline: none; } -input[type="text"]:hover { - border-color: #80808080; -} -input[type="text"]:active, -input[type="text"]:focus { - border-color: #808080; -} .artist-actions { margin-top: auto; @@ -84,7 +77,7 @@ input[type="text"]:focus { align-items: center; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); cursor: pointer; @@ -92,7 +85,7 @@ input[type="text"]:focus { } .credit:hover { - background: var(--bg-1); + background-color: var(--bg-1); } .release-artwork { diff --git a/admin/static/edit-blog.css b/admin/static/edit-blog.css new file mode 100644 index 0000000..86626d6 --- /dev/null +++ b/admin/static/edit-blog.css @@ -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; +} diff --git a/admin/static/edit-blog.js b/admin/static/edit-blog.js new file mode 100644 index 0000000..642c69c --- /dev/null +++ b/admin/static/edit-blog.js @@ -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; + }); +}); diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 434b487..8186f2c 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -12,7 +12,7 @@ input[type="text"] { gap: 1.2em; border-radius: 8px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); transition: background .1s ease-out, color .1s ease-out; @@ -33,7 +33,7 @@ input[type="text"] { .release-artwork #remove-artwork { margin-top: .5em; padding: .3em .6em; - background: var(--bg-3); + background-color: var(--bg-3); } .release-info { @@ -62,13 +62,13 @@ input[type="text"] { } #title:hover { - background: var(--bg-3); + background-color: var(--bg-3); border-color: var(--fg-0); } #title:active, #title:focus { - background: var(--bg-3); + background-color: var(--bg-3); } .release-title small { @@ -93,7 +93,7 @@ input[type="text"] { .release-info table tr td:not(:first-child) select:hover, .release-info table tr td:not(:first-child) input:hover, .release-info table tr td:not(:first-child) textarea:hover { - background: var(--bg-3); + background-color: var(--bg-3); cursor: pointer; } .release-info table td select, @@ -127,7 +127,7 @@ input[type="text"] { .release-actions button, .release-actions .button { color: var(--fg-2); - background: var(--bg-3); + background-color: var(--bg-3); } dialog { @@ -234,7 +234,7 @@ dialog div.dialog-actions { gap: 1em; border-radius: 8px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); } @@ -280,7 +280,7 @@ dialog div.dialog-actions { border: none; border-radius: 4px; color: var(--fg-2); - background: var(--bg-0); + background-color: var(--bg-0); } #editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { margin: 0 .3em; @@ -299,6 +299,7 @@ dialog div.dialog-actions { #editcredits .credit .delete { margin-right: .5em; cursor: pointer; + overflow: visible; } #editcredits .credit .delete:hover { text-decoration: underline; @@ -315,14 +316,17 @@ dialog div.dialog-actions { display: flex; gap: .5em; cursor: pointer; + background-color: var(--bg-2); } #addcredit ul li.new-artist:nth-child(even) { background: #f0f0f0; + background-color: var(--bg-1); } #addcredit ul li.new-artist:hover { background: #e0e0e0; + background-color: var(--bg-2); } #addcredit .new-artist .artist-id { @@ -375,6 +379,8 @@ dialog div.dialog-actions { #editlinks tr { display: flex; + background-color: var(--bg-1); + transition: background-color .1s ease-out; } #editlinks th { @@ -385,7 +391,7 @@ dialog div.dialog-actions { } #editlinks tr:nth-child(odd) { - background: #f8f8f8; + background-color: var(--bg-2); } #editlinks tr th, @@ -416,6 +422,11 @@ dialog div.dialog-actions { width: 1em; pointer-events: none; } +@media (prefers-color-scheme: dark) { + #editlinks tr .grabber img { + filter: invert(); + } +} #editlinks tr .link-name { width: 8em; } @@ -454,6 +465,7 @@ dialog div.dialog-actions { } #edittracks .track { + background-color: var(--bg-2); transition: transform .2s ease-out, opacity .2s; } @@ -476,7 +488,7 @@ dialog div.dialog-actions { } #edittracks .track:nth-child(even) { - background: #f0f0f0; + background-color: var(--bg-1); } #edittracks .track-number { @@ -492,7 +504,6 @@ dialog div.dialog-actions { #addtrack ul { padding: 0; list-style: none; - background: #f8f8f8; } #addtrack ul li.new-track { diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index f292ca5..4824124 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -8,7 +8,7 @@ gap: 1.2em; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); } @@ -45,25 +45,13 @@ font-weight: inherit; font-family: inherit; font-size: inherit; - background: var(--bg-0); + background-color: var(--bg-0); border: none; border-radius: 4px; outline: none; color: inherit; } -.track-info input[type="text"]:hover, -.track-info textarea:hover { - border-color: #80808080; -} - -.track-info input[type="text"]:active, -.track-info textarea:active, -.track-info input[type="text"]:focus, -.track-info textarea:focus { - border-color: #808080; -} - .track-actions { margin-top: 1em; display: flex; diff --git a/admin/static/index.js b/admin/static/index.js deleted file mode 100644 index 60bdfd0..0000000 --- a/admin/static/index.js +++ /dev/null @@ -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); - }); -}); diff --git a/admin/static/logs.css b/admin/static/logs.css index 8da60d0..2412a2b 100644 --- a/admin/static/logs.css +++ b/admin/static/logs.css @@ -8,7 +8,7 @@ form#search-form { padding: 1em; border-radius: 16px; color: var(--fg-0); - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); } @@ -23,7 +23,7 @@ div#search { border: none; border-radius: 16px; color: var(--fg-1); - background: var(--bg-0); + background-color: var(--bg-0); box-shadow: var(--shadow-sm); } @@ -100,8 +100,8 @@ td.log-content { #logs .log.warn { color: var(--col-on-warn); - background: var(--col-warn); + background-color: var(--col-warn); } #logs .log.warn:hover { - background: var(--col-warn-hover); + background-color: var(--col-warn-hover); } diff --git a/admin/static/releases.css b/admin/static/releases.css index 0694875..19f393f 100644 --- a/admin/static/releases.css +++ b/admin/static/releases.css @@ -6,7 +6,7 @@ gap: 1em; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); transition: background .1s ease-out, color .1s ease-out; @@ -67,14 +67,14 @@ display: inline-block; border-radius: 4px; - background: var(--bg-3); + background-color: var(--bg-3); box-shadow: var(--shadow-sm); transition: color .1s ease-out, background .1s ease-out; } .release .release-actions a:hover { - background: var(--bg-0); + background-color: var(--bg-0); color: var(--fg-3); text-decoration: none; } diff --git a/admin/static/releases.js b/admin/static/releases.js new file mode 100644 index 0000000..af12429 --- /dev/null +++ b/admin/static/releases.js @@ -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); + }); + }); +}); diff --git a/admin/static/tracks.css b/admin/static/tracks.css index c36c1b1..3ea4f06 100644 --- a/admin/static/tracks.css +++ b/admin/static/tracks.css @@ -12,7 +12,7 @@ gap: .5em; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); transition: background .1s ease-out, color .1s ease-out; @@ -44,11 +44,6 @@ opacity: .5; } -#tracks .track-album.empty { - color: #ff2020; - opacity: 1; -} - #tracks .track-description { font-style: italic; } @@ -67,61 +62,4 @@ margin: 0; display: flex; flex-direction: row; - /* - justify-content: space-between; - */ } - -/* -.track { - margin-bottom: 1em; - padding: 1em; - display: flex; - flex-direction: column; - gap: .5em; - - border-radius: 8px; - background-color: var(--bg-2); - box-shadow: var(--shadow-md); - - transition: color .1s ease-out, background-color .1s ease-out; -} - -.track p { - margin: 0; -} - -.track-id { - width: fit-content; - font-family: "Monaspace Argon", monospace; - font-size: .8em; - font-style: italic; - line-height: 1em; - user-select: all; -} - -.track-album { - margin-left: auto; - font-style: italic; - font-size: .75em; - opacity: .5; -} - -.track-album.empty { - color: #ff2020; - opacity: 1; -} - -.track-description { - font-style: italic; -} - -.track-lyrics { - max-height: 10em; - overflow-y: scroll; -} - -.track .empty { - opacity: 0.75; -} -*/ diff --git a/admin/static/tracks.js b/admin/static/tracks.js new file mode 100644 index 0000000..bef1152 --- /dev/null +++ b/admin/static/tracks.js @@ -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); + }); +}); diff --git a/admin/templates/html/blogs.html b/admin/templates/html/blogs.html new file mode 100644 index 0000000..143d848 --- /dev/null +++ b/admin/templates/html/blogs.html @@ -0,0 +1,33 @@ +{{define "head"}} +Blog - ari melody 💫 + + +{{end}} + +{{define "content"}} +
+
+

Blog Posts ({{.TotalPosts}} total)

+ Create New +
+ + {{if .Collections}} +
+ {{range .Collections}} + {{if .Posts}} +
+

{{.Year}}

+ {{range .Posts}} + {{block "blogpost" .}}{{end}} + {{end}} +
+ {{end}} + {{end}} +
+ {{else}} +

There are no blog posts.

+ {{end}} +
+ + +{{end}} diff --git a/admin/templates/html/components/blog/blogpost.html b/admin/templates/html/components/blog/blogpost.html new file mode 100644 index 0000000..984bdca --- /dev/null +++ b/admin/templates/html/components/blog/blogpost.html @@ -0,0 +1,14 @@ +{{define "blogpost"}} +
+

{{.Title}}{{if not .Visible}} (Not published){{end}}

+

+ {{.Author.Username}}'s avatar {{.Author.Username}} + • {{.PrintDate}} +

+

{{.Description}}

+
+ Edit + View +
+
+{{end}} diff --git a/admin/templates/html/components/track/edittracks.html b/admin/templates/html/components/track/edittracks.html index dadc1a5..6469c4c 100644 --- a/admin/templates/html/components/track/edittracks.html +++ b/admin/templates/html/components/track/edittracks.html @@ -49,7 +49,6 @@ deleteBtn.addEventListener("click", e => { e.preventDefault(); - if (!confirm("Are you sure you want to remove " + trackTitle + "?")) return; trackItem.remove(); refreshTrackNumbers(); }); diff --git a/admin/templates/html/edit-blog.html b/admin/templates/html/edit-blog.html new file mode 100644 index 0000000..71570ee --- /dev/null +++ b/admin/templates/html/edit-blog.html @@ -0,0 +1,87 @@ +{{define "head"}} +Editing {{.Post.Title}} - ari melody 💫 + + +{{end}} + +{{define "content"}} +
+

Editing Blog Post

+ +
+ +

+
{{.Post.Title}}
+

+ + + + + + + + + + + + + + + +
+ View + +
+
+ +
+
+

Danger Zone

+
+

+ Clicking the button below will delete this blog post. + This action is irreversible. + You will be prompted to confirm this decision. +

+ +
+
+ + +{{end}} diff --git a/admin/templates/html/edit-release.html b/admin/templates/html/edit-release.html index 42f623c..1ca31fe 100644 --- a/admin/templates/html/edit-release.html +++ b/admin/templates/html/edit-release.html @@ -8,7 +8,7 @@ {{define "content"}}
-

Editing {{.Release.Title}}

+

Editing Release

@@ -126,7 +126,7 @@
{{end}} {{if not .Release.Credits}} -

There are no credits.

+

This release has no credits.

{{end}}
@@ -141,11 +141,15 @@ >Edit + {{if .Release.Links}} + {{else}} +

This release has no links.

+ {{end}}
@@ -162,6 +166,9 @@ {{range .Release.Tracks}} {{block "track" .}}{{end}} {{end}} + {{if not .Release.Tracks}} +

This release has no tracks.

+ {{end}}
diff --git a/admin/templates/html/index.html b/admin/templates/html/index.html index afb9471..292c617 100644 --- a/admin/templates/html/index.html +++ b/admin/templates/html/index.html @@ -56,6 +56,7 @@
+ - + {{end}} diff --git a/admin/templates/html/layout.html b/admin/templates/html/layout.html index e65a7e7..a940321 100644 --- a/admin/templates/html/layout.html +++ b/admin/templates/html/layout.html @@ -27,7 +27,9 @@ +
+

music