From 65366032fd1092f02ca662b217d79668fef8eaa6 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 7 Nov 2025 01:04:10 +0000 Subject: [PATCH] add blog index to admin dashboard --- admin/blog/blog.go | 110 ++++++++ admin/http.go | 9 +- admin/releasehttp.go | 256 ++++++++++++++++++ admin/static/admin.css | 4 - admin/static/blog.css | 80 ++++++ admin/static/blog.js | 0 admin/templates/html/blogs.html | 33 +++ .../html/components/blog/blogpost.html | 14 + admin/templates/html/layout.html | 9 + admin/templates/templates.go | 179 ++++++------ 10 files changed, 603 insertions(+), 91 deletions(-) create mode 100644 admin/blog/blog.go create mode 100644 admin/releasehttp.go create mode 100644 admin/static/blog.css create mode 100644 admin/static/blog.js create mode 100644 admin/templates/html/blogs.html create mode 100644 admin/templates/html/components/blog/blogpost.html diff --git a/admin/blog/blog.go b/admin/blog/blog.go new file mode 100644 index 0000000..322b7db --- /dev/null +++ b/admin/blog/blog.go @@ -0,0 +1,110 @@ +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}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + blogID := r.PathValue("id") + w.Write([]byte(blogID)) + })) + + mux.Handle("/", handleBlogIndex(app)) + + mux.ServeHTTP(w, r) + }) +} + +type ( + blogPost struct { + *model.BlogPost + Author *model.Account + } + + blogPostGroup struct { + Year int + Posts []*blogPost + } +) + +func handleBlogIndex(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := controller.GetSessionFromRequest(app, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + 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 + } + }) +} 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/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 87a251f..821d294 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -264,10 +264,6 @@ code { border-radius: 4px; } -h1 { - margin: 0 0 .5em 0; -} - .cards { 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/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/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