aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
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
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')
-rw-r--r--internal/web/handlers_blog.go25
-rw-r--r--internal/web/handlers_sections.go161
-rw-r--r--internal/web/routes.go9
-rw-r--r--internal/web/static/blog.css11
-rw-r--r--internal/web/templates/dashboard/overview.html2
-rw-r--r--internal/web/templates/dashboard/section_form.html32
-rw-r--r--internal/web/templates/dashboard/sections.html29
-rw-r--r--internal/web/templates/layouts/blog.html10
-rw-r--r--internal/web/templates/partials/dashnav.html1
-rw-r--r--internal/web/web_test.go21
10 files changed, 295 insertions, 6 deletions
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 @@
<div class="card">
<h2>Quick actions</h2>
<p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">Write a new post</a></p>
- <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> &middot; <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p>
+ <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> &middot; <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p>
</div>
<div class="card">
<h2>Pages</h2>
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}}
+<div class="card">
+ <h1>{{if $s.ID}}Edit announcement{{else}}New announcement{{end}}</h1>
+ <form method="post" enctype="multipart/form-data">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <label>Title <span class="muted">(optional)</span><br><input name="title" value="{{$s.Title}}" maxlength="120" autofocus placeholder="Next meeting"></label>
+ <div class="row">
+ <label>Where<br><select name="placement">
+ <option value="above"{{if eq $s.Placement "above"}} selected{{end}}>Above the posts</option>
+ <option value="below"{{if eq $s.Placement "below"}} selected{{end}}>Below the posts</option>
+ <option value="sidebar"{{if eq $s.Placement "sidebar"}} selected{{end}}>Sidebar, under the menu</option>
+ </select></label>
+ <label>Look<br><select name="style">
+ <option value="note"{{if eq $s.Style "note"}} selected{{end}}>Highlighted</option>
+ <option value="warning"{{if eq $s.Style "warning"}} selected{{end}}>Warning</option>
+ <option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>Plain</option>
+ </select></label>
+ </div>
+ <p class="muted small">The sidebar spot only exists when the menu is a sidebar (see <a href="/b/{{.Blog.Subdomain}}/design">Design</a>); otherwise a sidebar announcement is shown above the posts.</p>
+ <label for="ed-body">Text <span class="muted">(Markdown)</span></label>
+ {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}}
+ <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> Shown on the blog</label>
+ <p class="actionrow">
+ <button type="submit">Save</button>
+ <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog &#8599;</a>
+ <a href="/b/{{.Blog.Subdomain}}/announcements">Back to announcements</a>
+ </p>
+ </form>
+</div>
+{{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"}}
+<div class="pagehead">
+ <h1>Announcements</h1>
+ <div class="actions"><a class="btn" href="{{$u}}/new">+ New announcement</a></div>
+ <p class="lead muted">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.</p>
+</div>
+<div class="card">
+{{if .Data.sections}}
+<table>
+ <tr><th>Order</th><th>Announcement</th><th>Where</th><th>Look</th><th></th></tr>
+ {{range .Data.sections}}<tr{{if not .Enabled}} class="disabled"{{end}}>
+ <td class="nowrap">
+ <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">&#9650;</button></form>
+ <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">&#9660;</button></form>
+ </td>
+ <td><a href="{{$u}}/{{.ID}}/edit">{{if .Title}}{{.Title}}{{else}}(untitled){{end}}</a>{{if not .Enabled}}<span class="tag off">hidden</span>{{end}}</td>
+ <td>{{if eq .Placement "above"}}Above the posts{{else if eq .Placement "below"}}Below the posts{{else}}Sidebar{{end}}</td>
+ <td>{{if eq .Style "note"}}Highlighted{{else if eq .Style "warning"}}Warning{{else}}Plain{{end}}</td>
+ <td class="nowrap">
+ <form method="post" action="{{$u}}/{{.ID}}/toggle" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{if .Enabled}}Hide{{else}}Show{{end}}</button></form>
+ <a class="danger" href="{{$u}}/{{.ID}}/delete">delete</a>
+ </td>
+ </tr>{{end}}
+</table>
+{{else}}<p class="muted">No announcements yet. <a href="{{$u}}/new">Write the first one.</a></p>{{end}}
+</div>
+{{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 @@
</div>
{{if eq .Data.theme.NavPosition "below-header"}}{{template "blognav" .}}{{end}}
<div class="wrap body-wrap">
- {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{end}}
+ {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{if .Data.sidebar}}<div class="sidebar-notices">{{template "notices" .Data.sidebar}}</div>{{end}}{{end}}
<div class="content">
+ {{template "notices" .Data.above}}
{{template "content" .}}
+ {{template "notices" .Data.below}}
</div>
</div>
<div class="site-footer">
@@ -33,6 +35,12 @@
</body>
</html>{{end}}
+{{define "notices"}}{{range .}}<aside class="notice notice-{{.Style}}">
+ {{if .Title}}<h3 class="notice-title">{{.Title}}</h3>{{end}}
+ <div class="notice-body">{{html .BodyHTML}}</div>
+</aside>
+{{end}}{{end}}
+
{{define "blognav"}}<div class="site-nav"><div class="wrap nav-inner">
{{range .Data.nav}}<a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}"{{if and $.Data.page (eq $.Data.page.ID .ID)}} class="active"{{end}}>{{.Title}}</a>
{{end}}</div></div>{{end}}
diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html
index 44e518e..f54cb2d 100644
--- a/internal/web/templates/partials/dashnav.html
+++ b/internal/web/templates/partials/dashnav.html
@@ -20,6 +20,7 @@
<a href="{{$b}}"{{if eq .Path $b}} class="active"{{end}}>Overview</a>
<a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>Posts</a>
<a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>Pages</a>
+ <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>Announcements</a>
<a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>Design</a>
<a href="{{$b}}images"{{if hasPrefix .Path (print $b "images")}} class="active"{{end}}>Images</a>
<a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>Settings</a>
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 6c8072e..002f18a 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -50,7 +50,7 @@ func TestHostRoutingWithoutDB(t *testing.T) {
func TestRootRoutePrecedence(t *testing.T) {
cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20}
s := NewServer(cfg, nil)
- for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/account/password": 303} {
+ for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/account/password": 303} {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", path, nil)
req.Host = "example.com"
@@ -86,7 +86,7 @@ func TestAllTemplatesParse(t *testing.T) {
tpl := newTemplates(false, funcs)
for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html",
"dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html",
- "dashboard/password.html", "dashboard/confirm.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html",
+ "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html",
"blog/page.html", "blog/post.html", "blog/404.html"} {
if _, err := tpl.get(name); err != nil {
t.Errorf("%s: %v", name, err)
@@ -120,3 +120,20 @@ func TestAppendImageMD(t *testing.T) {
t.Errorf("nil image should not change the body: %q", got)
}
}
+
+func TestSplitSections(t *testing.T) {
+ secs := []store.Section{{ID: 1, Placement: "above"}, {ID: 2, Placement: "below"}, {ID: 3, Placement: "sidebar"}}
+ above, below, sidebar := splitSections(secs, true)
+ if len(above) != 1 || len(below) != 1 || len(sidebar) != 1 || sidebar[0].ID != 3 {
+ t.Errorf("with sidebar: %v %v %v", above, below, sidebar)
+ }
+ above, _, sidebar = splitSections(secs, false)
+ if len(above) != 2 || len(sidebar) != 0 || above[1].ID != 3 { // sidebar falls back to above, keeping order
+ t.Errorf("without sidebar: %v %v", above, sidebar)
+ }
+ sec := &store.Section{Placement: "footer", Style: "<script>"}
+ normalizeSection(sec)
+ if sec.Placement != "above" || sec.Style != "note" {
+ t.Errorf("normalize: %+v", sec)
+ }
+}