From dd13e4b73a105ee416b6cfd8eb19b217622c0031 Mon Sep 17 00:00:00 2001 From: grm Date: Sat, 12 Sep 2026 12:33:57 +0300 Subject: 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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_blog.go | 25 +++- internal/web/handlers_sections.go | 161 +++++++++++++++++++++ internal/web/routes.go | 9 ++ internal/web/static/blog.css | 11 ++ internal/web/templates/dashboard/overview.html | 2 +- internal/web/templates/dashboard/section_form.html | 32 ++++ internal/web/templates/dashboard/sections.html | 29 ++++ internal/web/templates/layouts/blog.html | 10 +- internal/web/templates/partials/dashnav.html | 1 + internal/web/web_test.go | 21 ++- 10 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 internal/web/handlers_sections.go create mode 100644 internal/web/templates/dashboard/section_form.html create mode 100644 internal/web/templates/dashboard/sections.html (limited to 'internal/web') diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index b768c4d..9b2fe5e 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -12,13 +12,17 @@ import ( const postsPerPage = 10 -// blogView gathers what every public page needs: theme css, nav, the blog. +// blogView gathers what every public page needs: theme css, nav, announcements, the blog. func (s *Server) blogView(r *http.Request) (map[string]any, error) { blog := currentBlog(r) pages, err := s.st.ListPages(r.Context(), blog.ID) if err != nil { return nil, err } + sections, err := s.st.EnabledSections(r.Context(), blog.ID) + if err != nil { + return nil, err + } theme := ParseTheme(blog.ThemeJSON) var nav []store.Page for _, p := range pages { @@ -26,7 +30,24 @@ func (s *Server) blogView(r *http.Request) (map[string]any, error) { nav = append(nav, p) } } - return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav}, nil + above, below, sidebar := splitSections(sections, theme.NavPosition == "left-sidebar") + return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav, "above": above, "below": below, "sidebar": sidebar}, nil +} + +// splitSections groups announcements by placement. The sidebar only exists in +// the left-sidebar layout; elsewhere sidebar announcements go above the content. +func splitSections(secs []store.Section, hasSidebar bool) (above, below, sidebar []store.Section) { + for _, sec := range secs { + switch { + case sec.Placement == "below": + below = append(below, sec) + case sec.Placement == "sidebar" && hasSidebar: + sidebar = append(sidebar, sec) + default: + above = append(above, sec) + } + } + return } func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { 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) +} diff --git a/internal/web/routes.go b/internal/web/routes.go index e135229..bd39a18 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -37,6 +37,15 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostSave)) m.HandleFunc("GET /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDeleteConfirm)) m.HandleFunc("POST /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDelete)) + m.HandleFunc("GET /b/{sub}/announcements", s.withBlog(s.handleSections)) + m.HandleFunc("GET /b/{sub}/announcements/new", s.withBlog(s.handleSectionForm)) + m.HandleFunc("POST /b/{sub}/announcements/new", s.withBlog(s.handleSectionSave)) + m.HandleFunc("GET /b/{sub}/announcements/{id}/edit", s.withBlog(s.handleSectionForm)) + m.HandleFunc("POST /b/{sub}/announcements/{id}/edit", s.withBlog(s.handleSectionSave)) + m.HandleFunc("GET /b/{sub}/announcements/{id}/delete", s.withBlog(s.handleSectionDeleteConfirm)) + m.HandleFunc("POST /b/{sub}/announcements/{id}/delete", s.withBlog(s.handleSectionDelete)) + m.HandleFunc("POST /b/{sub}/announcements/{id}/move", s.withBlog(s.handleSectionMove)) + m.HandleFunc("POST /b/{sub}/announcements/{id}/toggle", s.withBlog(s.handleSectionToggle)) m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm)) m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign)) m.HandleFunc("GET /b/{sub}/images", s.withBlog(s.handleImages)) diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css index b09689b..a3695ae 100644 --- a/internal/web/static/blog.css +++ b/internal/web/static/blog.css @@ -29,6 +29,14 @@ img { max-width: 100%; height: auto; } .post-body table { border-collapse: collapse; } .post-body th, .post-body td { border: 1px solid rgba(0,0,0,0.15); padding: 0.3em 0.6em; } .post-body hr { border: 0; border-top: 1px solid rgba(0,0,0,0.15); } +/* announcements: translucent tints so they sit on any theme colour */ +.notice { margin: 0 0 1.5em; padding: 0.8em 1em; border: 1px solid rgba(0,0,0,0.15); } +.notice-note { background: rgba(255, 200, 60, 0.18); border-left: 5px solid rgba(210, 150, 0, 0.85); } +.notice-warning { background: rgba(220, 60, 40, 0.10); border-left: 5px solid rgba(190, 40, 30, 0.85); } +.notice-title { margin: 0 0 0.3em; font-size: 1em; text-transform: uppercase; letter-spacing: 0.05em; } +.notice-body > :first-child { margin-top: 0; } +.notice-body > :last-child { margin-bottom: 0; } +.content > .notice:last-child { margin: 1.5em 0 0; } .pager { margin-top: 2em; padding-top: 1em; border-top: 1px solid rgba(0,0,0,0.1); text-align: center; } .pager a, .pager span { margin: 0 0.8em; } .muted { opacity: 0.7; } @@ -41,10 +49,13 @@ img { max-width: 100%; height: auto; } .nav-left-sidebar .body-wrap .site-nav a { display: block; padding: 0.4em 1em; } .nav-left-sidebar .body-wrap .site-nav .nav-inner { padding: 0; } .nav-left-sidebar .body-wrap .content { margin-left: 200px; } +.nav-left-sidebar .body-wrap .sidebar-notices { float: left; clear: left; width: 180px; margin-top: 1em; } +.nav-left-sidebar .body-wrap .sidebar-notices .notice { font-size: 0.9em; padding: 0.6em 0.8em; } @media (max-width: 700px) { .content { padding: 1em; } .site-title { font-size: 1.5em; } .nav-left-sidebar .body-wrap .site-nav { float: none; width: auto; margin-bottom: 1em; } .nav-left-sidebar .body-wrap .site-nav a { display: inline-block; } .nav-left-sidebar .body-wrap .content { margin-left: 0; } + .nav-left-sidebar .body-wrap .sidebar-notices { float: none; width: auto; margin: 0 0 1em; } } diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html index 0ec72e6..fbe49f9 100644 --- a/internal/web/templates/dashboard/overview.html +++ b/internal/web/templates/dashboard/overview.html @@ -7,7 +7,7 @@

Pages

diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html new file mode 100644 index 0000000..9d4529c --- /dev/null +++ b/internal/web/templates/dashboard/section_form.html @@ -0,0 +1,32 @@ +{{define "title"}}{{if .Data.section.ID}}Edit announcement{{else}}New announcement{{end}} · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$s := .Data.section}} +
+

