diff options
| author | grm <grm@eyesin.space> | 2026-09-12 12:33:57 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-12 12:33:57 +0300 |
| commit | dd13e4b73a105ee416b6cfd8eb19b217622c0031 (patch) | |
| tree | 93ce503a09b99115efeeee6cd1a85781c1674b02 /internal/web/handlers_sections.go | |
| parent | 2b450b66fee4906e746f6e04a6d07a72ff2faf64 (diff) | |
| download | blogspace-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.go | 161 |
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) +} |
