diff --git a/admin/blog/blog.go b/admin/blog/blog.go index 806f34c..294ac66 100644 --- a/admin/blog/blog.go +++ b/admin/blog/blog.go @@ -66,7 +66,7 @@ func serveBlogIndex(app *model.AppState) http.Handler { if post.CreatedAt.Year() != collectionYear || i == len(posts) - 1 { if i == len(posts) - 1 { - collectionPosts = append(collectionPosts, &authoredPost) + collectionPosts = append([]*blogPost{&authoredPost}, collectionPosts...) } collections = append(collections, &blogPostGroup{ Year: collectionYear, diff --git a/admin/http.go b/admin/http.go index 4bbc9b6..55265cb 100644 --- a/admin/http.go +++ b/admin/http.go @@ -62,39 +62,69 @@ func adminIndexHandler(app *model.AppState) http.Handler { releases, err := controller.GetAllReleases(app.DB, false, 3, true) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } releaseCount, err := controller.GetReleaseCount(app.DB, false) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } artists, err := controller.GetAllArtists(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } artistCount, err := controller.GetArtistCount(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } tracks, err := controller.GetOrphanTracks(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } trackCount, err := controller.GetTrackCount(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + type BlogPost struct { + *model.BlogPost + Author *model.Account + } + blogPosts, err := controller.GetBlogPosts(app.DB, false, 1, 0) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog posts: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + var latestBlogPost *BlogPost = nil + if len(blogPosts) > 0 { + author, err := controller.GetAccountByID(app.DB, blogPosts[0].AuthorID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull latest blog post author %s: %v\n", blogPosts[0].AuthorID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + latestBlogPost = &BlogPost{ + BlogPost: blogPosts[0], + Author: author, + } + } + blogCount, err := controller.GetBlogPostCount(app.DB, false) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog post count: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -107,6 +137,8 @@ func adminIndexHandler(app *model.AppState) http.Handler { ArtistCount int Tracks []*model.Track TrackCount int + BlogPost *BlogPost + BlogCount int } err = templates.IndexTemplate.Execute(w, IndexData{ @@ -117,9 +149,11 @@ func adminIndexHandler(app *model.AppState) http.Handler { ArtistCount: artistCount, Tracks: tracks, TrackCount: trackCount, + BlogPost: latestBlogPost, + BlogCount: blogCount, }) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/admin/static/blog.css b/admin/static/blog.css index a7f281b..72d3cef 100644 --- a/admin/static/blog.css +++ b/admin/static/blog.css @@ -1,5 +1,11 @@ +.blog-collection { + display: flex; + flex-direction: column; + gap: .5em; +} + .blog-collection h2 { - margin: .5em 1em; + margin: 0 0 0 1em; font-size: 1em; text-transform: uppercase; font-weight: 600; diff --git a/admin/static/blog.js b/admin/static/blog.js index e69de29..3ce9111 100644 --- a/admin/static/blog.js +++ b/admin/static/blog.js @@ -0,0 +1,25 @@ +document.addEventListener('readystatechange', () => { + const newBlogBtn = document.getElementById("create-post"); + if (newBlogBtn) newBlogBtn.addEventListener("click", event => { + event.preventDefault(); + const id = prompt("Enter an ID for this blog post:"); + if (id == null || id == "") return; + + fetch("/api/v1/blog", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({id}) + }).then(res => { + if (res.ok) location = "/admin/blogs/" + 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/templates/html/index.html b/admin/templates/html/index.html index af08c09..90214f1 100644 --- a/admin/templates/html/index.html +++ b/admin/templates/html/index.html @@ -4,6 +4,7 @@ + {{end}} {{define "content"}} @@ -52,6 +53,18 @@ {{block "track" .}}{{end}} {{end}} + +
+
+

Latest Blog Post ({{.BlogCount}} total)

+ Create New +
+ {{if .BlogPost}} + {{block "blogpost" .BlogPost}}{{end}} + {{else}} +

There are no blog posts.

+ {{end}} +
@@ -59,4 +72,5 @@ + {{end}} diff --git a/admin/templates/templates.go b/admin/templates/templates.go index 6244540..924188b 100644 --- a/admin/templates/templates.go +++ b/admin/templates/templates.go @@ -43,6 +43,7 @@ var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse( componentReleaseHTML, componentArtistHTML, componentTrackHTML, + componentBlogPostHTML, }, "\n"), )) diff --git a/api/api.go b/api/api.go index 5312a55..68a08a9 100644 --- a/api/api.go +++ b/api/api.go @@ -1,14 +1,14 @@ package api import ( - "context" - "fmt" - "net/http" - "os" - "strings" + "context" + "fmt" + "net/http" + "os" + "strings" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" ) func Handler(app *model.AppState) http.Handler { @@ -45,7 +45,7 @@ func Handler(app *model.AppState) http.Handler { http.NotFound(w, r) } })) - mux.Handle("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + artistIndexHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: // GET /api/v1/artist @@ -56,7 +56,9 @@ func Handler(app *model.AppState) http.Handler { default: http.NotFound(w, r) } - })) + }) + mux.Handle("/v1/artist/", artistIndexHandler) + mux.Handle("/v1/artist", artistIndexHandler) // RELEASE ENDPOINTS @@ -87,7 +89,7 @@ func Handler(app *model.AppState) http.Handler { http.NotFound(w, r) } })) - mux.Handle("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + musicIndexHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: // GET /api/v1/music @@ -98,7 +100,9 @@ func Handler(app *model.AppState) http.Handler { default: http.NotFound(w, r) } - })) + }) + mux.Handle("/v1/music/", musicIndexHandler) + mux.Handle("/v1/music", musicIndexHandler) // TRACK ENDPOINTS @@ -129,7 +133,7 @@ func Handler(app *model.AppState) http.Handler { http.NotFound(w, r) } })) - mux.Handle("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + trackIndexHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: // GET /api/v1/track (admin) @@ -140,7 +144,17 @@ func Handler(app *model.AppState) http.Handler { default: http.NotFound(w, r) } - })) + }) + mux.Handle("/v1/track/", trackIndexHandler) + mux.Handle("/v1/track", trackIndexHandler) + + // BLOG ENDPOINTS + + mux.Handle("GET /v1/blog/{id}", ServeBlog(app)) + mux.Handle("PUT /v1/blog/{id}", requireAccount(UpdateBlog(app))) + mux.Handle("DELETE /v1/blog/{id}", requireAccount(DeleteBlog(app))) + mux.Handle("GET /v1/blog", ServeAllBlogs(app)) + mux.Handle("POST /v1/blog", requireAccount(CreateBlog(app))) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session, err := getSession(app, r) diff --git a/api/artist.go b/api/artist.go index 01899a6..322bc5d 100644 --- a/api/artist.go +++ b/api/artist.go @@ -99,7 +99,7 @@ func CreateArtist(app *model.AppState) http.Handler { } if artist.ID == "" { - http.Error(w, "Artist ID cannot be blank\n", http.StatusBadRequest) + http.Error(w, "Artist ID cannot be blank", http.StatusBadRequest) return } if artist.Name == "" { artist.Name = artist.ID } @@ -107,7 +107,7 @@ func CreateArtist(app *model.AppState) http.Handler { err = controller.CreateArtist(app.DB, &artist) if err != nil { if strings.Contains(err.Error(), "duplicate key") { - http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest) + http.Error(w, fmt.Sprintf("Artist %s already exists", artist.ID), http.StatusBadRequest) return } fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err) diff --git a/api/blog.go b/api/blog.go new file mode 100644 index 0000000..f935b95 --- /dev/null +++ b/api/blog.go @@ -0,0 +1,217 @@ +package api + +import ( + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" +) + +func ServeAllBlogs(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + onlyVisible := true + if session != nil && session.Account != nil { + onlyVisible = false + } + + posts, err := controller.GetBlogPosts(app.DB, onlyVisible, -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 + } + + type ( + BlogAuthor struct { + ID string `json:"id"` + Avatar string `json:"avatar"` + } + + BlogPost struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Author BlogAuthor `json:"author"` + CreatedAt time.Time `json:"created_at"` + } + ) + resPosts := []*BlogPost{} + + for _, post := range posts { + author, err := controller.GetAccountByID(app.DB, post.AuthorID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch author for blog post %s: %v\n", post.ID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + resPosts = append(resPosts, &BlogPost{ + ID: post.ID, + Title: post.Title, + Description: post.Description, + Author: BlogAuthor{ + ID: author.Username, + Avatar: author.AvatarURL.String, + }, + CreatedAt: post.CreatedAt, + }) + } + + err = json.NewEncoder(w).Encode(resPosts) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to serve blog posts: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} + +func ServeBlog(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + privileged := session != nil && session.Account != nil + blogID := r.PathValue("id") + + blog, err := controller.GetBlogPost(app.DB, blogID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.NotFound(w, r) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post %s: %v\n", blogID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if !blog.Visible && !privileged { + http.NotFound(w, r) + return + } + + err = json.NewEncoder(w).Encode(blog) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to serve blog post %s: %v\n", blogID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} + +func CreateBlog(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + var blog model.BlogPost + err := json.NewDecoder(r.Body).Decode(&blog) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + if blog.ID == "" { + http.Error(w, "Post ID cannot be empty", http.StatusBadRequest) + return + } + + if blog.Title == "" { blog.Title = blog.ID } + + if !blog.CreatedAt.Equal(time.Unix(0, 0)) { + blog.CreatedAt = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC) + } + + blog.AuthorID = session.Account.ID + + err = controller.CreateBlogPost(app.DB, &blog) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") { + http.Error(w, fmt.Sprintf("Post %s already exists", blog.ID), http.StatusBadRequest) + return + } + fmt.Printf("WARN: Failed to create blog %s: %s\n", blog.ID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" created by \"%s\".", blog.ID, session.Account.Username) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + err = json.NewEncoder(w).Encode(blog) + if err != nil { + fmt.Printf("WARN: Blog post %s created, but failed to send JSON response: %s\n", blog.ID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + +func UpdateBlog(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") + + blog, err := controller.GetBlogPost(app.DB, blogID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.NotFound(w, r) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post %s: %v\n", blogID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + err = json.NewDecoder(r.Body).Decode(blog) + if err != nil { + fmt.Printf("WARN: Failed to update blog %s: %s\n", blog.ID, err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + err = controller.UpdateBlogPost(app.DB, blogID, blog) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.NotFound(w, r) + return + } + fmt.Printf("WARN: Failed to update release %s: %s\n", blogID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" updated by \"%s\".", blog.ID, session.Account.Username) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + err = json.NewEncoder(w).Encode(blog) + if err != nil { + fmt.Printf("WARN: Blog post %s created, but failed to send JSON response: %s\n", blog.ID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + +func DeleteBlog(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") + + rowsAffected, err := controller.DeleteBlogPost(app.DB, blogID) + if err != nil { + fmt.Printf("WARN: Failed to delete blog post %s: %s\n", blogID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if rowsAffected == 0 { + http.NotFound(w, r) + return + } + + app.Log.Info(log.TYPE_BLOG, "Blog post \"%s\" deleted by \"%s\".", blogID, session.Account.Username) + }) +} diff --git a/api/release.go b/api/release.go index 69b7f12..f2bf479 100644 --- a/api/release.go +++ b/api/release.go @@ -200,7 +200,7 @@ func CreateRelease(app *model.AppState) http.Handler { } if release.ID == "" { - http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest) + http.Error(w, "Release ID cannot be empty", http.StatusBadRequest) return } @@ -216,7 +216,7 @@ func CreateRelease(app *model.AppState) http.Handler { err = controller.CreateRelease(app.DB, &release) if err != nil { if strings.Contains(err.Error(), "duplicate key") { - http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest) + http.Error(w, fmt.Sprintf("Release %s already exists", release.ID), http.StatusBadRequest) return } fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err) diff --git a/api/track.go b/api/track.go index 4e48418..ac5b83b 100644 --- a/api/track.go +++ b/api/track.go @@ -86,7 +86,7 @@ func CreateTrack(app *model.AppState) http.Handler { } if track.Title == "" { - http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest) + http.Error(w, "Track title cannot be empty", http.StatusBadRequest) return } @@ -121,7 +121,7 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { } if track.Title == "" { - http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest) + http.Error(w, "Track title cannot be empty", http.StatusBadRequest) return } diff --git a/controller/blog.go b/controller/blog.go index 7fb201e..f0b5742 100644 --- a/controller/blog.go +++ b/controller/blog.go @@ -44,6 +44,18 @@ func GetBlogPosts(db *sqlx.DB, onlyVisible bool, limit int, offset int) ([]*mode return blogs, nil } +func GetBlogPostCount(db *sqlx.DB, onlyVisible bool) (int, error) { + query := "SELECT count(*) FROM blogpost" + if onlyVisible { + query += " WHERE visible=true" + } + + var count int + err := db.Get(&count, query) + + return count, err +} + func CreateBlogPost(db *sqlx.DB, post *model.BlogPost) error { _, err := db.Exec( "INSERT INTO blogpost (id,title,description,visible,author,markdown,html,bluesky_actor,bluesky_post) " + @@ -81,3 +93,18 @@ func UpdateBlogPost(db *sqlx.DB, postID string, post *model.BlogPost) error { return err } + +func DeleteBlogPost(db *sqlx.DB, postID string) (int64, error) { + result, err := db.Exec( + "DELETE FROM blogpost "+ + "WHERE id=$1", + postID, + ) + if err != nil { + return 0, err + } + + rowsAffected, _ := result.RowsAffected() + + return rowsAffected, nil +} diff --git a/model/blog.go b/model/blog.go index 4a2d03a..c1d44a9 100644 --- a/model/blog.go +++ b/model/blog.go @@ -11,17 +11,17 @@ import ( type ( BlogPost struct { - ID string `db:"id"` - Title string `db:"title"` - Description string `db:"description"` - Visible bool `db:"visible"` - CreatedAt time.Time `db:"created_at"` - ModifiedAt sql.NullTime `db:"modified_at"` - AuthorID string `db:"author"` - Markdown string `db:"markdown"` - HTML template.HTML `db:"html"` - BlueskyActorID *string `db:"bluesky_actor"` - BlueskyPostID *string `db:"bluesky_post"` + ID string `json:"id" db:"id"` + Title string `json:"title" db:"title"` + Description string `json:"description" db:"description"` + Visible bool `json:"visible" db:"visible"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + ModifiedAt sql.NullTime `json:"modified_at" db:"modified_at"` + AuthorID string `json:"author" db:"author"` + Markdown string `json:"markdown" db:"markdown"` + HTML template.HTML `json:"html" db:"html"` + BlueskyActorID *string `json:"bluesky_actor" db:"bluesky_actor"` + BlueskyPostID *string `json:"bluesky_post" db:"bluesky_post"` } ) @@ -40,12 +40,12 @@ func (b *BlogPost) GetMonth() string { } func (b *BlogPost) PrintDate() string { - return b.CreatedAt.Format("2 January 2006, 03:04") + return b.CreatedAt.Format("2 January 2006, 15:04") } func (b *BlogPost) PrintModifiedDate() string { if !b.ModifiedAt.Valid { return "" } - return b.ModifiedAt.Time.Format("2 January 2006, 03:04") + return b.ModifiedAt.Time.Format("2 January 2006, 15:04") }