From d11e4c9544f544b3a024c7ac705a674b4096cf50 Mon Sep 17 00:00:00 2001 From: grm Date: Sun, 13 Sep 2026 00:38:24 +0300 Subject: Add the Layout tab: five areas with modules, columns and a menu editor A blog page is now header, left column, main content, right column and footer, each holding an ordered list of modules (blog title, logo, menu, archive by year/month, recent posts, custom HTML, footer text, RSS link, site map). Side columns have percentage widths, can be hidden, and can keep their space when empty; on phones they stack under the posts. The menu becomes its own ordered list mixing pages and outside links. Announcements are placed per column at its top or bottom. Modules and menu items get tables; the migration derives them from each blog's old theme (nav position, footer text, show-in-nav pages) so existing blogs look the same. Custom HTML is deliberately stored and served as-is (owner's decision; see AGENTS.md for why the blast radius is the blogger's own origin). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/store/blogs.go | 10 +++- internal/store/menu.go | 128 ++++++++++++++++++++++++++++++++++++++++++ internal/store/modules.go | 136 +++++++++++++++++++++++++++++++++++++++++++++ internal/store/pages.go | 49 +++++++++++++--- internal/store/posts.go | 27 +++++++++ internal/store/sections.go | 7 ++- 6 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 internal/store/menu.go create mode 100644 internal/store/modules.go (limited to 'internal/store') 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 // -: 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) { -- cgit v1.2.3