aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-18 11:22:25 +0300
committergrm <grm@eyesin.space>2026-09-18 11:22:25 +0300
commitac125593efe689fabd1e48a7023249c8973698b8 (patch)
treed7ee62adfd2260427383093ddf9956b2ef672598
parentad30b38759dc222a7137598559998acbd0c06740 (diff)
downloadblogspace-ac125593efe689fabd1e48a7023249c8973698b8.tar.gz
blogspace-ac125593efe689fabd1e48a7023249c8973698b8.tar.bz2
blogspace-ac125593efe689fabd1e48a7023249c8973698b8.zip
Add switches to keep announcements off a page or off post pages
Announcements showed on every public page. Now each page has a "Show announcements on this page" checkbox (pages.show_notices, on by default) that covers the page and its posts, and the Design tab's Content section has "Show announcements on post pages" (theme.post_notices, on by default) for bloggers who want notices only on listings, not while reading a post. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
-rw-r--r--AGENTS.md10
-rw-r--r--internal/db/migrations/blog/00012_page_show_notices.sql7
-rw-r--r--internal/i18n/el.go21
-rw-r--r--internal/store/pages.go43
-rw-r--r--internal/web/handlers_blog.go12
-rw-r--r--internal/web/handlers_pages.go3
-rw-r--r--internal/web/templates/dashboard/design.html1
-rw-r--r--internal/web/templates/dashboard/page_form.html1
-rw-r--r--internal/web/templates/dashboard/sections.html2
-rw-r--r--internal/web/theme.go4
-rw-r--r--internal/web/web_test.go16
11 files changed, 85 insertions, 35 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 5ac922d..2953025 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -363,7 +363,13 @@ internal/web/ server.go (host router, middleware, render helpers)
constraint, so a new style needs a migration), `enabled`
and `sort_order`. `blogView` loads the enabled ones and `placeNotices`
groups them by placement, moving ones for a side column that is not laid
- out into the main column.
+ out into the main column. Two switches hide them: `pages.show_notices`
+ (`Page.ShowNotices`, the page form's "Show announcements on this page",
+ default on) covers the page and its posts, and `theme.post_notices`
+ (`PostNotices`, Design → Content, default on) covers every post page —
+ `renderPage`/`handleBlogPost` put `noNotices` (a typed empty map, so the
+ layout's `index` lookups still work) in `notices` instead. Tag, search
+ and 404 pages always show them.
Rendered by the `notices` template in `layouts/blog.html` inside a
`.notices` wrapper; CSS classes `notice notice-<style>` use translucent
colours so they fit any theme, except `gizmo`: a box framed in the theme's
@@ -478,7 +484,7 @@ superadmin password to `admin`. Production refuses both.
`-- +goose Up/Down` sections; they run automatically at startup. Never
edit an applied migration. Blog chain so far: `00001_init`, `00002_language`,
`00003_files`, `00004_tags`, `00005_search`, `00006_format`, `00007_post_image`,
- `00008_page_outro`, `00009_gizmo_style`, `00010_font_kind`, `00011_post_hide_date`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after
+ `00008_page_outro`, `00009_gizmo_style`, `00010_font_kind`, `00011_post_hide_date`, `00012_page_show_notices`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after
the move to per-blog databases; deployments from before it have
`goose_db_version` rows 2–7 in the control DB that must be deleted once
(README "Upgrading from a single database") or the next control migration
diff --git a/internal/db/migrations/blog/00012_page_show_notices.sql b/internal/db/migrations/blog/00012_page_show_notices.sql
new file mode 100644
index 0000000..da4ff3d
--- /dev/null
+++ b/internal/db/migrations/blog/00012_page_show_notices.sql
@@ -0,0 +1,7 @@
+-- +goose Up
+-- A per-page switch for the announcements: an "About" page can do without
+-- the notices the home page carries.
+ALTER TABLE pages ADD COLUMN show_notices boolean NOT NULL DEFAULT true;
+
+-- +goose Down
+ALTER TABLE pages DROP COLUMN show_notices;
diff --git a/internal/i18n/el.go b/internal/i18n/el.go
index f1be9d4..f52b529 100644
--- a/internal/i18n/el.go
+++ b/internal/i18n/el.go
@@ -109,6 +109,7 @@ var el = map[string]string{
"Image not added:": "Η εικόνα δεν προστέθηκε:",
"shown above the posts; optional": "εμφανίζεται πάνω από τις δημοσιεύσεις· προαιρετικό",
"Show in menu": "Εμφάνιση στο μενού",
+ "Show announcements on this page": "Εμφάνιση ανακοινώσεων σε αυτή τη σελίδα",
"Back to pages": "Πίσω στις σελίδες",
"Page deleted.": "Η σελίδα διαγράφηκε.",
"“%s” is now the home page.": "Η «%s» είναι τώρα η αρχική σελίδα.",
@@ -128,7 +129,7 @@ var el = map[string]string{
"New announcement": "Νέα ανακοίνωση",
"Edit announcement": "Επεξεργασία ανακοίνωσης",
"Announcement": "Ανακοίνωση",
- "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.": "Ειδοποιήσεις που εμφανίζονται σε κάθε σελίδα και δημοσίευση του ιστολογίου — η επόμενη συνάντηση, το βιβλίο του μήνα, οι διακοπές. Αν θα την ξαναχρειαστείτε, κρύψτε την αντί να τη διαγράψετε.",
+ "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. A page can opt out on its form, and the Design tab can keep them off post pages.": "Ειδοποιήσεις που εμφανίζονται σε κάθε σελίδα και δημοσίευση του ιστολογίου — η επόμενη συνάντηση, το βιβλίο του μήνα, οι διακοπές. Αν θα την ξαναχρειαστείτε, κρύψτε την αντί να τη διαγράψετε. Μια σελίδα μπορεί να τις εξαιρέσει από τη φόρμα της, και η καρτέλα Σχεδίαση μπορεί να τις κρατήσει εκτός των σελίδων δημοσιεύσεων.",
"Where": "Πού",
"Look": "Εμφάνιση",
"Position": "Θέση",
@@ -393,14 +394,16 @@ var el = map[string]string{
"Show tags with a # in front": "Εμφάνιση των ετικετών με # μπροστά",
"Rule under titles": "Γραμμή κάτω από τους τίτλους",
"The rule goes under page and post titles, in the link colour.": "Η γραμμή μπαίνει κάτω από τους τίτλους σελίδων και δημοσιεύσεων, στο χρώμα των συνδέσμων.",
- "Double dots": "Διπλές τελείες",
- "Date format": "Μορφή ημερομηνίας",
- "Logo & site icon": "Λογότυπο & εικονίδιο ιστολογίου",
- "Logo size": "Μέγεθος λογότυπου",
- "Small (48 px tall)": "Μικρό (48 px ύψος)",
- "Medium (80 px)": "Μεσαίο (80 px)",
- "Large (140 px)": "Μεγάλο (140 px)",
- "Favicon": "Εικονίδιο (favicon)",
+ "Show announcements on post pages": "Εμφάνιση ανακοινώσεων στις σελίδες δημοσιεύσεων",
+ "each page has its own switch": "κάθε σελίδα έχει τον δικό της διακόπτη",
+ "Double dots": "Διπλές τελείες",
+ "Date format": "Μορφή ημερομηνίας",
+ "Logo & site icon": "Λογότυπο & εικονίδιο ιστολογίου",
+ "Logo size": "Μέγεθος λογότυπου",
+ "Small (48 px tall)": "Μικρό (48 px ύψος)",
+ "Medium (80 px)": "Μεσαίο (80 px)",
+ "Large (140 px)": "Μεγάλο (140 px)",
+ "Favicon": "Εικονίδιο (favicon)",
"The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon.": "Το μικρό εικονίδιο που δείχνουν οι φυλλομετρητές στην καρτέλα. Ένα τετράγωνο PNG ή ICO 32–64 px είναι ιδανικό· επιλέξτε Κανένα για το εικονίδιο του Blogspace.",
"Reset to defaults": "Επαναφορά προεπιλογών",
"Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images and fonts (they stay in your library).": "Επαναφέρει κάθε χρώμα, γραμματοσειρά και επιλογή διάταξης όπως ξεκινά ένα νέο ιστολόγιο και ξεχνά τις επιλεγμένες εικόνες και γραμματοσειρές (μένουν στη συλλογή σας).",
diff --git a/internal/store/pages.go b/internal/store/pages.go
index 08af9fe..3fdc492 100644
--- a/internal/store/pages.go
+++ b/internal/store/pages.go
@@ -6,26 +6,27 @@ import (
)
type Page struct {
- ID int64
- Slug string
- Title string
- IntroMD string
- IntroHTML string
- OutroMD string // shown after the posts
- OutroHTML string
- Format string // FormatMarkdown | FormatHTML: how IntroMD and OutroMD are read
- NavOrder int
- ShowInNav bool // derived: the page has a menu entry
- IsHome bool
- CreatedAt time.Time
- PostCount int // filled by ListPages only
+ ID int64
+ Slug string
+ Title string
+ IntroMD string
+ IntroHTML string
+ OutroMD string // shown after the posts
+ OutroHTML string
+ Format string // FormatMarkdown | FormatHTML: how IntroMD and OutroMD are read
+ NavOrder int
+ ShowInNav bool // derived: the page has a menu entry
+ ShowNotices bool // announcements are shown on this page (and its posts)
+ IsHome bool
+ CreatedAt time.Time
+ PostCount int // filled by ListPages only
}
-const pageCols = `id, slug, title, intro_md, intro_html, outro_md, outro_html, format, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at`
+const pageCols = `id, slug, title, intro_md, intro_html, outro_md, outro_html, format, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), show_notices, is_home, created_at`
func scanPage(row interface{ Scan(...any) error }) (*Page, error) {
var p Page
- err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt)
+ err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.ShowNotices, &p.IsHome, &p.CreatedAt)
if err != nil {
return nil, wrap(err)
}
@@ -42,7 +43,7 @@ func (bs *BlogStore) ListPages(ctx context.Context) ([]Page, error) {
var out []Page
for rows.Next() {
var p Page
- if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
+ if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.ShowNotices, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
return nil, err
}
out = append(out, p)
@@ -70,9 +71,9 @@ func (bs *BlogStore) CreatePage(ctx context.Context, p *Page) (*Page, error) {
return nil, err
}
defer tx.Rollback(ctx)
- created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, outro_md, outro_html, format, nav_order)
- VALUES ($1,$2,$3,$4,$5,$6,$7,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols,
- p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.OutroMD, p.OutroHTML, formatOrMD(p.Format)))
+ created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, outro_md, outro_html, format, show_notices, nav_order)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols,
+ p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.OutroMD, p.OutroHTML, formatOrMD(p.Format), p.ShowNotices))
if err != nil {
return nil, err
}
@@ -92,8 +93,8 @@ func (bs *BlogStore) UpdatePage(ctx context.Context, p *Page) error {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5, outro_md=$6, outro_html=$7, format=$8 WHERE id=$1`,
- p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.OutroMD, p.OutroHTML, formatOrMD(p.Format)); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5, outro_md=$6, outro_html=$7, format=$8, show_notices=$9 WHERE id=$1`,
+ p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.OutroMD, p.OutroHTML, formatOrMD(p.Format), p.ShowNotices); err != nil {
return wrap(err)
}
if p.ShowInNav {
diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go
index c26adfa..c1afedb 100644
--- a/internal/web/handlers_blog.go
+++ b/internal/web/handlers_blog.go
@@ -63,6 +63,10 @@ func (s *Server) blogView(r *http.Request) (map[string]any, error) {
return v, nil
}
+// noNotices stands in for the placed announcements on pages that hide them
+// (typed, so the layout's index lookups still work).
+var noNotices = map[string][]store.Section{}
+
// pageNum reads the ?p= of a paginated listing (1 when absent or silly).
func pageNum(r *http.Request) int {
n, _ := strconv.Atoi(r.URL.Query().Get("p"))
@@ -109,6 +113,9 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store.
last := (total + per - 1) / per
fillExcerpts(v["theme"].(Theme), posts)
v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last
+ if !page.ShowNotices {
+ v["notices"] = noNotices
+ }
// The pager appends ?p=N to base: no trailing slash, which /{page} would not match.
v["base"] = "/" + page.Slug
if page.IsHome {
@@ -195,6 +202,11 @@ func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) {
return
}
v["page"], v["post"] = page, post
+ // A post shows the announcements only when its page does and the theme
+ // wants them on posts at all.
+ if !page.ShowNotices || !v["theme"].(Theme).PostNotices {
+ v["notices"] = noNotices
+ }
s.render(w, r, "blog/post.html", v)
}
diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go
index 8dc0f5f..b085b82 100644
--- a/internal/web/handlers_pages.go
+++ b/internal/web/handlers_pages.go
@@ -39,7 +39,7 @@ func (s *Server) loadPage(w http.ResponseWriter, r *http.Request) *store.Page {
}
func (s *Server) handlePageForm(w http.ResponseWriter, r *http.Request) {
- p := &store.Page{ShowInNav: true}
+ p := &store.Page{ShowInNav: true, ShowNotices: true}
if r.PathValue("id") != "" {
if p = s.loadPage(w, r); p == nil {
return
@@ -62,6 +62,7 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
p.OutroMD = strings.ReplaceAll(r.FormValue("outro"), "\r\n", "\n")
p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML)
p.ShowInNav = r.FormValue("show_in_nav") == "on"
+ p.ShowNotices = r.FormValue("show_notices") == "on"
autoSlug := p.Slug == ""
if autoSlug {
p.Slug = slug.Make(p.Title)
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html
index fea1b92..476e311 100644
--- a/internal/web/templates/dashboard/design.html
+++ b/internal/web/templates/dashboard/design.html
@@ -188,6 +188,7 @@
<h3>{{t "Posts"}}</h3>
<label class="check"><input type="checkbox" name="show_dates"{{if $t.ShowDates}} checked{{end}}> {{t "Show the date on posts"}}</label>
<label class="check"><input type="checkbox" name="tag_hash"{{if $t.TagHash}} checked{{end}}> {{t "Show tags with a # in front"}} <span class="muted">(#garden)</span></label>
+ <label class="check"><input type="checkbox" name="post_notices"{{if $t.PostNotices}} checked{{end}}> {{t "Show announcements on post pages"}} <span class="muted">({{t "each page has its own switch"}})</span></label>
<div class="row">
<label>{{t "Rule under titles"}}<br><select name="title_rule">
<option value="none"{{if eq $t.TitleRule "none"}} selected{{end}}>{{t "None"}}</option>
diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html
index 18b0a86..4ee720b 100644
--- a/internal/web/templates/dashboard/page_form.html
+++ b/internal/web/templates/dashboard/page_form.html
@@ -12,6 +12,7 @@
<label for="ed-outro">{{t "Closing text"}} <span class="muted">({{t "shown after the posts, in the same format; optional"}})</span></label>
{{template "editor" (dict "name" "outro" "value" .Data.page.OutroMD "format" .Data.page.Format "formatof" "intro" "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
<label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> {{t "Show in menu"}}</label>
+ <label class="check"><input type="checkbox" name="show_notices"{{if .Data.page.ShowNotices}} checked{{end}}> {{t "Show announcements on this page"}}</label>
<p class="actionrow">
<button type="submit">{{t "Save"}}</button>
{{if .Data.page.ID}}<a class="btn secondary" href="{{.BlogURL}}{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}" target="_blank">{{t "View page"}} &#8599;</a>{{end}}
diff --git a/internal/web/templates/dashboard/sections.html b/internal/web/templates/dashboard/sections.html
index 9f0e314..2767921 100644
--- a/internal/web/templates/dashboard/sections.html
+++ b/internal/web/templates/dashboard/sections.html
@@ -4,7 +4,7 @@
<div class="pagehead">
<h1>{{t "Announcements"}}</h1>
<div class="actions"><a class="btn" href="{{$u}}/new">+ {{t "New announcement"}}</a></div>
- <p class="lead muted">{{t "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>
+ <p class="lead muted">{{t "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. A page can opt out on its form, and the Design tab can keep them off post pages."}}</p>
</div>
<div class="card">
{{if .Data.sections}}
diff --git a/internal/web/theme.go b/internal/web/theme.go
index 676a01e..0437f51 100644
--- a/internal/web/theme.go
+++ b/internal/web/theme.go
@@ -55,6 +55,7 @@ type Theme struct {
PostImage string `json:"post_image"` // where the post page shows the featured image: top | bottom | list (listings only)
TitleRule string `json:"title_rule"` // none | line | dots | dots2 | stripes, drawn under page and post titles in the link colour
TagHash bool `json:"tag_hash"` // tags are listed as #name
+ PostNotices bool `json:"post_notices"` // announcements are shown on post pages too, not only on listings
FooterBg string `json:"footer_bg"`
FooterColor string `json:"footer_color"`
@@ -108,7 +109,7 @@ func DefaultTheme() Theme {
Font: "sans", HeadingFont: "same", FontSize: "normal",
HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", TitleSize: "normal", HeaderRule: "none",
NavAlign: "left", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222",
- ShowDates: true, DateFormat: "long", PostsPerPage: postsPerPageChoices[0], ListStyle: "full", PostImage: "top", TitleRule: "none",
+ ShowDates: true, DateFormat: "long", PostsPerPage: postsPerPageChoices[0], ListStyle: "full", PostImage: "top", TitleRule: "none", PostNotices: true,
FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterAlign: "center",
LogoSize: "medium",
HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, PageAlign: "center",
@@ -269,6 +270,7 @@ func ThemeFromForm(cur Theme, f url.Values) Theme {
t.PostImage = get("post_image")
t.TitleRule = get("title_rule")
t.TagHash = on("tag_hash")
+ t.PostNotices = on("post_notices")
t.FooterBg = get("footer_bg")
t.FooterColor = get("footer_color")
t.FooterAlign = get("footer_align")
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index a7c189f..34ec057 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -138,6 +138,22 @@ func TestThemeNormalizeAndCSS(t *testing.T) {
}
}
+// Announcements on post pages are on by default (the look blogs had before
+// the switch) and, like every checkbox, off when the box is unticked; stored
+// themes from before the option get the default too.
+func TestPostNotices(t *testing.T) {
+ if !DefaultTheme().PostNotices || !ParseTheme([]byte(`{"show_dates":false}`)).PostNotices {
+ t.Error("post notices should default to on")
+ }
+ th := ThemeFromForm(DefaultTheme(), url.Values{"post_notices": {"on"}})
+ if !th.PostNotices {
+ t.Errorf("ticked: %+v", th)
+ }
+ if th = ThemeFromForm(th, url.Values{"show_dates": {"on"}}); th.PostNotices {
+ t.Errorf("unticked: %+v", th)
+ }
+}
+
// The rule under titles is a body class blog.css draws from, coloured by the
// theme CSS; the tag hash is a plain flag the templates read.
func TestTitleRuleAndTagHash(t *testing.T) {