diff options
| author | grm <grm@eyesin.space> | 2026-09-18 11:22:25 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-18 11:22:25 +0300 |
| commit | ac125593efe689fabd1e48a7023249c8973698b8 (patch) | |
| tree | d7ee62adfd2260427383093ddf9956b2ef672598 | |
| parent | ad30b38759dc222a7137598559998acbd0c06740 (diff) | |
| download | blogspace-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.md | 10 | ||||
| -rw-r--r-- | internal/db/migrations/blog/00012_page_show_notices.sql | 7 | ||||
| -rw-r--r-- | internal/i18n/el.go | 21 | ||||
| -rw-r--r-- | internal/store/pages.go | 43 | ||||
| -rw-r--r-- | internal/web/handlers_blog.go | 12 | ||||
| -rw-r--r-- | internal/web/handlers_pages.go | 3 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/design.html | 1 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/page_form.html | 1 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/sections.html | 2 | ||||
| -rw-r--r-- | internal/web/theme.go | 4 | ||||
| -rw-r--r-- | internal/web/web_test.go | 16 |
11 files changed, 85 insertions, 35 deletions
@@ -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"}} ↗</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) { |
