diff options
Diffstat (limited to 'internal/store')
| -rw-r--r-- | internal/store/blogs.go | 10 | ||||
| -rw-r--r-- | internal/store/menu.go | 128 | ||||
| -rw-r--r-- | internal/store/modules.go | 136 | ||||
| -rw-r--r-- | internal/store/pages.go | 49 | ||||
| -rw-r--r-- | internal/store/posts.go | 27 | ||||
| -rw-r--r-- | internal/store/sections.go | 7 |
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) { |
