aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/handlers_sections.go
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-12 12:33:57 +0300
committergrm <grm@eyesin.space>2026-09-12 12:33:57 +0300
commitdd13e4b73a105ee416b6cfd8eb19b217622c0031 (patch)
tree93ce503a09b99115efeeee6cd1a85781c1674b02 /internal/web/handlers_sections.go
parent2b450b66fee4906e746f6e04a6d07a72ff2faf64 (diff)
downloadblogspace-dd13e4b73a105ee416b6cfd8eb19b217622c0031.tar.gz
blogspace-dd13e4b73a105ee416b6cfd8eb19b217622c0031.tar.bz2
blogspace-dd13e4b73a105ee416b6cfd8eb19b217622c0031.zip
Add announcements: blog-wide notices on every page and post
A blog can have any number of announcements ("sections" in the schema): a book club's next meeting, this month's pick, a holiday closure. Each has a placement (above or below the posts, or under the menu when the menu is a left sidebar), a look (highlighted, warning, plain), an order, and an on/off switch so recurring notices can be kept around while hidden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/handlers_sections.go')
-rw-r--r--internal/web/handlers_sections.go161
1 files changed, 161 insertions, 0 deletions
diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go
new file mode 100644
index 0000000..21ea998
--- /dev/null
+++ b/internal/web/handlers_sections.go
@@ -0,0 +1,161 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gramanas/blogspace/internal/markdown"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+// Announcements ("sections" in the schema) are notices shown on every page and post.
+
+const maxSectionBytes = 20_000
+
+func (s *Server) sectionsURL(r *http.Request) string {
+ return "/b/" + currentBlog(r).Subdomain + "/announcements"
+}
+
+func (s *Server) handleSections(w http.ResponseWriter, r *http.Request) {
+ secs, err := s.st.ListSections(r.Context(), currentBlog(r).ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "dashboard/sections.html", map[string]any{"sections": secs})
+}
+
+func (s *Server) loadSection(w http.ResponseWriter, r *http.Request) *store.Section {
+ id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ sec, err := s.st.SectionByID(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 sec
+}
+
+func (s *Server) handleSectionForm(w http.ResponseWriter, r *http.Request) {
+ sec := &store.Section{Placement: "above", Style: "note", Enabled: true}
+ if r.PathValue("id") != "" {
+ if sec = s.loadSection(w, r); sec == nil {
+ return
+ }
+ }
+ s.render(w, r, "dashboard/section_form.html", map[string]any{"section": sec})
+}
+
+// normalizeSection clamps the enum fields so templates and CSS class names can trust them.
+func normalizeSection(sec *store.Section) {
+ sec.Placement = pick(sec.Placement, "above", "below", "sidebar")
+ sec.Style = pick(sec.Style, "note", "plain", "warning")
+}
+
+func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ sec := &store.Section{BlogID: blog.ID}
+ if r.PathValue("id") != "" {
+ if sec = s.loadSection(w, r); sec == nil {
+ return
+ }
+ }
+ sec.Title = strings.TrimSpace(r.FormValue("title"))
+ sec.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n")
+ sec.Placement = r.FormValue("placement")
+ sec.Style = r.FormValue("style")
+ sec.Enabled = r.FormValue("enabled") == "on"
+ normalizeSection(sec)
+ fail := func(msg string) {
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/section_form.html", map[string]any{"section": sec, "error": msg})
+ }
+ img, err := s.readUpload(r, "inline_image")
+ switch {
+ case err != nil:
+ fail("Image not added: " + err.Error())
+ return
+ case len(sec.Title) > 120:
+ fail("Title is too long (max 120 characters).")
+ return
+ case strings.TrimSpace(sec.BodyMD) == "" && img == nil:
+ fail("Write some text for the announcement.")
+ return
+ case len(sec.BodyMD) > maxSectionBytes:
+ fail("Announcement is too long (20 KB max).")
+ return
+ }
+ sec.BodyMD = appendImageMD(sec.BodyMD, img)
+ sec.BodyHTML = markdown.Render(sec.BodyMD)
+ if sec.ID == 0 {
+ sec, err = s.st.CreateSection(r.Context(), sec)
+ } else {
+ err = s.st.UpdateSection(r.Context(), sec)
+ }
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, s.sectionsURL(r)+"/"+strconv.FormatInt(sec.ID, 10)+"/edit", "Saved. Refresh your blog to see it.")
+}
+
+func (s *Server) handleSectionDeleteConfirm(w http.ResponseWriter, r *http.Request) {
+ sec := s.loadSection(w, r)
+ if sec == nil {
+ return
+ }
+ what := "this announcement"
+ if sec.Title != "" {
+ what = "the announcement \"" + sec.Title + "\""
+ }
+ s.render(w, r, "dashboard/confirm.html", map[string]any{"what": what, "action": r.URL.Path, "back": s.sectionsURL(r)})
+}
+
+func (s *Server) handleSectionDelete(w http.ResponseWriter, r *http.Request) {
+ sec := s.loadSection(w, r)
+ if sec == nil {
+ return
+ }
+ if err := s.st.DeleteSection(r.Context(), sec.BlogID, sec.ID); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, s.sectionsURL(r), "Announcement deleted.")
+}
+
+func (s *Server) handleSectionMove(w http.ResponseWriter, r *http.Request) {
+ sec := s.loadSection(w, r)
+ if sec == nil {
+ return
+ }
+ dir := 1
+ if r.FormValue("dir") == "up" {
+ dir = -1
+ }
+ if err := s.st.MoveSection(r.Context(), sec.BlogID, sec.ID, dir); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ http.Redirect(w, r, s.sectionsURL(r), http.StatusSeeOther)
+}
+
+// handleSectionToggle flips visibility without a trip through the edit form.
+func (s *Server) handleSectionToggle(w http.ResponseWriter, r *http.Request) {
+ sec := s.loadSection(w, r)
+ if sec == nil {
+ return
+ }
+ if err := s.st.SetSectionEnabled(r.Context(), sec.BlogID, sec.ID, !sec.Enabled); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ msg := "Announcement hidden."
+ if !sec.Enabled {
+ msg = "Announcement shown. Refresh your blog to see it."
+ }
+ redirectOK(w, r, s.sectionsURL(r), msg)
+}