{{if $s.ID}}Edit announcement{{else}}New announcement{{end}}

+
+ + +
+ + +
+

The sidebar spot only exists when the menu is a sidebar (see Design); otherwise a sidebar announcement is shown above the posts.

+ + {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} + +

+ + View blog ↗ + Back to announcements +

+
+
+{{end}} diff --git a/internal/web/templates/dashboard/sections.html b/internal/web/templates/dashboard/sections.html new file mode 100644 index 0000000..d2fade7 --- /dev/null +++ b/internal/web/templates/dashboard/sections.html @@ -0,0 +1,29 @@ +{{define "title"}}Announcements · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$u := print "/b/" .Blog.Subdomain "/announcements"}} +
+

Announcements

+ +

Notices shown on every page and post of your blog — the next meeting, this month's book, a holiday closure. Hide one instead of deleting it if you will need it again.

+
+
+{{if .Data.sections}} + + + {{range .Data.sections}} + + + + + + {{end}} +
OrderAnnouncementWhereLook
+
+
+
{{if .Title}}{{.Title}}{{else}}(untitled){{end}}{{if not .Enabled}}hidden{{end}}{{if eq .Placement "above"}}Above the posts{{else if eq .Placement "below"}}Below the posts{{else}}Sidebar{{end}}{{if eq .Style "note"}}Highlighted{{else if eq .Style "warning"}}Warning{{else}}Plain{{end}} +
+ delete +
+{{else}}

No announcements yet. Write the first one.

{{end}} +
+{{end}} diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index 7e3128e..ac6595b 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -19,9 +19,11 @@
{{if eq .Data.theme.NavPosition "below-header"}}{{template "blognav" .}}{{end}}
- {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{end}} + {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{if .Data.sidebar}}{{end}}{{end}}
+ {{template "notices" .Data.above}} {{template "content" .}} + {{template "notices" .Data.below}}