aboutsummaryrefslogtreecommitdiffstats
path: root/internal/store
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/blogs.go10
-rw-r--r--internal/store/menu.go128
-rw-r--r--internal/store/modules.go136
-rw-r--r--internal/store/pages.go49
-rw-r--r--internal/store/posts.go27
-rw-r--r--internal/store/sections.go7
6 files changed, 346 insertions, 11 deletions
diff --git a/internal/store/blogs.go b/internal/store/blogs.go
index e6fbc56..b7edee0 100644
--- a/internal/store/blogs.go
+++ b/internal/store/blogs.go
@@ -73,10 +73,16 @@ func createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title
if err != nil {
return nil, err
}
- _, err = tx.Exec(ctx, `INSERT INTO pages (blog_id, slug, title, nav_order, is_home) VALUES ($1,'home','Home',0,true)`, b.ID)
- if err != nil {
+ var homeID int64
+ if err := tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, nav_order, is_home) VALUES ($1,'home','Home',0,true) RETURNING id`, b.ID).Scan(&homeID); err != nil {
return nil, wrap(err)
}
+ if err := addMenuPage(ctx, tx, b.ID, homeID); err != nil {
+ return nil, err
+ }
+ if err := insertDefaultModules(ctx, tx, b.ID); err != nil {
+ return nil, err
+ }
return b, nil
}
diff --git a/internal/store/menu.go b/internal/store/menu.go
new file mode 100644
index 0000000..2d3eabd
--- /dev/null
+++ b/internal/store/menu.go
@@ -0,0 +1,128 @@
+package store
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgconn"
+)
+
+// MenuItem is one entry of a blog's menu: either a page of the blog or a custom link.
+type MenuItem struct {
+ ID int64
+ BlogID int64
+ PageID *int64 // nil for custom links
+ Label string // custom links only; pages use their title
+ URL string // custom links only
+ SortOrder int
+ // joined from pages
+ PageTitle string
+ PageSlug string
+ IsHome bool
+}
+
+// Text is what the visitor reads.
+func (m MenuItem) Text() string {
+ if m.PageID != nil {
+ return m.PageTitle
+ }
+ return m.Label
+}
+
+// Href is where the entry points.
+func (m MenuItem) Href() string {
+ switch {
+ case m.PageID == nil:
+ return m.URL
+ case m.IsHome:
+ return "/"
+ }
+ return "/" + m.PageSlug
+}
+
+const menuCols = `m.id, m.blog_id, m.page_id, m.label, m.url, m.sort_order, coalesce(p.title,''), coalesce(p.slug,''), coalesce(p.is_home,false)`
+
+func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error) {
+ rows, err := s.db.Query(ctx, `SELECT `+menuCols+` FROM menu_items m LEFT JOIN pages p ON p.id=m.page_id
+ WHERE m.blog_id=$1 ORDER BY m.sort_order, m.id`, blogID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []MenuItem
+ for rows.Next() {
+ var m MenuItem
+ if err := rows.Scan(&m.ID, &m.BlogID, &m.PageID, &m.Label, &m.URL, &m.SortOrder, &m.PageTitle, &m.PageSlug, &m.IsHome); err != nil {
+ return nil, err
+ }
+ out = append(out, m)
+ }
+ return out, rows.Err()
+}
+
+// AddMenuPage appends a page to the menu; a page already in the menu is left where it is.
+func (s *Store) AddMenuPage(ctx context.Context, blogID, pageID int64) error {
+ return addMenuPage(ctx, s.db, blogID, pageID)
+}
+
+func addMenuPage(ctx context.Context, db querier, blogID, pageID int64) error {
+ _, err := db.Exec(ctx, `INSERT INTO menu_items (blog_id, page_id, sort_order)
+ SELECT $1, id, (SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1)
+ FROM pages WHERE blog_id=$1 AND id=$2 ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO NOTHING`, blogID, pageID)
+ return err
+}
+
+// SetPageInMenu adds or removes a page's menu entry.
+func (s *Store) SetPageInMenu(ctx context.Context, blogID, pageID int64, on bool) error {
+ if on {
+ return s.AddMenuPage(ctx, blogID, pageID)
+ }
+ _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, blogID, pageID)
+ return err
+}
+
+// AddMenuLink appends a custom link to the menu.
+func (s *Store) AddMenuLink(ctx context.Context, blogID int64, label, url string) error {
+ _, err := s.db.Exec(ctx, `INSERT INTO menu_items (blog_id, label, url, sort_order)
+ VALUES ($1,$2,$3,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1))`, blogID, label, url)
+ return err
+}
+
+func (s *Store) DeleteMenuItem(ctx context.Context, blogID, id int64) error {
+ _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND id=$2`, blogID, id)
+ return err
+}
+
+// MoveMenuItem swaps sort_order with the neighbouring entry (dir = -1 up, +1 down).
+func (s *Store) MoveMenuItem(ctx context.Context, blogID, id int64, dir int) error {
+ items, err := s.ListMenu(ctx, blogID)
+ if err != nil {
+ return err
+ }
+ idx := -1
+ for i := range items {
+ if items[i].ID == id {
+ idx = i
+ }
+ }
+ j := idx + dir
+ if idx < 0 || j < 0 || j >= len(items) {
+ return nil
+ }
+ items[idx], items[j] = items[j], items[idx]
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ for i, m := range items { // renumber everything; keeps orders dense
+ if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil {
+ return err
+ }
+ }
+ return tx.Commit(ctx)
+}
+
+// querier is what the helpers shared with page transactions need: the pool or a pgx.Tx.
+type querier interface {
+ Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
+}
diff --git a/internal/store/modules.go b/internal/store/modules.go
new file mode 100644
index 0000000..c7d9760
--- /dev/null
+++ b/internal/store/modules.go
@@ -0,0 +1,136 @@
+package store
+
+import (
+ "context"
+ "time"
+)
+
+// Module is one building block of a blog's layout: a menu, an archive, a
+// block of custom HTML… placed in one of the areas of the page.
+type Module struct {
+ ID int64
+ BlogID int64
+ Area string // header | left | right | above | below | footer
+ Kind string // title | logo | menu | archive | recent | html | rss | text | sitemap
+ Title string // optional heading (side columns and footer)
+ Body string // custom HTML, or the footer text
+ Count int // recent posts to show
+ SortOrder int
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+const moduleCols = `id, blog_id, area, kind, title, body, count, sort_order, created_at, updated_at`
+
+func scanModule(row interface{ Scan(...any) error }) (*Module, error) {
+ var m Module
+ err := row.Scan(&m.ID, &m.BlogID, &m.Area, &m.Kind, &m.Title, &m.Body, &m.Count, &m.SortOrder, &m.CreatedAt, &m.UpdatedAt)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ return &m, nil
+}
+
+// ListModules returns every module of a blog, grouped by area in display order.
+func (s *Store) ListModules(ctx context.Context, blogID int64) ([]Module, error) {
+ rows, err := s.db.Query(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 ORDER BY area, sort_order, id`, blogID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Module
+ for rows.Next() {
+ m, err := scanModule(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *m)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) ModuleByID(ctx context.Context, blogID, id int64) (*Module, error) {
+ return scanModule(s.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id))
+}
+
+// CreateModule appends a module to the end of its area.
+func (s *Store) CreateModule(ctx context.Context, m *Module) (*Module, error) {
+ return scanModule(s.db.QueryRow(ctx, `INSERT INTO modules (blog_id, area, kind, title, body, count, sort_order)
+ VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM modules WHERE blog_id=$1 AND area=$2)) RETURNING `+moduleCols,
+ m.BlogID, m.Area, m.Kind, m.Title, m.Body, m.Count))
+}
+
+// UpdateModule saves the editable settings; area and kind never change.
+func (s *Store) UpdateModule(ctx context.Context, m *Module) error {
+ _, err := s.db.Exec(ctx, `UPDATE modules SET title=$3, body=$4, count=$5, updated_at=now() WHERE blog_id=$1 AND id=$2`,
+ m.BlogID, m.ID, m.Title, m.Body, m.Count)
+ return wrap(err)
+}
+
+func (s *Store) DeleteModule(ctx context.Context, blogID, id int64) error {
+ _, err := s.db.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id)
+ return err
+}
+
+// MoveModule swaps sort_order with the neighbouring module of the same area (dir = -1 up, +1 down).
+func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error {
+ cur, err := s.ModuleByID(ctx, blogID, id)
+ if err != nil {
+ return err
+ }
+ all, err := s.ListModules(ctx, blogID)
+ if err != nil {
+ return err
+ }
+ var mods []Module
+ idx := -1
+ for _, m := range all {
+ if m.Area != cur.Area {
+ continue
+ }
+ if m.ID == id {
+ idx = len(mods)
+ }
+ mods = append(mods, m)
+ }
+ j := idx + dir
+ if idx < 0 || j < 0 || j >= len(mods) {
+ return nil
+ }
+ mods[idx], mods[j] = mods[j], mods[idx]
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ for i, m := range mods { // renumber the area; keeps orders dense
+ if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil {
+ return err
+ }
+ }
+ return tx.Commit(ctx)
+}
+
+// ResetModules puts the layout back to what a new blog starts with.
+func (s *Store) ResetModules(ctx context.Context, blogID int64) error {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ if _, err := tx.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1`, blogID); err != nil {
+ return err
+ }
+ if err := insertDefaultModules(ctx, tx, blogID); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+// insertDefaultModules is the starting layout: title and menu in the header,
+// an RSS link in the footer. Must match what the layout migration gave old blogs.
+func insertDefaultModules(ctx context.Context, tx querier, blogID int64) error {
+ _, err := tx.Exec(ctx, `INSERT INTO modules (blog_id, area, kind, sort_order) VALUES
+ ($1,'header','title',0), ($1,'header','menu',1), ($1,'footer','rss',0)`, blogID)
+ return err
+}
diff --git a/internal/store/pages.go b/internal/store/pages.go
index 87d440f..cd0e652 100644
--- a/internal/store/pages.go
+++ b/internal/store/pages.go
@@ -13,13 +13,13 @@ type Page struct {
IntroMD string
IntroHTML string
NavOrder int
- ShowInNav bool
+ ShowInNav bool // derived: the page has a menu entry
IsHome bool
CreatedAt time.Time
PostCount int // filled by ListPages only
}
-const pageCols = `id, blog_id, slug, title, intro_md, intro_html, nav_order, show_in_nav, is_home, created_at`
+const pageCols = `id, blog_id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at`
func scanPage(row interface{ Scan(...any) error }) (*Page, error) {
var p Page
@@ -60,16 +60,49 @@ func (s *Store) HomePage(ctx context.Context, blogID int64) (*Page, error) {
return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 ORDER BY is_home DESC, nav_order, id LIMIT 1`, blogID))
}
+// CreatePage adds a page at the end of the page list and, when ShowInNav is
+// set, to the end of the menu.
func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) {
- return scanPage(s.db.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, intro_md, intro_html, nav_order, show_in_nav)
- VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages WHERE blog_id=$1),$6) RETURNING `+pageCols,
- p.BlogID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.ShowInNav))
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+ created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, intro_md, intro_html, nav_order)
+ VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages WHERE blog_id=$1)) RETURNING `+pageCols,
+ p.BlogID, p.Slug, p.Title, p.IntroMD, p.IntroHTML))
+ if err != nil {
+ return nil, err
+ }
+ if p.ShowInNav {
+ if err := addMenuPage(ctx, tx, p.BlogID, created.ID); err != nil {
+ return nil, err
+ }
+ created.ShowInNav = true
+ }
+ return created, tx.Commit(ctx)
}
+// UpdatePage saves the page and keeps its menu entry in step with ShowInNav.
func (s *Store) UpdatePage(ctx context.Context, p *Page) error {
- _, err := s.db.Exec(ctx, `UPDATE pages SET slug=$3, title=$4, intro_md=$5, intro_html=$6, show_in_nav=$7 WHERE blog_id=$1 AND id=$2`,
- p.BlogID, p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.ShowInNav)
- return wrap(err)
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$3, title=$4, intro_md=$5, intro_html=$6 WHERE blog_id=$1 AND id=$2`,
+ p.BlogID, p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil {
+ return wrap(err)
+ }
+ if p.ShowInNav {
+ err = addMenuPage(ctx, tx, p.BlogID, p.ID)
+ } else {
+ _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, p.BlogID, p.ID)
+ }
+ if err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
}
func (s *Store) DeletePage(ctx context.Context, blogID, id int64) error {
diff --git a/internal/store/posts.go b/internal/store/posts.go
index 06bea7f..9f3c4f9 100644
--- a/internal/store/posts.go
+++ b/internal/store/posts.go
@@ -66,6 +66,33 @@ func (s *Store) PublishedPosts(ctx context.Context, pageID int64, limit, offset
return posts, total, err
}
+// PostRef is the light-weight row the archive module lists.
+type PostRef struct {
+ Title string
+ Slug string
+ PageSlug string
+ CreatedAt time.Time
+}
+
+// PublishedPostIndex lists every published post of a blog, newest first, without bodies.
+func (s *Store) PublishedPostIndex(ctx context.Context, blogID int64) ([]PostRef, error) {
+ rows, err := s.db.Query(ctx, `SELECT p.title, p.slug, g.slug, p.created_at FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE g.blog_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC`, blogID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []PostRef
+ for rows.Next() {
+ var r PostRef
+ if err := rows.Scan(&r.Title, &r.Slug, &r.PageSlug, &r.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
// RecentPublishedPosts returns the newest published posts across a whole blog (for feeds).
func (s *Store) RecentPublishedPosts(ctx context.Context, blogID int64, limit int) ([]Post, error) {
return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
diff --git a/internal/store/sections.go b/internal/store/sections.go
index f112102..caac251 100644
--- a/internal/store/sections.go
+++ b/internal/store/sections.go
@@ -2,6 +2,7 @@ package store
import (
"context"
+ "strings"
"time"
)
@@ -12,7 +13,7 @@ type Section struct {
Title string
BodyMD string
BodyHTML string
- Placement string // above | below | sidebar
+ Placement string // <column>-<position>: left|main|right - top|bottom
Style string // plain | note | warning
Enabled bool
SortOrder int
@@ -20,6 +21,10 @@ type Section struct {
UpdatedAt time.Time
}
+// Column and Position are the two halves of Placement.
+func (s Section) Column() string { c, _, _ := strings.Cut(s.Placement, "-"); return c }
+func (s Section) Position() string { _, p, _ := strings.Cut(s.Placement, "-"); return p }
+
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) {