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/db/migrations/00003_layout.sql | 56 ++++ internal/db/migrations/00004_section_placement.sql | 13 + 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 +- internal/web/handlers_blog.go | 51 ++-- internal/web/handlers_design.go | 14 +- internal/web/handlers_layout.go | 307 +++++++++++++++++++++ internal/web/handlers_sections.go | 6 +- internal/web/layout.go | 222 +++++++++++++++ internal/web/routes.go | 13 + internal/web/server.go | 7 +- internal/web/static/blog.css | 50 +++- internal/web/static/dashboard.css | 45 ++- internal/web/templates.go | 11 + internal/web/templates/dashboard/design.html | 54 ++-- internal/web/templates/dashboard/layout.html | 113 ++++++++ internal/web/templates/dashboard/module_form.html | 34 +++ internal/web/templates/dashboard/overview.html | 2 +- internal/web/templates/dashboard/pages.html | 6 +- internal/web/templates/dashboard/section_form.html | 14 +- internal/web/templates/dashboard/sections.html | 2 +- internal/web/templates/layouts/blog.html | 84 ++++-- internal/web/templates/partials/dashnav.html | 1 + internal/web/theme.go | 176 ++++++++---- internal/web/web_test.go | 192 ++++++++++--- 29 files changed, 1605 insertions(+), 225 deletions(-) create mode 100644 internal/db/migrations/00003_layout.sql create mode 100644 internal/db/migrations/00004_section_placement.sql create mode 100644 internal/store/menu.go create mode 100644 internal/store/modules.go create mode 100644 internal/web/handlers_layout.go create mode 100644 internal/web/layout.go create mode 100644 internal/web/templates/dashboard/layout.html create mode 100644 internal/web/templates/dashboard/module_form.html (limited to 'internal') diff --git a/internal/db/migrations/00003_layout.sql b/internal/db/migrations/00003_layout.sql new file mode 100644 index 0000000..e8fff23 --- /dev/null +++ b/internal/db/migrations/00003_layout.sql @@ -0,0 +1,56 @@ +-- +goose Up +-- Layout modules: what each area of a blog (header, columns, footer) shows. +CREATE TABLE modules ( + id bigserial PRIMARY KEY, + blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + area text NOT NULL CHECK (area IN ('header', 'left', 'right', 'above', 'below', 'footer')), + kind text NOT NULL CHECK (kind IN ('title', 'logo', 'menu', 'archive', 'recent', 'html', 'rss', 'text', 'sitemap')), + title text NOT NULL DEFAULT '', + body text NOT NULL DEFAULT '', + count integer NOT NULL DEFAULT 5, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX modules_blog ON modules (blog_id, area, sort_order, id); + +-- The menu: blog pages and custom links in one ordered list. +CREATE TABLE menu_items ( + id bigserial PRIMARY KEY, + blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + page_id bigint REFERENCES pages(id) ON DELETE CASCADE, + label text NOT NULL DEFAULT '', + url text NOT NULL DEFAULT '', + sort_order integer NOT NULL DEFAULT 0, + CHECK (page_id IS NOT NULL OR url <> '') +); +CREATE UNIQUE INDEX menu_items_page ON menu_items (page_id) WHERE page_id IS NOT NULL; +CREATE INDEX menu_items_blog ON menu_items (blog_id, sort_order, id); + +-- Existing blogs keep the menu they had: pages marked "show in menu", in menu +-- order, with the home page only if the theme included it. +INSERT INTO menu_items (blog_id, page_id, sort_order) +SELECT p.blog_id, p.id, row_number() OVER (PARTITION BY p.blog_id ORDER BY p.nav_order, p.id) - 1 +FROM pages p JOIN blogs b ON b.id = p.blog_id +WHERE p.show_in_nav AND (NOT p.is_home OR coalesce((b.theme->>'nav_show_home')::boolean, true)); +ALTER TABLE pages DROP COLUMN show_in_nav; + +-- ...and the layout their theme described. +INSERT INTO modules (blog_id, area, kind, sort_order) +SELECT id, 'header', 'title', CASE WHEN theme->>'nav_position' = 'top-bar' THEN 1 ELSE 0 END +FROM blogs WHERE coalesce((theme->>'header_show_title')::boolean, true); +INSERT INTO modules (blog_id, area, kind, sort_order) +SELECT id, 'header', 'menu', CASE WHEN theme->>'nav_position' = 'top-bar' THEN 0 ELSE 1 END +FROM blogs WHERE coalesce(theme->>'nav_position', 'below-header') <> 'left-sidebar'; +INSERT INTO modules (blog_id, area, kind, sort_order) +SELECT id, 'left', 'menu', 0 FROM blogs WHERE theme->>'nav_position' = 'left-sidebar'; +INSERT INTO modules (blog_id, area, kind, body, sort_order) +SELECT id, 'footer', 'text', theme->>'footer_text', 0 FROM blogs WHERE coalesce(theme->>'footer_text', '') <> ''; +INSERT INTO modules (blog_id, area, kind, sort_order) +SELECT id, 'footer', 'rss', 1 FROM blogs WHERE coalesce((theme->>'footer_show_rss')::boolean, true); + +-- +goose Down +ALTER TABLE pages ADD COLUMN show_in_nav boolean NOT NULL DEFAULT true; +UPDATE pages SET show_in_nav = EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id = pages.id); +DROP TABLE menu_items; +DROP TABLE modules; diff --git a/internal/db/migrations/00004_section_placement.sql b/internal/db/migrations/00004_section_placement.sql new file mode 100644 index 0000000..c36787e --- /dev/null +++ b/internal/db/migrations/00004_section_placement.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Announcements are placed in a column (left, main, right) at its top or bottom. +ALTER TABLE sections DROP CONSTRAINT sections_placement_check; +UPDATE sections SET placement = CASE placement WHEN 'above' THEN 'main-top' WHEN 'below' THEN 'main-bottom' ELSE 'left-top' END; +ALTER TABLE sections ALTER COLUMN placement SET DEFAULT 'main-top'; +ALTER TABLE sections ADD CONSTRAINT sections_placement_check + CHECK (placement IN ('left-top', 'left-bottom', 'main-top', 'main-bottom', 'right-top', 'right-bottom')); + +-- +goose Down +ALTER TABLE sections DROP CONSTRAINT sections_placement_check; +UPDATE sections SET placement = CASE placement WHEN 'main-top' THEN 'above' WHEN 'main-bottom' THEN 'below' ELSE 'sidebar' END; +ALTER TABLE sections ALTER COLUMN placement SET DEFAULT 'above'; +ALTER TABLE sections ADD CONSTRAINT sections_placement_check CHECK (placement IN ('above', 'below', 'sidebar')); 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) { diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index 9b2fe5e..42ec45b 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -12,42 +12,47 @@ import ( const postsPerPage = 10 -// blogView gathers what every public page needs: theme css, nav, announcements, the blog. +// blogView gathers what every public page needs: theme css, the layout with +// its modules and their data, the menu, announcements, the blog. func (s *Server) blogView(r *http.Request) (map[string]any, error) { blog := currentBlog(r) - pages, err := s.st.ListPages(r.Context(), blog.ID) + ctx := r.Context() + pages, err := s.st.ListPages(ctx, blog.ID) if err != nil { return nil, err } - sections, err := s.st.EnabledSections(r.Context(), blog.ID) + menu, err := s.st.ListMenu(ctx, blog.ID) + if err != nil { + return nil, err + } + mods, err := s.st.ListModules(ctx, blog.ID) + if err != nil { + return nil, err + } + sections, err := s.st.EnabledSections(ctx, blog.ID) if err != nil { return nil, err } theme := ParseTheme(blog.ThemeJSON) - var nav []store.Page - for _, p := range pages { - if p.ShowInNav && (theme.NavShowHome || !p.IsHome) { - nav = append(nav, p) + layout := buildLayout(theme, mods) + v := map[string]any{"theme": theme, "css": theme.CSS(), "layout": layout, "menu": menu, "pages": pages} + // Module data is only fetched when a visible module shows it. + if layout.NeedsArchive() { + index, err := s.st.PublishedPostIndex(ctx, blog.ID) + if err != nil { + return nil, err } + v["archive"] = groupArchive(index) } - above, below, sidebar := splitSections(sections, theme.NavPosition == "left-sidebar") - return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav, "above": above, "below": below, "sidebar": sidebar}, nil -} - -// splitSections groups announcements by placement. The sidebar only exists in -// the left-sidebar layout; elsewhere sidebar announcements go above the content. -func splitSections(secs []store.Section, hasSidebar bool) (above, below, sidebar []store.Section) { - for _, sec := range secs { - switch { - case sec.Placement == "below": - below = append(below, sec) - case sec.Placement == "sidebar" && hasSidebar: - sidebar = append(sidebar, sec) - default: - above = append(above, sec) + if n := layout.MaxRecent(); n > 0 { + recent, err := s.st.RecentPublishedPosts(ctx, blog.ID, n) + if err != nil { + return nil, err } + v["recent"] = recent } - return + v["notices"] = placeNotices(sections, layout) + return v, nil } func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go index 3da7ee3..1e18001 100644 --- a/internal/web/handlers_design.go +++ b/internal/web/handlers_design.go @@ -36,7 +36,7 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { } theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form) // Optional direct uploads from the design form. - for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage, "favicon_file": &theme.Favicon} { + for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage, "logo_file": &theme.Logo, "favicon_file": &theme.Favicon} { img, err := s.readUpload(r, field) if err != nil { images, _ := s.st.ListImages(r.Context(), blog.ID) @@ -71,14 +71,19 @@ func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) { redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Applied the "+p.Name+" scheme. Refresh your blog to see it.") } -// handleDesignReset restores the defaults; images stay in the library. +// handleDesignReset restores the defaults, layout modules included; images +// stay in the library and the menu is left alone. func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, DefaultTheme().JSON()); err != nil { s.serverError(w, err) return } - redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design reset to the defaults.") + if err := s.st.ResetModules(r.Context(), blog.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design and layout reset to the defaults.") } // readUpload stores the file from a multipart field, returning nil if the field is empty. @@ -218,6 +223,9 @@ func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) { if theme.Favicon == id.String() { theme.Favicon, changed = "", true } + if theme.Logo == id.String() { + theme.Logo, changed = "", true + } if changed { if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { s.serverError(w, err) diff --git a/internal/web/handlers_layout.go b/internal/web/handlers_layout.go new file mode 100644 index 0000000..b22c1b5 --- /dev/null +++ b/internal/web/handlers_layout.go @@ -0,0 +1,307 @@ +package web + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gramanas/blogspace/internal/store" +) + +// The Layout tab: which areas of the blog are shown, how wide the columns +// are, what modules each area holds, and the menu. + +const ( + maxModuleBytes = 20_000 + maxRecentCount = 20 + maxMenuLabel = 60 +) + +func (s *Server) layoutURL(r *http.Request) string { + return "/b/" + currentBlog(r).Subdomain + "/layout" +} + +// layoutData is everything layout.html needs; it is rebuilt after a failed form too. +func (s *Server) layoutData(r *http.Request) (map[string]any, error) { + blog := currentBlog(r) + mods, err := s.st.ListModules(r.Context(), blog.ID) + if err != nil { + return nil, err + } + menu, err := s.st.ListMenu(r.Context(), blog.ID) + if err != nil { + return nil, err + } + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + return nil, err + } + byArea := map[string][]store.Module{} + for _, m := range mods { + byArea[m.Area] = append(byArea[m.Area], m) + } + var freePages []store.Page // pages not yet in the menu + for _, p := range pages { + if !p.ShowInNav { + freePages = append(freePages, p) + } + } + return map[string]any{"theme": ParseTheme(blog.ThemeJSON), "areas": byArea, "menu": menu, "freePages": freePages, + "kinds": moduleKinds, "names": moduleNames, "areaNames": areaNames, "areaOrder": []string{"above", "below"}}, nil +} + +func (s *Server) handleLayout(w http.ResponseWriter, r *http.Request) { + data, err := s.layoutData(r) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/layout.html", data) +} + +func (s *Server) handleLayoutWidths(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + theme := LayoutFromForm(ParseTheme(blog.ThemeJSON), r.Form) + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#columns", "Columns saved. Refresh your blog to see them.") +} + +// handleAreaToggle shows or hides a whole area, keeping its modules. +func (s *Server) handleAreaToggle(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + area := r.PathValue("area") + if _, ok := areaNames[area]; !ok || singleModuleAreas[area] { + http.NotFound(w, r) + return + } + theme := ParseTheme(blog.ThemeJSON) + on := !theme.AreaOn(area) + theme.SetAreaOn(area, on) + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + msg := areaNames[area] + " hidden." + if on { + msg = areaNames[area] + " shown again. Refresh your blog to see it." + } + redirectOK(w, r, s.layoutURL(r)+"#"+area, msg) +} + +func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + area, kind := r.PathValue("area"), r.FormValue("kind") + if !allowedKind(area, kind) { + s.plainError(w, http.StatusBadRequest, "That module cannot go in that area.") + return + } + if singleModuleAreas[area] { + mods, err := s.st.ListModules(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + for _, m := range mods { + if m.Area == area { + redirectOK(w, r, s.layoutURL(r)+"#main", "There is already a module there; edit or remove it first.") + return + } + } + } + m := &store.Module{BlogID: blog.ID, Area: area, Kind: kind, Count: 5} + switch kind { // sensible headings so the module reads well before it is edited + case "archive": + m.Title = "Archive" + case "recent": + m.Title = "Recent posts" + case "sitemap": + m.Title = "Pages" + } + m, err := s.st.CreateModule(r.Context(), m) + if err != nil { + s.serverError(w, err) + return + } + if kind == "html" || kind == "text" { + redirectOK(w, r, s.layoutURL(r)+"/modules/"+strconv.FormatInt(m.ID, 10)+"/edit", "Added. Now write what it should show.") + return + } + redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(area), moduleNames[kind]+" added. Refresh your blog to see it.") +} + +// anchorFor is the layout.html card an area is edited in. +func anchorFor(area string) string { + if singleModuleAreas[area] { + return "main" + } + return area +} + +func (s *Server) loadModule(w http.ResponseWriter, r *http.Request) *store.Module { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + m, err := s.st.ModuleByID(r.Context(), currentBlog(r).ID, id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + } else { + s.serverError(w, err) + } + return nil + } + return m +} + +func (s *Server) handleModuleForm(w http.ResponseWriter, r *http.Request) { + m := s.loadModule(w, r) + if m == nil { + return + } + if !moduleHasSettings(m.Kind) { + http.Redirect(w, r, s.layoutURL(r), http.StatusSeeOther) + return + } + s.render(w, r, "dashboard/module_form.html", moduleFormData(m, "")) +} + +func moduleFormData(m *store.Module, errMsg string) map[string]any { + d := map[string]any{"module": m, "name": moduleNames[m.Kind], "area": areaNames[m.Area], "counts": []int{3, 5, 8, 10, 15, 20}} + if errMsg != "" { + d["error"] = errMsg + } + return d +} + +func (s *Server) handleModuleSave(w http.ResponseWriter, r *http.Request) { + m := s.loadModule(w, r) + if m == nil { + return + } + m.Title = strings.TrimSpace(r.FormValue("title")) + m.Body = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") + if n, err := strconv.Atoi(r.FormValue("count")); err == nil { + m.Count = clampInt(n, 1, maxRecentCount) + } + fail := func(msg string) { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", moduleFormData(m, msg)) + } + switch { + case len(m.Title) > 120: + fail("Heading is too long (max 120 characters).") + return + case len(m.Body) > maxModuleBytes: + fail("Content is too long (20 KB max).") + return + } + if err := s.st.UpdateModule(r.Context(), m); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), "Saved. Refresh your blog to see it.") +} + +func (s *Server) handleModuleMove(w http.ResponseWriter, r *http.Request) { + m := s.loadModule(w, r) + if m == nil { + return + } + dir := 1 + if r.FormValue("dir") == "up" { + dir = -1 + } + if err := s.st.MoveModule(r.Context(), m.BlogID, m.ID, dir); err != nil { + s.serverError(w, err) + return + } + http.Redirect(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), http.StatusSeeOther) +} + +// handleModuleDeleteConfirm guards modules with content the blogger typed in; +// the list removes the others directly. +func (s *Server) handleModuleDeleteConfirm(w http.ResponseWriter, r *http.Request) { + m := s.loadModule(w, r) + if m == nil { + return + } + if strings.TrimSpace(m.Body) == "" { + s.deleteModule(w, r, m) + return + } + s.render(w, r, "dashboard/confirm.html", map[string]any{"what": "the \"" + moduleNames[m.Kind] + "\" module and its text", "action": r.URL.Path, "back": s.layoutURL(r)}) +} + +func (s *Server) handleModuleDelete(w http.ResponseWriter, r *http.Request) { + if m := s.loadModule(w, r); m != nil { + s.deleteModule(w, r, m) + } +} + +func (s *Server) deleteModule(w http.ResponseWriter, r *http.Request, m *store.Module) { + if err := s.st.DeleteModule(r.Context(), m.BlogID, m.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), moduleNames[m.Kind]+" removed.") +} + +// ---- menu -------------------------------------------------------------------- + +func (s *Server) handleMenuAddPage(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pageID, _ := strconv.ParseInt(r.FormValue("page"), 10, 64) + if pageID == 0 { + redirectOK(w, r, s.layoutURL(r)+"#menu", "Pick a page first.") + return + } + if err := s.st.AddMenuPage(r.Context(), blog.ID, pageID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#menu", "Page added to the menu. Refresh your blog to see it.") +} + +func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + label, link := strings.TrimSpace(r.FormValue("label")), strings.TrimSpace(r.FormValue("url")) + if link != "" && !strings.HasPrefix(link, "/") && !strings.Contains(link, ":") { + link = "https://" + link // "example.org" is what people type + } + switch { + case label == "" || len(label) > maxMenuLabel: + redirectOK(w, r, s.layoutURL(r)+"#menu", "Give the link a short text (max 60 characters).") + return + case !validLinkURL(link): + redirectOK(w, r, s.layoutURL(r)+"#menu", "That address does not look right. Use a full web address like https://example.org or a path like /about.") + return + } + if err := s.st.AddMenuLink(r.Context(), blog.ID, label, link); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#menu", "Link added to the menu. Refresh your blog to see it.") +} + +func (s *Server) handleMenuMove(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + dir := 1 + if r.FormValue("dir") == "up" { + dir = -1 + } + if err := s.st.MoveMenuItem(r.Context(), currentBlog(r).ID, id, dir); err != nil { + s.serverError(w, err) + return + } + http.Redirect(w, r, s.layoutURL(r)+"#menu", http.StatusSeeOther) +} + +func (s *Server) handleMenuDelete(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := s.st.DeleteMenuItem(r.Context(), currentBlog(r).ID, id); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#menu", "Removed from the menu.") +} diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go index 21ea998..f5e2e59 100644 --- a/internal/web/handlers_sections.go +++ b/internal/web/handlers_sections.go @@ -42,7 +42,7 @@ func (s *Server) loadSection(w http.ResponseWriter, r *http.Request) *store.Sect } func (s *Server) handleSectionForm(w http.ResponseWriter, r *http.Request) { - sec := &store.Section{Placement: "above", Style: "note", Enabled: true} + sec := &store.Section{Placement: "main-top", Style: "note", Enabled: true} if r.PathValue("id") != "" { if sec = s.loadSection(w, r); sec == nil { return @@ -53,7 +53,7 @@ func (s *Server) handleSectionForm(w http.ResponseWriter, r *http.Request) { // normalizeSection clamps the enum fields so templates and CSS class names can trust them. func normalizeSection(sec *store.Section) { - sec.Placement = pick(sec.Placement, "above", "below", "sidebar") + sec.Placement = pick(sec.Column(), "main", "left", "right") + "-" + pick(sec.Position(), "top", "bottom") sec.Style = pick(sec.Style, "note", "plain", "warning") } @@ -67,7 +67,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { } sec.Title = strings.TrimSpace(r.FormValue("title")) sec.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") - sec.Placement = r.FormValue("placement") + sec.Placement = r.FormValue("column") + "-" + r.FormValue("position") sec.Style = r.FormValue("style") sec.Enabled = r.FormValue("enabled") == "on" normalizeSection(sec) diff --git a/internal/web/layout.go b/internal/web/layout.go new file mode 100644 index 0000000..521cb63 --- /dev/null +++ b/internal/web/layout.go @@ -0,0 +1,222 @@ +package web + +import ( + "net/url" + "strconv" + "strings" + "time" + + "github.com/gramanas/blogspace/internal/store" +) + +// Modules a blogger can put in each area. Header modules are the big +// blocks; the side columns take the small boxes; the footer its own set. +var moduleKinds = map[string][]string{ + "header": {"title", "logo", "menu"}, + "left": {"menu", "recent", "archive", "logo", "html"}, + "right": {"menu", "recent", "archive", "logo", "html"}, + "above": {"html"}, + "below": {"html"}, + "footer": {"text", "rss", "sitemap", "html"}, +} + +// Friendly names for the dashboard. +var moduleNames = map[string]string{ + "title": "Blog title & tagline", + "logo": "Logo", + "menu": "Menu", + "archive": "Archive (posts by month)", + "recent": "Recent posts", + "html": "Custom HTML", + "rss": "RSS link", + "text": "Footer text", + "sitemap": "Site map", +} + +var areaNames = map[string]string{ + "header": "Header", "left": "Left column", "right": "Right column", + "above": "Before the posts", "below": "After the posts", "footer": "Footer", +} + +// singleModuleAreas hold at most one module (the custom HTML before/after the posts). +var singleModuleAreas = map[string]bool{"above": true, "below": true} + +func allowedKind(area, kind string) bool { + for _, k := range moduleKinds[area] { + if k == kind { + return true + } + } + return false +} + +// moduleHasSettings says whether the module has an edit page. +func moduleHasSettings(kind string) bool { + switch kind { + case "html", "text", "recent", "archive", "sitemap": + return true + } + return false +} + +// moduleSummary is the one-liner the Layout page shows next to a module's name. +func moduleSummary(m store.Module) string { + switch m.Kind { + case "recent": + return strconv.Itoa(m.Count) + " posts" + headingNote(m) + case "html", "text": + body := strings.Join(strings.Fields(m.Body), " ") + if body == "" { + return "empty" + } + if len(body) > 60 { + body = body[:57] + "…" + } + return body + } + return strings.TrimPrefix(headingNote(m), ", ") +} + +func headingNote(m store.Module) string { + if m.Title == "" { + return "" + } + return ", heading “" + m.Title + "”" +} + +// validLinkURL accepts what a menu link may point to: a web address, a mail +// address, or a path on the blog itself. +func validLinkURL(s string) bool { + if s == "" || len(s) > 500 || strings.ContainsAny(s, " \t\n\r\"<>") { + return false + } + if strings.HasPrefix(s, "/") && !strings.HasPrefix(s, "//") { + return true + } + u, err := url.Parse(s) + if err != nil { + return false + } + switch u.Scheme { + case "http", "https": + return u.Host != "" + case "mailto": + return u.Opaque != "" + } + return false +} + +// Layout is the resolved page structure for one render: the modules of +// each area that is switched on, and which side columns are laid out. +type Layout struct { + Header, Left, Right, Above, Below, Footer []store.Module + // A column is laid out when it has something to show — or always, when + // the theme keeps the columns' space. + HasLeft, HasRight bool +} + +// buildLayout groups modules by area, dropping areas the theme has switched off. +func buildLayout(t Theme, mods []store.Module) Layout { + var l Layout + for _, m := range mods { + if !t.AreaOn(m.Area) { + continue + } + switch m.Area { + case "header": + l.Header = append(l.Header, m) + case "left": + l.Left = append(l.Left, m) + case "right": + l.Right = append(l.Right, m) + case "above": + l.Above = append(l.Above, m) + case "below": + l.Below = append(l.Below, m) + case "footer": + l.Footer = append(l.Footer, m) + } + } + l.HasLeft = t.KeepColumns || len(l.Left) > 0 + l.HasRight = t.KeepColumns || len(l.Right) > 0 + return l +} + +func (l Layout) all() []store.Module { + var out []store.Module + for _, a := range [][]store.Module{l.Header, l.Left, l.Right, l.Above, l.Below, l.Footer} { + out = append(out, a...) + } + return out +} + +// NeedsArchive says whether any visible module lists the archive. +func (l Layout) NeedsArchive() bool { + for _, m := range l.all() { + if m.Kind == "archive" { + return true + } + } + return false +} + +// MaxRecent is the longest "recent posts" list any visible module wants (0 = none). +func (l Layout) MaxRecent() int { + n := 0 + for _, m := range l.all() { + if m.Kind == "recent" && m.Count > n { + n = m.Count + } + } + return n +} + +// ---- archive --------------------------------------------------------------- + +type ArchiveMonth struct { + Name string // "September" + Posts []store.PostRef +} + +type ArchiveYear struct { + Year int + Months []ArchiveMonth + Count int +} + +// groupArchive turns the newest-first post index into years → months → posts. +func groupArchive(posts []store.PostRef) []ArchiveYear { + var years []ArchiveYear + var lastY, lastM int + for _, p := range posts { + y, m := p.CreatedAt.Year(), int(p.CreatedAt.Month()) + if len(years) == 0 || y != lastY { + years = append(years, ArchiveYear{Year: y}) + lastM = 0 + } + yr := &years[len(years)-1] + if len(yr.Months) == 0 || m != lastM { + yr.Months = append(yr.Months, ArchiveMonth{Name: time.Month(m).String()}) + } + mo := &yr.Months[len(yr.Months)-1] + mo.Posts = append(mo.Posts, p) + yr.Count++ + lastY, lastM = y, m + } + return years +} + +// placeNotices groups announcements by placement ("left-top", "main-bottom", …). +// Ones meant for a side column that is not laid out go to the main column instead. +func placeNotices(secs []store.Section, l Layout) map[string][]store.Section { + out := map[string][]store.Section{} + for _, sec := range secs { + col := sec.Column() + if (col == "left" && !l.HasLeft) || (col == "right" && !l.HasRight) { + col = "main" + } + key := col + "-" + sec.Position() + out[key] = append(out[key], sec) + } + return out +} diff --git a/internal/web/routes.go b/internal/web/routes.go index 84b19a6..4b3d14f 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -46,6 +46,19 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /b/{sub}/announcements/{id}/delete", s.withBlog(s.handleSectionDelete)) m.HandleFunc("POST /b/{sub}/announcements/{id}/move", s.withBlog(s.handleSectionMove)) m.HandleFunc("POST /b/{sub}/announcements/{id}/toggle", s.withBlog(s.handleSectionToggle)) + m.HandleFunc("GET /b/{sub}/layout", s.withBlog(s.handleLayout)) + m.HandleFunc("POST /b/{sub}/layout/widths", s.withBlog(s.handleLayoutWidths)) + m.HandleFunc("POST /b/{sub}/layout/areas/{area}/toggle", s.withBlog(s.handleAreaToggle)) + m.HandleFunc("POST /b/{sub}/layout/areas/{area}/add", s.withBlog(s.handleModuleAdd)) + m.HandleFunc("GET /b/{sub}/layout/modules/{id}/edit", s.withBlog(s.handleModuleForm)) + m.HandleFunc("POST /b/{sub}/layout/modules/{id}/edit", s.withBlog(s.handleModuleSave)) + m.HandleFunc("POST /b/{sub}/layout/modules/{id}/move", s.withBlog(s.handleModuleMove)) + m.HandleFunc("GET /b/{sub}/layout/modules/{id}/delete", s.withBlog(s.handleModuleDeleteConfirm)) + m.HandleFunc("POST /b/{sub}/layout/modules/{id}/delete", s.withBlog(s.handleModuleDelete)) + m.HandleFunc("POST /b/{sub}/layout/menu/add-page", s.withBlog(s.handleMenuAddPage)) + m.HandleFunc("POST /b/{sub}/layout/menu/add-link", s.withBlog(s.handleMenuAddLink)) + m.HandleFunc("POST /b/{sub}/layout/menu/{id}/move", s.withBlog(s.handleMenuMove)) + m.HandleFunc("POST /b/{sub}/layout/menu/{id}/delete", s.withBlog(s.handleMenuDelete)) m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm)) m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign)) m.HandleFunc("POST /b/{sub}/design/preset", s.withBlog(s.handleDesignPreset)) diff --git a/internal/web/server.go b/internal/web/server.go index cbeac5d..483663e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -254,9 +254,14 @@ func htmlEscape(s string) string { // redirectOK redirects with a flash message shown by the layout. func redirectOK(w http.ResponseWriter, r *http.Request, to, msg string) { + to, frag, _ := strings.Cut(to, "#") // keep a #anchor after the query sep := "?" if strings.Contains(to, "?") { sep = "&" } - http.Redirect(w, r, to+sep+"ok="+urlQuery(msg), http.StatusSeeOther) + to += sep + "ok=" + urlQuery(msg) + if frag != "" { + to += "#" + frag + } + http.Redirect(w, r, to, http.StatusSeeOther) } diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css index 778c169..c785ca6 100644 --- a/internal/web/static/blog.css +++ b/internal/web/static/blog.css @@ -5,6 +5,8 @@ img { max-width: 100%; height: auto; } .site-header { padding: 0; } .header-image img { display: block; width: 100%; max-height: 320px; object-fit: cover; } .header-text { padding: 1.4em 1em; } +.header-logo { padding: 1em; } +.header-logo img.logo { display: inline-block; width: auto; } .site-title { margin: 0; font-size: 2em; line-height: 1.2; } .site-title a { text-decoration: none; } .tagline { margin: 0.3em 0 0; opacity: 0.85; } @@ -12,7 +14,7 @@ img { max-width: 100%; height: auto; } .nav-inner { padding: 0.3em 1em; } .site-nav a { display: inline-block; padding: 0.5em 0.8em; text-decoration: none; font-weight: bold; } .site-nav a.active, .site-nav a:hover { text-decoration: underline; } -.body-wrap { padding: 1.5em 1em; overflow: hidden; } +.body-wrap { padding: 1.5em 1em; } .content { padding: 1.5em 2em; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.15); } .page-title { margin-top: 0; } .intro { padding-bottom: 1em; margin-bottom: 1.5em; border-bottom: 1px solid rgba(0,0,0,0.1); } @@ -43,19 +45,43 @@ img { max-width: 100%; height: auto; } .site-footer { margin-top: 1em; padding: 1.5em 0; text-align: center; } .site-footer p { margin: 0.3em 0; } .site-footer .small { font-size: 0.85em; opacity: 0.8; } +.site-footer .mod { background: none; box-shadow: none; padding: 0; margin: 0 0 0.8em; } +.site-footer .mod-title { font-size: 0.95em; margin: 0 0 0.3em; } +.site-footer ul { list-style: none; margin: 0; padding: 0; } +.site-footer .mod-sitemap li { display: inline-block; margin: 0 0.6em; } +.custom-html { margin: 0 0 1.5em; } +.custom-html-below { margin: 1.5em 0 0; } -/* left sidebar layout: float the menu beside the content */ -.nav-left-sidebar .body-wrap .site-nav { float: left; width: 180px; border: 0; border-radius: 6px; padding: 0.5em 0; } -.nav-left-sidebar .body-wrap .site-nav a { display: block; padding: 0.4em 1em; } -.nav-left-sidebar .body-wrap .site-nav .nav-inner { padding: 0; text-align: left; } /* alignment is a horizontal-menu option */ -.nav-left-sidebar .body-wrap .content { margin-left: 200px; } -.nav-left-sidebar .body-wrap .sidebar-notices { float: left; clear: left; width: 180px; margin-top: 1em; } -.nav-left-sidebar .body-wrap .sidebar-notices .notice { font-size: 0.9em; padding: 0.6em 0.8em; } +/* columns: main first in the HTML so it comes first on phones; on wide screens + flexbox puts the left column before it. Widths come from the theme CSS. + Browsers without flexbox see the columns stacked, which still reads fine. */ +.cols { display: flex; flex-wrap: wrap; align-items: flex-start; } +body.has-left .wrap, body.has-right .wrap { max-width: none; } /* side columns: use the whole page */ +.col { width: 100%; } +.col-left { order: -1; padding-right: 1.5em; } +.col-right { padding-left: 1.5em; } +/* side-column and footer modules */ +.mod { margin: 0 0 1.5em; padding: 1em 1.2em; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.15); font-size: 0.95em; } +.mod > :last-child { margin-bottom: 0; } +.mod-title { margin: 0 0 0.6em; font-size: 1em; text-transform: uppercase; letter-spacing: 0.05em; } +.mod ul { margin: 0; padding-left: 1.2em; } +.mod li { margin: 0.25em 0; } +.mod-logo { text-align: center; } +.mod-logo img.logo { width: auto; } +.col .site-nav { border: 0; padding: 0.5em 0; } +.col .site-nav .mod-title { padding: 0 1em; } +.col .site-nav .nav-inner { padding: 0; } +.col .site-nav a { display: block; padding: 0.4em 1em; } +.mod-archive details { margin: 0.2em 0; } +.mod-archive details details { margin-left: 1em; } +.mod-archive summary { cursor: pointer; padding: 0.15em 0; } +.mod-archive ul { margin: 0.2em 0 0.4em; } +.col .notice { font-size: 0.9em; padding: 0.6em 0.8em; } @media (max-width: 700px) { .content { padding: 1em; } .site-title { font-size: 1.5em; } - .nav-left-sidebar .body-wrap .site-nav { float: none; width: auto; margin-bottom: 1em; } - .nav-left-sidebar .body-wrap .site-nav a { display: inline-block; } - .nav-left-sidebar .body-wrap .content { margin-left: 0; } - .nav-left-sidebar .body-wrap .sidebar-notices { float: none; width: auto; margin: 0 0 1em; } + .cols { display: block; } + .col-left, .col-right { padding: 0; margin-top: 1.5em; } + .col-left { order: 0; } + .col-empty { display: none; } /* a reserved-but-empty column has nothing to stack */ } diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index 4ea0d43..7a66ba5 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -59,7 +59,7 @@ p { margin: 0.6em 0; } label { display: block; margin: 0.8em 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; } label .muted { text-transform: none; letter-spacing: 0; } label code { text-transform: none; letter-spacing: 0; } -input[type=text], input[type=password], input:not([type]), input[type=file], select, textarea { +input[type=text], input[type=password], input[type=number], input:not([type]), input[type=file], select, textarea { width: 100%; max-width: 100%; padding: 0.5em 0.6em; margin-top: 0.25em; border: 2px solid #1d1a17; font: inherit; font-family: -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; text-transform: none; letter-spacing: 0; background: #fffdf8; color: #1d1a17; } input:focus, select:focus, textarea:focus, button:focus, .btn:focus { outline: 2px solid #c8541a; outline-offset: 1px; } input[type=color] { width: 4em; height: 2.3em; padding: 0.1em; margin-top: 0.25em; border: 2px solid #1d1a17; background: #fffdf8; } @@ -145,19 +145,32 @@ details.help[open] summary { margin-bottom: 0.5em; } .presets .preset:hover { background: #ebe5d6; color: #1d1a17; } .presets .sw { display: inline-block; width: 22%; height: 2.2em; margin: 0 1%; border: 1px solid rgba(0,0,0,0.25); vertical-align: middle; } .presets .pname { display: block; margin-top: 0.4em; } -/* menu position tiles + options that only apply to one layout; the show/hide is - pure CSS (:checked ~), and browsers without it simply show everything */ -.navpos { overflow: hidden; } -.navpos > input[type=radio] { position: absolute; opacity: 0; width: 1px; height: 1px; } -.navpos .tile { float: left; width: 8.5em; margin: 0.3em 0.9em 0.9em 0; padding: 0.5em 0.4em 0.4em; border: 2px solid #ddd6c7; background: #fffdf8; text-align: center; cursor: pointer; font-size: 0.75em; } -.navpos .tile svg { display: block; width: 80px; height: 56px; margin: 0 auto 0.4em; border: 1px solid #ddd6c7; } -.navpos .tile span { display: block; } -.navpos .tile:hover { border-color: #1d1a17; } -.navpos > input:checked + .tile { border-color: #c8541a; box-shadow: 3px 3px 0 #c8541a; } -.navpos > input:focus + .tile { outline: 2px solid #c8541a; outline-offset: 1px; } -.navpos .only-horizontal, .navpos .only-sidebar { clear: both; } -#np-side:checked ~ .only-horizontal { display: none; } -#np-below:checked ~ .only-sidebar, #np-top:checked ~ .only-sidebar { display: none; } +/* layout page: the column diagram, area cards with their module tables */ +.lay { margin: 0 0 1em; border: 2px solid #1d1a17; background: #f4efe4; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; text-align: center; } +.lay-row { padding: 0.5em; background: #1d1a17; color: #f4efe4; } +.lay-cols { overflow: hidden; padding: 0.5em 0; } +.lay-col { float: left; padding: 1.6em 0.3em; border: 1px dashed #1d1a17; background: #fffdf8; } +.lay-col.lay-main { background: #ebe5d6; } +.lay .off { opacity: 0.4; text-decoration: line-through; } +.lay-col.off { text-decoration: none; } +.lay-col.empty { background: transparent; } +.row input[type=number] { width: 6em; } +.cardhead { overflow: hidden; } +.cardhead h2 { float: left; margin-right: 1em; } +.cardhead .switch { float: right; margin-top: -0.2em; } +.card.area-off { border-style: dashed; box-shadow: none; } +.card.area-off .modules, .card.area-off .addmod { opacity: 0.6; } +table.modules { margin: 0.5em 0 1em; } +table.modules td.order, table.modules td.actions { width: 1%; } +table.modules th { width: 10em; } +.addmod { border-top: 2px dashed #ddd6c7; padding-top: 0.6em; } +.addmod label, .add label { display: inline-block; margin: 0 0.8em 0.4em 0; min-width: 14em; } +.addmod select, .add select, .add input { width: auto; min-width: 14em; } +.addmod button, .add button { vertical-align: bottom; margin-bottom: 0.4em; } +.addrow { margin-top: 1em; border-top: 2px dashed #ddd6c7; padding-top: 0.6em; } +.addrow .add { float: left; width: 48%; margin-right: 4%; } +.addrow .add + .add { margin-right: 0; } +textarea.mono { font-size: 0.9em; } .hoverpick { margin: 0.8em 0; } .hoverpick > input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; } .hoverpick > label.check { display: inline; margin: 0; } @@ -174,7 +187,9 @@ details.reset { margin-top: 1.6em; } .cols .card, .row label, .thumb { float: none; width: auto; margin-right: 4px; } .imagepick .pick { width: 46%; margin-right: 4%; } .presets .preset { width: 46%; margin-right: 4%; } - .navpos .tile { width: 30%; margin-right: 3%; } + .addrow .add { float: none; width: auto; margin-right: 0; } + .cardhead h2, .cardhead .switch { float: none; margin-top: 0; } + .cardhead .switch { display: block; margin-bottom: 0.6em; } .cols .card + .card { margin-right: 4px; } .topbar .links { float: none; display: block; margin-top: 0.3em; } .topbar .links a, .topbar .links form { margin-left: 0; margin-right: 1em; } diff --git a/internal/web/templates.go b/internal/web/templates.go index 1e17654..e7e35f5 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -98,7 +98,18 @@ var funcs = template.FuncMap{ } return *p }, + "deref64": func(p *int64) int64 { + if p == nil { + return 0 + } + return *p + }, + // split turns a plain-text field into lines: {{range split .Body "\n"}}

{{.}}

{{end}} + "split": strings.Split, "lower": strings.ToLower, + // layout modules: one-line description and whether the kind has an edit page + "modsummary": moduleSummary, + "modsettings": moduleHasSettings, // hasPrefix marks the current dashboard tab: {{if hasPrefix .Path "/b/x/posts"}} "hasPrefix": strings.HasPrefix, // dict builds a map for passing several values to a partial: {{template "x" (dict "a" 1 "b" 2)}} diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html index af482fe..342cabe 100644 --- a/internal/web/templates/dashboard/design.html +++ b/internal/web/templates/dashboard/design.html @@ -5,7 +5,8 @@

Design

Changes are live as soon as you save; refresh your blog ↗ to see them.

-

SchemesBackgroundTextHeaderMenuPostsFooterIcon

+

SchemesBackgroundTextHeaderMenuPostsFooterLogo & icon

+

What appears where — header, columns, footer and their modules — is arranged on the Layout tab.

Colour schemes

@@ -81,12 +82,13 @@ -
+

Width only applies while there are no side columns; with a left or right column the blog uses the whole page.

- +

The title, logo and menu are header modules — add, remove and order them on the Layout tab. The banner below is shown across the top of the header whenever one is chosen.

{{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "images" .Data.images "label" "Header image (banner)")}}