aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/handlers_posts.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web/handlers_posts.go')
-rw-r--r--internal/web/handlers_posts.go158
1 files changed, 158 insertions, 0 deletions
diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go
new file mode 100644
index 0000000..8678985
--- /dev/null
+++ b/internal/web/handlers_posts.go
@@ -0,0 +1,158 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gramanas/blogspace/internal/markdown"
+ "github.com/gramanas/blogspace/internal/slug"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+func (s *Server) handlePosts(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ pageID, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64)
+ pages, err := s.st.ListPages(r.Context(), blog.ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ posts, err := s.st.ListPosts(r.Context(), blog.ID, pageID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "dashboard/posts.html", map[string]any{"posts": posts, "pages": pages, "pageID": pageID})
+}
+
+func (s *Server) loadPost(w http.ResponseWriter, r *http.Request) *store.Post {
+ id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ p, err := s.st.PostByID(r.Context(), currentBlog(r).ID, id)
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ http.NotFound(w, r)
+ } else {
+ s.serverError(w, err)
+ }
+ return nil
+ }
+ return p
+}
+
+func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ pages, err := s.st.ListPages(r.Context(), blog.ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ p := &store.Post{Published: true}
+ if r.PathValue("id") != "" {
+ if p = s.loadPost(w, r); p == nil {
+ return
+ }
+ } else if pid, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64); pid != 0 {
+ p.PageID = pid
+ } else if hp, err := s.st.HomePage(r.Context(), blog.ID); err == nil {
+ p.PageID = hp.ID
+ }
+ s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages})
+}
+
+func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ pages, err := s.st.ListPages(r.Context(), blog.ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ p := &store.Post{}
+ if r.PathValue("id") != "" {
+ if p = s.loadPost(w, r); p == nil {
+ return
+ }
+ }
+ p.Title = strings.TrimSpace(r.FormValue("title"))
+ p.Slug = strings.TrimSpace(r.FormValue("slug"))
+ p.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n")
+ p.Published = r.FormValue("published") == "on"
+ p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64)
+ autoSlug := p.Slug == ""
+ if autoSlug {
+ p.Slug = slug.Make(p.Title)
+ }
+ fail := func(status int, msg string) {
+ s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg})
+ }
+ pageOK := false
+ for _, pg := range pages {
+ if pg.ID == p.PageID {
+ pageOK = true
+ }
+ }
+ switch {
+ case p.Title == "" || len(p.Title) > 200:
+ fail(http.StatusBadRequest, "Title is required (max 200 characters).")
+ return
+ case !slug.Valid(p.Slug):
+ fail(http.StatusBadRequest, "Slug may only contain lowercase letters, digits and dashes.")
+ return
+ case !pageOK:
+ fail(http.StatusBadRequest, "Pick a page for this post.")
+ return
+ case len(p.BodyMD) > 200_000:
+ fail(http.StatusBadRequest, "Post is too long (200 KB max).")
+ return
+ }
+ p.BodyHTML = markdown.Render(p.BodyMD)
+ base := p.Slug
+ for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict
+ p.Slug = slug.WithSuffix(base, n)
+ if p.ID == 0 {
+ var created *store.Post
+ if created, err = s.st.CreatePost(r.Context(), p); err == nil {
+ p = created
+ }
+ } else {
+ err = s.st.UpdatePost(r.Context(), p)
+ }
+ if !errors.Is(err, store.ErrConflict) || !autoSlug || n >= 50 {
+ break
+ }
+ }
+ if err != nil {
+ if errors.Is(err, store.ErrConflict) {
+ fail(http.StatusConflict, "A post with that slug already exists on this page; choose another slug.")
+ return
+ }
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.")
+}
+
+func (s *Server) handlePostDeleteConfirm(w http.ResponseWriter, r *http.Request) {
+ p := s.loadPost(w, r)
+ if p == nil {
+ return
+ }
+ s.render(w, r, "dashboard/confirm.html", map[string]any{
+ "what": "the post \"" + p.Title + "\"",
+ "action": r.URL.Path,
+ "back": "/b/" + currentBlog(r).Subdomain + "/posts",
+ })
+}
+
+func (s *Server) handlePostDelete(w http.ResponseWriter, r *http.Request) {
+ p := s.loadPost(w, r)
+ if p == nil {
+ return
+ }
+ if err := s.st.DeletePost(r.Context(), currentBlog(r).ID, p.ID); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", "Post deleted.")
+}