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/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/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 4103737..f0b5742 100644 --- a/controller/blog.go +++ b/controller/blog.go @@ -93,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") }