aboutsummaryrefslogtreecommitdiffstats
path: root/internal/store
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/store
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/store')
-rw-r--r--internal/store/sections.go114
1 files changed, 114 insertions, 0 deletions
diff --git a/internal/store/sections.go b/internal/store/sections.go
new file mode 100644
index 0000000..f112102
--- /dev/null
+++ b/internal/store/sections.go
@@ -0,0 +1,114 @@
+package store
+
+import (
+ "context"
+ "time"
+)
+
+// Section is an announcement shown on every page and post of a blog.
+type Section struct {
+ ID int64
+ BlogID int64
+ Title string
+ BodyMD string
+ BodyHTML string
+ Placement string // above | below | sidebar
+ Style string // plain | note | warning
+ Enabled bool
+ SortOrder int
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+const sectionCols = `id, blog_id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at`
+
+func scanSection(row interface{ Scan(...any) error }) (*Section, error) {
+ var s Section
+ err := row.Scan(&s.ID, &s.BlogID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ return &s, nil
+}
+
+func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) {
+ rows, err := s.db.Query(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Section
+ for rows.Next() {
+ sec, err := scanSection(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *sec)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) ListSections(ctx context.Context, blogID int64) ([]Section, error) {
+ return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 ORDER BY sort_order, id`, blogID)
+}
+
+// EnabledSections is what the public blog renders.
+func (s *Store) EnabledSections(ctx context.Context, blogID int64) ([]Section, error) {
+ return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND enabled ORDER BY sort_order, id`, blogID)
+}
+
+func (s *Store) SectionByID(ctx context.Context, blogID, id int64) (*Section, error) {
+ return scanSection(s.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id))
+}
+
+func (s *Store) CreateSection(ctx context.Context, sec *Section) (*Section, error) {
+ return scanSection(s.db.QueryRow(ctx, `INSERT INTO sections (blog_id, title, body_md, body_html, placement, style, enabled, sort_order)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,(SELECT coalesce(max(sort_order),-1)+1 FROM sections WHERE blog_id=$1)) RETURNING `+sectionCols,
+ sec.BlogID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled))
+}
+
+func (s *Store) UpdateSection(ctx context.Context, sec *Section) error {
+ _, err := s.db.Exec(ctx, `UPDATE sections SET title=$3, body_md=$4, body_html=$5, placement=$6, style=$7, enabled=$8, updated_at=now() WHERE blog_id=$1 AND id=$2`,
+ sec.BlogID, sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)
+ return wrap(err)
+}
+
+func (s *Store) SetSectionEnabled(ctx context.Context, blogID, id int64, on bool) error {
+ _, err := s.db.Exec(ctx, `UPDATE sections SET enabled=$3, updated_at=now() WHERE blog_id=$1 AND id=$2`, blogID, id, on)
+ return err
+}
+
+func (s *Store) DeleteSection(ctx context.Context, blogID, id int64) error {
+ _, err := s.db.Exec(ctx, `DELETE FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id)
+ return err
+}
+
+// MoveSection swaps sort_order with the neighbouring section (dir = -1 up, +1 down).
+func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) error {
+ secs, err := s.ListSections(ctx, blogID)
+ if err != nil {
+ return err
+ }
+ idx := -1
+ for i := range secs {
+ if secs[i].ID == id {
+ idx = i
+ }
+ }
+ j := idx + dir
+ if idx < 0 || j < 0 || j >= len(secs) {
+ return nil
+ }
+ secs[idx], secs[j] = secs[j], secs[idx]
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ for i, sec := range secs { // renumber everything; keeps orders dense
+ if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, sec.ID, i); err != nil {
+ return err
+ }
+ }
+ return tx.Commit(ctx)
+}