diff options
| author | grm <grm@eyesin.space> | 2026-09-13 00:38:24 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-13 00:38:24 +0300 |
| commit | d11e4c9544f544b3a024c7ac705a674b4096cf50 (patch) | |
| tree | a60be98a37b764dfcb655b2738c8a991a248103b /internal | |
| parent | e974eb6f3feb06c8951f85c1d34161938905f807 (diff) | |
| download | blogspace-d11e4c9544f544b3a024c7ac705a674b4096cf50.tar.gz blogspace-d11e4c9544f544b3a024c7ac705a674b4096cf50.tar.bz2 blogspace-d11e4c9544f544b3a024c7ac705a674b4096cf50.zip | |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal')
29 files changed, 1605 insertions, 225 deletions
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 // <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) { 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"}}<p>{{.}}</p>{{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 @@ <h1>Design</h1> <p class="lead muted">Changes are live as soon as you save; refresh <a href="{{.BlogURL}}" target="_blank">your blog ↗</a> to see them.</p> </div> -<p class="jumps"><a href="#schemes">Schemes</a><a href="#background">Background</a><a href="#text">Text</a><a href="#header">Header</a><a href="#menu">Menu</a><a href="#posts">Posts</a><a href="#footer">Footer</a><a href="#icon">Icon</a></p> +<p class="jumps"><a href="#schemes">Schemes</a><a href="#background">Background</a><a href="#text">Text</a><a href="#header">Header</a><a href="#menu">Menu</a><a href="#posts">Posts</a><a href="#footer">Footer</a><a href="#logo">Logo & icon</a></p> +<p class="muted small">What appears where — header, columns, footer and their modules — is arranged on the <a href="/b/{{.Blog.Subdomain}}/layout">Layout</a> tab.</p> <div class="card" id="schemes"> <h2>Colour schemes</h2> @@ -81,12 +82,13 @@ <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>Card (shadow, rounded corners)</option> <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>Flat</option> </select></label> - <label>Padding<br><select name="content_padding"> + <label>Padding <span class="muted">(inside the box)</span><br><select name="content_padding"> <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>Compact</option> <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>Normal</option> <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>Roomy</option> </select></label> </div> + <p class="muted small">Width only applies while there are no side columns; with a left or right column the blog uses the whole page.</p> </div> <div class="card" id="header"> @@ -104,43 +106,24 @@ <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>Tall</option> </select></label> </div> - <label class="check"><input type="checkbox" name="header_show_title"{{if $t.HeaderShowTitle}} checked{{end}}> Show blog title and tagline in the header</label> + <p class="muted small">The title, logo and menu are header modules — add, remove and order them on the <a href="/b/{{.Blog.Subdomain}}/layout#header">Layout</a> tab. The banner below is shown across the top of the header whenever one is chosen.</p> {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "images" .Data.images "label" "Header image (banner)")}} </div> <div class="card" id="menu"> <h2>Menu</h2> - <p class="muted small">The menu lists the pages you marked "show in menu". Pick where it goes; the options underneath follow your choice.</p> - <div class="navpos"> - <input type="radio" name="nav_position" value="below-header" id="np-below"{{if eq $t.NavPosition "below-header"}} checked{{end}}> - <label for="np-below" class="tile"><svg viewBox="0 0 80 56" width="80" height="56" aria-hidden="true"><rect x="0" y="0" width="80" height="56" fill="#f4efe4"/><rect x="0" y="0" width="80" height="14" fill="#1d1a17"/><rect x="0" y="16" width="80" height="6" fill="#c8541a"/><rect x="10" y="28" width="60" height="24" fill="#fffdf8" stroke="#1d1a17"/></svg><span>Below the header</span></label> - <input type="radio" name="nav_position" value="top-bar" id="np-top"{{if eq $t.NavPosition "top-bar"}} checked{{end}}> - <label for="np-top" class="tile"><svg viewBox="0 0 80 56" width="80" height="56" aria-hidden="true"><rect x="0" y="0" width="80" height="56" fill="#f4efe4"/><rect x="0" y="0" width="80" height="6" fill="#c8541a"/><rect x="0" y="8" width="80" height="14" fill="#1d1a17"/><rect x="10" y="28" width="60" height="24" fill="#fffdf8" stroke="#1d1a17"/></svg><span>Bar at the very top</span></label> - <input type="radio" name="nav_position" value="left-sidebar" id="np-side"{{if eq $t.NavPosition "left-sidebar"}} checked{{end}}> - <label for="np-side" class="tile"><svg viewBox="0 0 80 56" width="80" height="56" aria-hidden="true"><rect x="0" y="0" width="80" height="56" fill="#f4efe4"/><rect x="0" y="0" width="80" height="14" fill="#1d1a17"/><rect x="8" y="20" width="18" height="32" fill="#c8541a"/><rect x="30" y="20" width="42" height="32" fill="#fffdf8" stroke="#1d1a17"/></svg><span>Sidebar on the left</span></label> - <div class="row only-horizontal"> - <label>Alignment<br><select name="nav_align"> - <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>Left</option> - <option value="center"{{if eq $t.NavAlign "center"}} selected{{end}}>Centred</option> - <option value="right"{{if eq $t.NavAlign "right"}} selected{{end}}>Right</option> - </select></label> - </div> - <div class="row only-sidebar"> - <label>Sidebar width<br><select name="nav_sidebar_width"> - <option value="narrow"{{if eq $t.NavSidebarWidth "narrow"}} selected{{end}}>Narrow</option> - <option value="normal"{{if eq $t.NavSidebarWidth "normal"}} selected{{end}}>Normal</option> - <option value="wide"{{if eq $t.NavSidebarWidth "wide"}} selected{{end}}>Wide</option> - </select></label> - </div> - <p class="muted small only-sidebar">Links stack under each other; announcements placed "in the sidebar" appear beneath them. On phones the sidebar turns into a row of links above the content.</p> - </div> + <p class="muted small">Which links the menu shows, and where it sits, is set on the <a href="/b/{{.Blog.Subdomain}}/layout#menu">Layout</a> tab. Here you choose how it looks.</p> <div class="row"> + <label>Alignment <span class="muted">(menu in the header)</span><br><select name="nav_align"> + <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>Left</option> + <option value="center"{{if eq $t.NavAlign "center"}} selected{{end}}>Centred</option> + <option value="right"{{if eq $t.NavAlign "right"}} selected{{end}}>Right</option> + </select></label> <label>Link style<br><select name="nav_style"> <option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>Normal</option> <option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>Uppercase, spaced</option> </select></label> </div> - <label class="check"><input type="checkbox" name="nav_show_home"{{if $t.NavShowHome}} checked{{end}}> Include the home page in the menu</label> <h3>Menu colours</h3> <div class="row"> <label>Background<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label> @@ -167,16 +150,23 @@ <div class="card" id="footer"> <h2>Footer</h2> - <label>Footer text<br><input name="footer_text" value="{{$t.FooterText}}" maxlength="2000" placeholder="© 2026 Me"></label> + <p class="muted small">The footer text, RSS link and other footer modules live on the <a href="/b/{{.Blog.Subdomain}}/layout#footer">Layout</a> tab.</p> <div class="row"> <label>Background<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label> <label>Text colour<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label> </div> - <label class="check"><input type="checkbox" name="footer_show_rss"{{if $t.FooterShowRSS}} checked{{end}}> Show the RSS link in the footer</label> </div> -<div class="card" id="icon"> - <h2>Site icon</h2> +<div class="card" id="logo"> + <h2>Logo & site icon</h2> + <div class="row"> + <label>Logo size<br><select name="logo_size"> + <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>Small (48 px tall)</option> + <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>Medium (80 px)</option> + <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>Large (140 px)</option> + </select></label> + </div> + {{template "imagepick" (dict "name" "logo" "current" $t.Logo "images" .Data.images "label" "Logo" "hint" "Shown wherever you place a Logo module (Layout tab); it links to your front page.")}} {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "images" .Data.images "label" "Favicon" "hint" "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon." "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}} </div> diff --git a/internal/web/templates/dashboard/layout.html b/internal/web/templates/dashboard/layout.html new file mode 100644 index 0000000..7691b1e --- /dev/null +++ b/internal/web/templates/dashboard/layout.html @@ -0,0 +1,113 @@ +{{define "title"}}Layout · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$t := .Data.theme}} +{{$u := print "/b/" .Blog.Subdomain "/layout"}} +<div class="pagehead"> + <h1>Layout</h1> + <p class="lead muted">Decide what goes where on your blog. Every change here is saved straight away; refresh <a href="{{.BlogURL}}" target="_blank">your blog ↗</a> to see it. Colours and fonts are on the <a href="/b/{{.Blog.Subdomain}}/design">Design</a> tab.</p> +</div> +<p class="jumps"><a href="#columns">Columns</a><a href="#header">Header</a><a href="#left">Left column</a><a href="#main">Main content</a><a href="#right">Right column</a><a href="#footer">Footer</a><a href="#menu">Menu</a></p> + +<div class="card" id="columns"> + <h2>Columns</h2> + <div class="lay"> + <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">Header{{if not $t.HeaderOn}} (hidden){{end}}</div> + <div class="lay-cols"> + <div class="lay-col lay-side{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.areas "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">Left {{$t.LeftWidth}}%</div> + <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">Main content {{$t.MainWidth}}%</div> + <div class="lay-col lay-side{{if not $t.RightOn}} off{{end}}{{if not (index .Data.areas "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">Right {{$t.RightWidth}}%</div> + </div> + <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">Footer{{if not $t.FooterOn}} (hidden){{end}}</div> + </div> + <form method="post" action="{{$u}}/widths"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <div class="row"> + <label>Left column width<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label> + <label>Right column width<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label> + </div> + <p class="muted small">Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; as soon as one does, the blog spreads over the whole page. On phones the columns stack: main content first, then the left and right columns, then the footer.</p> + <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> Always keep the columns' space, even when a column is empty or hidden <span class="muted">(the main content then has the same width on every page)</span></label> + <p class="actionrow"><button type="submit">Save columns</button></p> + </form> +</div> + +{{template "area" (dict "area" "header" "root" . "hint" "The top of every page. Stack the title, a logo and the menu in the order you like. A banner image can be chosen on the Design tab.")}} +{{template "area" (dict "area" "left" "root" . "hint" "A narrow column beside your posts. Good for the menu, an archive or recent posts.")}} + +<div class="card" id="main"> + <div class="cardhead"><h2>Main content</h2></div> + <p class="muted small">Your posts and pages always show here. You can add your own HTML before and after them — an intro box, a newsletter form, a badge…</p> + <table class="modules"> + {{range $area := .Data.areaOrder}}{{$mods := index $.Data.areas $area}} + <tr><th>{{index $.Data.areaNames $area}}</th> + {{if $mods}}{{$m := index $mods 0}} + <td>{{index $.Data.names $m.Kind}}{{with modsummary $m}} <span class="muted">— {{.}}</span>{{end}}</td> + <td class="nowrap actions"><a href="{{$u}}/modules/{{$m.ID}}/edit">edit</a> <a class="danger" href="{{$u}}/modules/{{$m.ID}}/delete">remove</a></td> + {{else}} + <td class="muted">Nothing extra.</td> + <td class="nowrap actions"><form method="post" action="{{$u}}/areas/{{$area}}/add" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="kind" value="html"><button class="mini">+ Add custom HTML</button></form></td> + {{end}} + </tr>{{end}} + </table> +</div> + +{{template "area" (dict "area" "right" "root" . "hint" "A narrow column on the other side of your posts. Same choices as the left one.")}} +{{template "area" (dict "area" "footer" "root" . "hint" "The bottom of every page: a copyright line, the RSS link, a list of your pages.")}} + +<div class="card" id="menu"> + <h2>Menu</h2> + <p class="muted small">The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking "Show in menu" on a page does the same as adding it here.</p> + {{if .Data.menu}}<table class="modules"> + {{range .Data.menu}}<tr> + <td class="nowrap order"> + <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> + <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + </td> + <td>{{.Text}} {{if .PageID}}<span class="tag">page</span>{{else}}<span class="tag">link</span> <span class="muted small">{{.URL}}</span>{{end}}</td> + <td class="nowrap actions"><form method="post" action="{{$u}}/menu/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">Remove</button></form></td> + </tr>{{end}} + </table>{{else}}<p class="muted">The menu is empty.</p>{{end}} + <div class="cols addrow"> + <form method="post" action="{{$u}}/menu/add-page" class="add"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <h3>Add a page</h3> + {{if .Data.freePages}}<label>Page<br><select name="page">{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label> + <button type="submit" class="secondary">Add page</button> + {{else}}<p class="muted small">Every page is already in the menu.</p>{{end}} + </form> + <form method="post" action="{{$u}}/menu/add-link" class="add"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <h3>Add a link</h3> + <label>Text<br><input name="label" maxlength="60" placeholder="My photos"></label> + <label>Address<br><input name="url" maxlength="500" placeholder="https://example.org"></label> + <button type="submit" class="secondary">Add link</button> + </form> + </div> +</div> +{{end}} + +{{/* One area card: on/off switch, its modules in order, and an "add" form. */}} +{{define "area"}}{{$r := .root}}{{$area := .area}}{{$u := print "/b/" $r.Blog.Subdomain "/layout"}}{{$on := $r.Data.theme.AreaOn $area}}{{$mods := index $r.Data.areas $area}} +<div class="card{{if not $on}} area-off{{end}}" id="{{$area}}"> + <div class="cardhead"> + <h2>{{index $r.Data.areaNames $area}}{{if not $on}} <span class="tag off">hidden</span>{{end}}</h2> + <form method="post" action="{{$u}}/areas/{{$area}}/toggle" class="inline switch"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><button class="mini">{{if $on}}Hide this area{{else}}Show this area{{end}}</button></form> + </div> + <p class="muted small">{{.hint}}</p> + {{if $mods}}<table class="modules"> + {{range $mods}}<tr> + <td class="nowrap order"> + <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> + <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + </td> + <td>{{index $r.Data.names .Kind}}{{with modsummary .}} <span class="muted">— {{.}}</span>{{end}} + {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}<span class="tag off">no logo chosen</span> <span class="muted small">pick one on the <a href="/b/{{$r.Blog.Subdomain}}/design#logo">Design</a> tab</span>{{end}}</td> + <td class="nowrap actions">{{if modsettings .Kind}}<a href="{{$u}}/modules/{{.ID}}/edit">edit</a> {{end}}<a class="danger" href="{{$u}}/modules/{{.ID}}/delete">remove</a></td> + </tr>{{end}} + </table>{{else}}<p class="muted">Nothing here yet{{if ne $area "header"}} — this area is not shown until you add a module{{end}}.</p>{{end}} + <form method="post" action="{{$u}}/areas/{{$area}}/add" class="addmod"> + <input type="hidden" name="_csrf" value="{{$r.CSRF}}"> + <label>Add a module<br><select name="kind">{{range index $r.Data.kinds $area}}<option value="{{.}}">{{index $r.Data.names .}}</option>{{end}}</select></label> + <button type="submit" class="secondary">Add</button> + </form> +</div>{{end}} diff --git a/internal/web/templates/dashboard/module_form.html b/internal/web/templates/dashboard/module_form.html new file mode 100644 index 0000000..b63b405 --- /dev/null +++ b/internal/web/templates/dashboard/module_form.html @@ -0,0 +1,34 @@ +{{define "title"}}{{.Data.name}} · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$m := .Data.module}} +<div class="card"> + <h1>{{.Data.name}} <span class="muted small">in the {{lower .Data.area}}</span></h1> + <form method="post"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + {{if or (eq $m.Kind "recent") (eq $m.Kind "archive") (eq $m.Kind "sitemap") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}} + <label>Heading <span class="muted">(optional)</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}Recent posts{{else if eq $m.Kind "archive"}}Archive{{else if eq $m.Kind "sitemap"}}Pages{{end}}"></label> + {{end}} + {{if eq $m.Kind "recent"}} + <div class="row"><label>How many posts<br><select name="count">{{range $n := .Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> + <p class="muted small">The newest published posts from every page of your blog, newest first.</p> + {{else if eq $m.Kind "archive"}} + <p class="muted small">Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open.</p> + {{else if eq $m.Kind "sitemap"}} + <p class="muted small">Links to every page of your blog.</p> + {{else if eq $m.Kind "html"}} + <label for="mod-body">HTML</label> + <textarea id="mod-body" name="body" rows="14" class="mono" spellcheck="false" placeholder="<p>Hello!</p>">{{$m.Body}}</textarea> + <p class="muted small">Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most.</p> + {{else if eq $m.Kind "text"}} + <label for="mod-body">Text</label> + <textarea id="mod-body" name="body" rows="4" placeholder="© 2026 Me">{{$m.Body}}</textarea> + <p class="muted small">Plain text; each line becomes its own paragraph.</p> + {{end}} + <p class="actionrow"> + <button type="submit">Save</button> + <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog ↗</a> + <a href="/b/{{.Blog.Subdomain}}/layout#{{if or (eq $m.Area "above") (eq $m.Area "below")}}main{{else}}{{$m.Area}}{{end}}">Back to layout</a> + </p> + </form> +</div> +{{end}} diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html index fbe49f9..acd539d 100644 --- a/internal/web/templates/dashboard/overview.html +++ b/internal/web/templates/dashboard/overview.html @@ -7,7 +7,7 @@ <div class="card"> <h2>Quick actions</h2> <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">Write a new post</a></p> - <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> · <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> · <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p> + <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> · <a href="/b/{{.Blog.Subdomain}}/layout">Arrange the layout</a> · <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> · <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p> </div> <div class="card"> <h2>Pages</h2> diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html index 5e9aca5..eeb09c5 100644 --- a/internal/web/templates/dashboard/pages.html +++ b/internal/web/templates/dashboard/pages.html @@ -3,18 +3,18 @@ <div class="pagehead"> <h1>Pages</h1> <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ New page</a></div> - <p class="lead muted">Pages appear in your blog's menu in this order. Each page holds its own list of posts.</p> + <p class="lead muted">Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the <a href="/b/{{.Blog.Subdomain}}/layout#menu">Layout</a> tab.</p> </div> <div class="card"> <table> - <tr><th>Menu order</th><th>Title</th><th>Address</th><th>Posts</th><th></th></tr> + <tr><th>Order</th><th>Title</th><th>Address</th><th>Posts</th><th></th></tr> {{range .Data.pages}}<tr> <td class="nowrap"> <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> </td> <td><a href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/edit">{{.Title}}</a> - {{if .IsHome}}<span class="tag">home</span>{{end}}{{if not .ShowInNav}}<span class="tag">hidden</span>{{end}}</td> + {{if .IsHome}}<span class="tag">home</span>{{end}}{{if not .ShowInNav}}<span class="tag off">not in menu</span>{{end}}</td> <td><a href="{{$.BlogURL}}{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}" target="_blank">{{if .IsHome}}/{{else}}/{{.Slug}}{{end}} ↗</a></td> <td><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.PostCount}}</a></td> <td class="nowrap"> diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html index 9d4529c..7eeaf7c 100644 --- a/internal/web/templates/dashboard/section_form.html +++ b/internal/web/templates/dashboard/section_form.html @@ -7,10 +7,14 @@ <input type="hidden" name="_csrf" value="{{.CSRF}}"> <label>Title <span class="muted">(optional)</span><br><input name="title" value="{{$s.Title}}" maxlength="120" autofocus placeholder="Next meeting"></label> <div class="row"> - <label>Where<br><select name="placement"> - <option value="above"{{if eq $s.Placement "above"}} selected{{end}}>Above the posts</option> - <option value="below"{{if eq $s.Placement "below"}} selected{{end}}>Below the posts</option> - <option value="sidebar"{{if eq $s.Placement "sidebar"}} selected{{end}}>Sidebar, under the menu</option> + <label>Where<br><select name="column"> + <option value="main"{{if eq $s.Column "main"}} selected{{end}}>Main content (with the posts)</option> + <option value="left"{{if eq $s.Column "left"}} selected{{end}}>Left column</option> + <option value="right"{{if eq $s.Column "right"}} selected{{end}}>Right column</option> + </select></label> + <label>Position<br><select name="position"> + <option value="top"{{if eq $s.Position "top"}} selected{{end}}>At the top</option> + <option value="bottom"{{if eq $s.Position "bottom"}} selected{{end}}>At the bottom</option> </select></label> <label>Look<br><select name="style"> <option value="note"{{if eq $s.Style "note"}} selected{{end}}>Highlighted</option> @@ -18,7 +22,7 @@ <option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>Plain</option> </select></label> </div> - <p class="muted small">The sidebar spot only exists when the menu is a sidebar (see <a href="/b/{{.Blog.Subdomain}}/design">Design</a>); otherwise a sidebar announcement is shown above the posts.</p> + <p class="muted small">"Top" of a side column means above its modules, "bottom" below them. If that column is not shown on your blog (see <a href="/b/{{.Blog.Subdomain}}/layout">Layout</a>) the announcement moves to the main content instead.</p> <label for="ed-body">Text <span class="muted">(Markdown)</span></label> {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> Shown on the blog</label> diff --git a/internal/web/templates/dashboard/sections.html b/internal/web/templates/dashboard/sections.html index d2fade7..1834229 100644 --- a/internal/web/templates/dashboard/sections.html +++ b/internal/web/templates/dashboard/sections.html @@ -16,7 +16,7 @@ <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> </td> <td><a href="{{$u}}/{{.ID}}/edit">{{if .Title}}{{.Title}}{{else}}(untitled){{end}}</a>{{if not .Enabled}}<span class="tag off">hidden</span>{{end}}</td> - <td>{{if eq .Placement "above"}}Above the posts{{else if eq .Placement "below"}}Below the posts{{else}}Sidebar{{end}}</td> + <td>{{if eq .Column "main"}}Main content{{else if eq .Column "left"}}Left column{{else}}Right column{{end}}, {{.Position}}</td> <td>{{if eq .Style "note"}}Highlighted{{else if eq .Style "warning"}}Warning{{else}}Plain{{end}}</td> <td class="nowrap"> <form method="post" action="{{$u}}/{{.ID}}/toggle" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{if .Enabled}}Hide{{else}}Show{{end}}</button></form> diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index 3447e1f..deaaa75 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -11,30 +11,35 @@ {{end}}<link rel="stylesheet" href="/static/blog.css"> <style>{{css .Data.css}}</style> </head> -<body class="nav-{{.Data.theme.NavPosition}}"> -{{if eq .Data.theme.NavPosition "top-bar"}}{{template "blognav" .}}{{end}} -<div class="site-header"> +{{$l := .Data.layout}} +<body class="{{if $l.HasLeft}}has-left {{end}}{{if $l.HasRight}}has-right{{end}}"> +{{if or $l.Header .Data.theme.HeaderImage}}{{if .Data.theme.HeaderOn}}<div class="site-header"> {{if .Data.theme.HeaderImage}}<div class="header-image"><a href="/"><img src="/media/{{.Data.theme.HeaderImage}}" alt=""></a></div>{{end}} - {{if .Data.theme.HeaderShowTitle}}<div class="wrap header-text"> - <h1 class="site-title"><a href="/">{{.Blog.Title}}</a></h1> - {{if .Blog.Tagline}}<p class="tagline">{{.Blog.Tagline}}</p>{{end}} - </div>{{end}} -</div> -{{if eq .Data.theme.NavPosition "below-header"}}{{template "blognav" .}}{{end}} -<div class="wrap body-wrap"> - {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{if .Data.sidebar}}<div class="sidebar-notices">{{template "notices" .Data.sidebar}}</div>{{end}}{{end}} - <div class="content"> - {{template "notices" .Data.above}} + {{range $l.Header}}{{template "module" (dict "m" . "root" $)}}{{end}} +</div>{{end}}{{end}} +<div class="wrap body-wrap cols"> + {{$n := .Data.notices}} + <div class="col col-main"><div class="content"> + {{template "notices" (index $n "main-top")}} + {{range $l.Above}}{{template "module" (dict "m" . "root" $)}}{{end}} {{template "content" .}} - {{template "notices" .Data.below}} - </div> -</div> -<div class="site-footer"> - <div class="wrap footer-inner"> - {{if .Data.theme.FooterText}}<p>{{.Data.theme.FooterText}}</p>{{end}} - <p class="small">{{if .Data.theme.FooterShowRSS}}<a href="/feed.xml">RSS</a> · {{end}}{{.Blog.Title}}</p> - </div> + {{range $l.Below}}{{template "module" (dict "m" . "root" $)}}{{end}} + {{template "notices" (index $n "main-bottom")}} + </div></div> + {{if $l.HasLeft}}<div class="col col-left{{if and (not $l.Left) (not (index $n "left-top")) (not (index $n "left-bottom"))}} col-empty{{end}}"> + {{template "notices" (index $n "left-top")}} + {{range $l.Left}}{{template "module" (dict "m" . "root" $)}}{{end}} + {{template "notices" (index $n "left-bottom")}} + </div>{{end}} + {{if $l.HasRight}}<div class="col col-right{{if and (not $l.Right) (not (index $n "right-top")) (not (index $n "right-bottom"))}} col-empty{{end}}"> + {{template "notices" (index $n "right-top")}} + {{range $l.Right}}{{template "module" (dict "m" . "root" $)}}{{end}} + {{template "notices" (index $n "right-bottom")}} + </div>{{end}} </div> +{{if $l.Footer}}<div class="site-footer"><div class="wrap footer-inner"> + {{range $l.Footer}}{{template "module" (dict "m" . "root" $)}}{{end}} +</div></div>{{end}} </body> </html>{{end}} @@ -44,6 +49,37 @@ </aside> {{end}}{{end}} -{{define "blognav"}}<div class="site-nav"><div class="wrap nav-inner"> -{{range .Data.nav}}<a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}"{{if and $.Data.page (eq $.Data.page.ID .ID)}} class="active"{{end}}>{{.Title}}</a> -{{end}}</div></div>{{end}} +{{/* One layout module. .m is the store.Module, .root the page data; the same + kind renders differently in the header (big blocks) and elsewhere (boxes). */}} +{{define "module"}}{{$m := .m}}{{$r := .root}}{{$t := $r.Data.theme}} +{{- if eq $m.Kind "title"}}<div class="wrap header-text"> + <h1 class="site-title"><a href="/">{{$r.Blog.Title}}</a></h1> + {{if $r.Blog.Tagline}}<p class="tagline">{{$r.Blog.Tagline}}</p>{{end}} +</div> +{{- else if eq $m.Kind "logo"}}{{if $t.Logo}}{{if eq $m.Area "header"}}<div class="wrap header-logo"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div> +{{else}}<div class="mod mod-logo"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{end}}{{end}} +{{- else if eq $m.Kind "menu"}}{{if eq $m.Area "header"}}<div class="site-nav"><div class="wrap nav-inner">{{template "menulinks" $r}}</div></div> +{{else}}<div class="mod site-nav mod-menu">{{template "modtitle" $m}}<div class="nav-inner">{{template "menulinks" $r}}</div></div>{{end}} +{{- else if eq $m.Kind "recent"}}<div class="mod mod-recent">{{template "modtitle" $m}} + <ul>{{range $i, $p := $r.Data.recent}}{{if lt $i $m.Count}}<li><a href="/{{$p.PageSlug}}/{{$p.Slug}}">{{$p.Title}}</a></li>{{end}}{{end}}</ul> +</div> +{{- else if eq $m.Kind "archive"}}<div class="mod mod-archive">{{template "modtitle" $m}} + {{range $i, $y := $r.Data.archive}}<details{{if eq $i 0}} open{{end}}><summary>{{$y.Year}} <span class="muted">({{$y.Count}})</span></summary> + {{range $y.Months}}<details><summary>{{.Name}} <span class="muted">({{len .Posts}})</span></summary> + <ul>{{range .Posts}}<li><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></li>{{end}}</ul> + </details>{{end}} + </details>{{else}}<p class="muted">No posts yet.</p>{{end}} +</div> +{{- else if eq $m.Kind "html"}}{{if not $m.Body}}{{else if or (eq $m.Area "above") (eq $m.Area "below")}}<div class="custom-html custom-html-{{$m.Area}}">{{html $m.Body}}</div> +{{else}}<div class="mod mod-html">{{template "modtitle" $m}}{{html $m.Body}}</div>{{end}} +{{- else if eq $m.Kind "rss"}}<p class="mod mod-rss small"><a href="/feed.xml">RSS feed</a></p> +{{- else if eq $m.Kind "text"}}{{if $m.Body}}<div class="mod mod-text">{{range split $m.Body "\n"}}{{if .}}<p>{{.}}</p>{{end}}{{end}}</div>{{end}} +{{- else if eq $m.Kind "sitemap"}}<div class="mod mod-sitemap">{{template "modtitle" $m}} + <ul>{{range $r.Data.pages}}<li><a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}">{{.Title}}</a></li>{{end}}</ul> +</div> +{{- end}}{{end}} + +{{define "modtitle"}}{{if .Title}}<h3 class="mod-title">{{.Title}}</h3>{{end}}{{end}} + +{{define "menulinks"}}{{range .Data.menu}}<a href="{{.Href}}"{{if and .PageID $.Data.page (eq $.Data.page.ID (deref64 .PageID))}} class="active"{{end}}>{{.Text}}</a> +{{end}}{{end}} diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html index f54cb2d..e46d7c3 100644 --- a/internal/web/templates/partials/dashnav.html +++ b/internal/web/templates/partials/dashnav.html @@ -21,6 +21,7 @@ <a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>Posts</a> <a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>Pages</a> <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>Announcements</a> + <a href="{{$b}}layout"{{if hasPrefix .Path (print $b "layout")}} class="active"{{end}}>Layout</a> <a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>Design</a> <a href="{{$b}}images"{{if hasPrefix .Path (print $b "images")}} class="active"{{end}}>Images</a> <a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>Settings</a> diff --git a/internal/web/theme.go b/internal/web/theme.go index 5ee2398..46067ba 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/url" "regexp" + "strconv" "strings" "text/template" "time" @@ -23,50 +24,69 @@ type Theme struct { LinkColor string `json:"link_color"` LinkHover string `json:"link_hover"` // "" = same as LinkColor LinkUnderline string `json:"link_underline"` // always | hover | never - ContentWidth string `json:"content_width"` // narrow | medium | wide + ContentWidth string `json:"content_width"` // narrow | medium | wide (no side columns) ContentStyle string `json:"content_style"` // card | flat ContentPadding string `json:"content_padding"` // normal | compact | roomy Font string `json:"font"` // sans | serif | mono HeadingFont string `json:"heading_font"` // same | sans | serif | mono FontSize string `json:"font_size"` // small | normal | large - HeaderShowTitle bool `json:"header_show_title"` HeaderImage string `json:"header_image"` HeaderImageHeight string `json:"header_image_height"` // short | medium | tall HeaderAlign string `json:"header_align"` // left | center HeaderBg string `json:"header_bg"` HeaderText string `json:"header_text"` - NavPosition string `json:"nav_position"` // below-header | top-bar | left-sidebar - NavAlign string `json:"nav_align"` // left | center | right (horizontal menus) - NavSidebarWidth string `json:"nav_sidebar_width"` // narrow | normal | wide (sidebar only) - NavStyle string `json:"nav_style"` // normal | uppercase - NavBg string `json:"nav_bg"` - NavText string `json:"nav_text"` - NavHover string `json:"nav_hover"` // "" = same as NavText - NavShowHome bool `json:"nav_show_home"` + NavAlign string `json:"nav_align"` // left | center | right (header menu) + NavStyle string `json:"nav_style"` // normal | uppercase + NavBg string `json:"nav_bg"` + NavText string `json:"nav_text"` + NavHover string `json:"nav_hover"` // "" = same as NavText ShowDates bool `json:"show_dates"` DateFormat string `json:"date_format"` // long | short | iso - FooterText string `json:"footer_text"` - FooterBg string `json:"footer_bg"` - FooterColor string `json:"footer_color"` - FooterShowRSS bool `json:"footer_show_rss"` - - Favicon string `json:"favicon"` // image uuid or "" for the Blogspace default + FooterBg string `json:"footer_bg"` + FooterColor string `json:"footer_color"` + + Logo string `json:"logo"` // image uuid or "" + LogoSize string `json:"logo_size"` // small | medium | large + Favicon string `json:"favicon"` // image uuid or "" for the Blogspace default + + // Layout: which areas are shown and how wide the side columns are. What + // each area contains lives in the modules table (store.Module). + HeaderOn bool `json:"header_on"` + LeftOn bool `json:"left_on"` + RightOn bool `json:"right_on"` + FooterOn bool `json:"footer_on"` + LeftWidth int `json:"left_width"` // percent of the page + RightWidth int `json:"right_width"` // percent of the page + // KeepColumns lays the side columns out even when empty or hidden, so + // the main content keeps the same width on every page. + KeepColumns bool `json:"keep_columns"` } +// Areas of the blog page, in page order. above/below sit inside the main column. +var Areas = []string{"header", "left", "above", "below", "right", "footer"} + +const ( + minColumnWidth = 15 + maxColumnWidth = 40 + maxColumnsSum = 70 // main keeps at least 30% +) + func DefaultTheme() Theme { return Theme{ BgColor: "#e9e6df", BgMode: "cover", ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", LinkUnderline: "always", ContentWidth: "medium", ContentStyle: "card", ContentPadding: "normal", Font: "sans", HeadingFont: "same", FontSize: "normal", - HeaderShowTitle: true, HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", - NavPosition: "below-header", NavAlign: "left", NavSidebarWidth: "normal", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true, + HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", + NavAlign: "left", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", ShowDates: true, DateFormat: "long", - FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterShowRSS: true, + FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", + LogoSize: "medium", + HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, } } @@ -131,9 +151,7 @@ func (t *Theme) normalize() { t.HeaderAlign = pick(t.HeaderAlign, "left", "center") t.HeaderBg = color(t.HeaderBg, d.HeaderBg) t.HeaderText = color(t.HeaderText, d.HeaderText) - t.NavPosition = pick(t.NavPosition, "below-header", "top-bar", "left-sidebar") t.NavAlign = pick(t.NavAlign, "left", "center", "right") - t.NavSidebarWidth = pick(t.NavSidebarWidth, "normal", "narrow", "wide") t.NavStyle = pick(t.NavStyle, "normal", "uppercase") t.NavBg = color(t.NavBg, d.NavBg) t.NavText = color(t.NavText, d.NavText) @@ -141,10 +159,24 @@ func (t *Theme) normalize() { t.DateFormat = pick(t.DateFormat, "long", "short", "iso") t.FooterBg = color(t.FooterBg, d.FooterBg) t.FooterColor = color(t.FooterColor, d.FooterColor) - if len(t.FooterText) > 2000 { - t.FooterText = t.FooterText[:2000] - } + t.Logo = imageID(t.Logo) + t.LogoSize = pick(t.LogoSize, "medium", "small", "large") t.Favicon = imageID(t.Favicon) + t.LeftWidth = clampInt(t.LeftWidth, minColumnWidth, maxColumnWidth) + t.RightWidth = clampInt(t.RightWidth, minColumnWidth, maxColumnWidth) + if t.LeftWidth+t.RightWidth > maxColumnsSum { // the right column gives way + t.RightWidth = maxColumnsSum - t.LeftWidth + } +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v } // ThemeFromForm reads the design form on top of the current theme. @@ -172,32 +204,71 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.Font = get("font") t.HeadingFont = get("heading_font") t.FontSize = get("font_size") - t.HeaderShowTitle = on("header_show_title") t.HeaderImageHeight = get("header_image_height") t.HeaderAlign = get("header_align") t.HeaderBg = get("header_bg") t.HeaderText = get("header_text") - t.NavPosition = get("nav_position") t.NavAlign = get("nav_align") - t.NavSidebarWidth = get("nav_sidebar_width") t.NavStyle = get("nav_style") t.NavBg = get("nav_bg") t.NavText = get("nav_text") t.NavHover = hover("nav_hover") - t.NavShowHome = on("nav_show_home") t.ShowDates = on("show_dates") t.DateFormat = get("date_format") - t.FooterText = get("footer_text") t.FooterBg = get("footer_bg") t.FooterColor = get("footer_color") - t.FooterShowRSS = on("footer_show_rss") + t.LogoSize = get("logo_size") t.BgImage = pickImage(t.BgImage, get("bg_image")) t.HeaderImage = pickImage(t.HeaderImage, get("header_image")) t.Favicon = pickImage(t.Favicon, get("favicon")) + t.Logo = pickImage(t.Logo, get("logo")) t.normalize() return t } +// LayoutFromForm reads the columns form of the Layout page; the area +// toggles have their own buttons (SetAreaOn). +func LayoutFromForm(cur Theme, f url.Values) Theme { + t := cur + t.LeftWidth, _ = strconv.Atoi(strings.TrimSpace(f.Get("left_width"))) + t.RightWidth, _ = strconv.Atoi(strings.TrimSpace(f.Get("right_width"))) + t.KeepColumns = f.Get("keep_columns") == "on" + t.normalize() + return t +} + +// AreaOn reports whether an area is switched on; above/below are part of the main column and always on. +func (t Theme) AreaOn(area string) bool { + switch area { + case "header": + return t.HeaderOn + case "left": + return t.LeftOn + case "right": + return t.RightOn + case "footer": + return t.FooterOn + } + return true +} + +// SetAreaOn flips an area's switch; unknown areas are ignored. +func (t *Theme) SetAreaOn(area string, on bool) { + switch area { + case "header": + t.HeaderOn = on + case "left": + t.LeftOn = on + case "right": + t.RightOn = on + case "footer": + t.FooterOn = on + } +} + +// MainWidth is what the main column gets when both side columns are shown. +func (t Theme) MainWidth() int { return 100 - t.LeftWidth - t.RightWidth } + // pickImage applies an imagepick radio: "none" clears, an id selects, and an // empty value (no radio in the form) keeps what was there. func pickImage(cur, v string) string { @@ -324,29 +395,15 @@ func (t Theme) ContentPaddingCSS() (desktop, phone string) { func (t Theme) PaddingDesktop() string { d, _ := t.ContentPaddingCSS(); return d } func (t Theme) PaddingPhone() string { _, p := t.ContentPaddingCSS(); return p } -// SidebarWidthPx is the sidebar width when it differs from blog.css's 180px, else "". -func (t Theme) SidebarWidthPx() string { - if t.NavPosition != "left-sidebar" { - return "" - } - switch t.NavSidebarWidth { - case "narrow": +// LogoMaxHeight sizes the logo image wherever the logo module appears. +func (t Theme) LogoMaxHeight() string { + switch t.LogoSize { + case "small": + return "48px" + case "large": return "140px" - case "wide": - return "240px" } - return "" -} - -// SidebarContentMargin leaves the same 20px gutter blog.css uses. -func (t Theme) SidebarContentMargin() string { - switch t.NavSidebarWidth { - case "narrow": - return "160px" - case "wide": - return "260px" - } - return "" + return "80px" } func (t Theme) HeaderImageMaxHeight() string { @@ -359,7 +416,7 @@ func (t Theme) HeaderImageMaxHeight() string { return "320px" } -var themeCSS = template.Must(template.New("theme").Parse(` +var themeCSS = template.Must(template.New("theme").Funcs(template.FuncMap{"sub": func(a, b int) int { return a - b }}).Parse(` body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.55; {{- if .BgImage}} background-image:url(/media/{{.BgImage}}); {{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat; @@ -383,12 +440,15 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl {{- if .NavHover}} .site-nav a:hover { color:{{.NavHover}}; } {{- end}} -{{- with .SidebarWidthPx}} -.nav-left-sidebar .body-wrap .site-nav, .nav-left-sidebar .body-wrap .sidebar-notices { width:{{.}}; } -.nav-left-sidebar .body-wrap .content { margin-left:{{$.SidebarContentMargin}}; } -@media (max-width: 700px) { .nav-left-sidebar .body-wrap .site-nav, .nav-left-sidebar .body-wrap .sidebar-notices { width:auto; } .nav-left-sidebar .body-wrap .content { margin-left:0; } } -{{- end}} -.content { background:{{.ContentBg}};{{with .PaddingDesktop}} padding:{{.}};{{end}}{{if eq .ContentStyle "flat"}} border-radius:0; box-shadow:none;{{end}} } +.content, .mod { background:{{.ContentBg}};{{if eq .ContentStyle "flat"}} border-radius:0; box-shadow:none;{{end}} } +.content {{"{"}}{{with .PaddingDesktop}} padding:{{.}};{{end}} } +.logo { max-height:{{.LogoMaxHeight}}; } +@media (min-width: 701px) { + .col-left { width:{{.LeftWidth}}%; } .col-right { width:{{.RightWidth}}%; } + body.has-left.has-right .col-main { width:{{.MainWidth}}%; } + body.has-left:not(.has-right) .col-main { width:{{sub 100 .LeftWidth}}%; } + body.has-right:not(.has-left) .col-main { width:{{sub 100 .RightWidth}}%; } +} {{- with .PaddingPhone}} @media (max-width: 700px) { .content { padding:{{.}}; } } {{- end}} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 9afa11a..b8f761a 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -51,7 +51,7 @@ func TestHostRoutingWithoutDB(t *testing.T) { func TestRootRoutePrecedence(t *testing.T) { cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} s := NewServer(cfg, nil) - for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/account/password": 303} { + for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/account/password": 303} { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", path, nil) req.Host = "example.com" @@ -68,19 +68,25 @@ func TestRootRoutePrecedence(t *testing.T) { } func TestThemeNormalizeAndCSS(t *testing.T) { - th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","nav_position":"left-sidebar","link_hover":"blue","heading_font":"wingdings","favicon":"x"}`)) - if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.NavPosition != "left-sidebar" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" { + th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","logo":"../etc","link_hover":"blue","heading_font":"wingdings","favicon":"x","logo_size":"huge","left_width":5,"right_width":90}`)) + if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.Logo != "" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" || th.LogoSize != "medium" { t.Errorf("normalize: %+v", th) } - // Themes saved before the new options existed keep today's look. - if !th.ShowDates || !th.FooterShowRSS || th.LinkUnderline != "always" || th.ContentStyle != "card" || th.DateFormat != "long" { + if th.LeftWidth != 15 || th.RightWidth != 40 { + t.Errorf("width clamp: %+v", th) + } + // Themes saved before the new options existed keep today's look: every area on, columns a quarter each. + if !th.ShowDates || th.LinkUnderline != "always" || th.ContentStyle != "card" || th.DateFormat != "long" { t.Errorf("old theme defaults: %+v", th) } - f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "header_show_title": {"on"}, "bg_image": {"not-a-uuid"}, + if !th.HeaderOn || !th.LeftOn || !th.RightOn || !th.FooterOn || th.LeftWidth != 15 { + t.Errorf("old theme layout defaults: %+v", th) + } + f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "bg_image": {"not-a-uuid"}, "logo_size": {"large"}, "link_hover": {"#ff0000"}, "nav_hover": {"#00ff00"}, "nav_hover_custom": {"on"}, "nav_style": {"uppercase"}, "nav_align": {"center"}, "link_underline": {"hover"}, "heading_font": {"serif"}, "content_style": {"flat"}, "content_padding": {"roomy"}, "date_format": {"iso"}} th = ThemeFromForm(DefaultTheme(), f) - if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || !th.HeaderShowTitle || th.BgImage != "" { + if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || th.BgImage != "" || th.LogoSize != "large" { t.Errorf("from form: %+v", th) } if th.LinkHover != "" || th.NavHover != "#00ff00" || th.ShowDates || th.DateFormat != "iso" { @@ -88,7 +94,7 @@ func TestThemeNormalizeAndCSS(t *testing.T) { } css := th.CSS() for _, want := range []string{"background-color:#abcdef", "max-width:1100px", "text-transform:uppercase", ".site-nav a:hover { color:#00ff00", "a:hover { text-decoration:underline", - "h1, h2, h3, h4, h5, h6 { font-family:Georgia", "text-align:center", "border-radius:0", "padding:2.2em 3em", "@media (max-width: 700px)"} { + "h1, h2, h3, h4, h5, h6 { font-family:Georgia", "text-align:center", "border-radius:0", "padding:2.2em 3em", "@media (max-width: 700px)", ".logo { max-height:140px"} { if !strings.Contains(css, want) { t.Errorf("css missing %q:\n%s", want, css) } @@ -102,20 +108,139 @@ func TestThemeNormalizeAndCSS(t *testing.T) { } } -func TestSidebarWidthCSS(t *testing.T) { - th := ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}, "nav_sidebar_width": {"wide"}}) +func TestLayoutTheme(t *testing.T) { + th := LayoutFromForm(DefaultTheme(), url.Values{"left_width": {"40"}, "right_width": {"40"}, "keep_columns": {"on"}}) + if th.LeftWidth != 40 || th.RightWidth != 30 || th.MainWidth() != 30 || !th.KeepColumns { + t.Errorf("the right column should give way: %+v", th) + } + th = LayoutFromForm(th, url.Values{"left_width": {"junk"}, "right_width": {"20"}}) + if th.LeftWidth != 15 || th.RightWidth != 20 || th.KeepColumns { + t.Errorf("junk clamps to the minimum: %+v", th) + } css := th.CSS() - if !strings.Contains(css, "width:240px") || !strings.Contains(css, "margin-left:260px") || !strings.Contains(css, "margin-left:0") { - t.Errorf("sidebar css: %s", css) + for _, want := range []string{"@media (min-width: 701px)", ".col-left { width:15%", ".col-right { width:20%", "body.has-left.has-right .col-main { width:65%", + "body.has-left:not(.has-right) .col-main { width:85%", "body.has-right:not(.has-left) .col-main { width:80%"} { + if !strings.Contains(css, want) { + t.Errorf("css missing %q:\n%s", want, css) + } + } + th.SetAreaOn("left", false) + th.SetAreaOn("bogus", false) + if th.AreaOn("left") || !th.AreaOn("right") || !th.AreaOn("above") { + t.Errorf("area switches: %+v", th) + } +} + +func TestBuildLayout(t *testing.T) { + mods := []store.Module{{ID: 1, Area: "header", Kind: "title"}, {ID: 2, Area: "left", Kind: "recent", Count: 3}, {ID: 3, Area: "right", Kind: "recent", Count: 8}, + {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}} + th := DefaultTheme() + l := buildLayout(th, mods) + if !l.HasLeft || !l.HasRight || len(l.Header) != 1 || len(l.Footer) != 1 || len(l.Above) != 1 || l.MaxRecent() != 8 || !l.NeedsArchive() { + t.Errorf("layout: %+v", l) + } + th.RightOn, th.FooterOn = false, false + l = buildLayout(th, mods) + if l.HasRight || len(l.Footer) != 0 || l.MaxRecent() != 3 || l.NeedsArchive() { + t.Errorf("switched-off areas should drop their modules: %+v", l) + } + if buildLayout(th, nil).HasLeft { + t.Error("an empty column is not shown") + } + th.KeepColumns = true + if k := buildLayout(th, nil); !k.HasLeft || !k.HasRight || len(k.Right) != 0 { + t.Errorf("keep_columns reserves empty and hidden columns: %+v", k) + } + th.KeepColumns = false + // announcements go to their column, or to the main one when that column is not laid out + secs := []store.Section{{ID: 1, Placement: "main-top"}, {ID: 2, Placement: "right-bottom"}, {ID: 3, Placement: "left-top"}, {ID: 4, Placement: "left-bottom"}} + n := placeNotices(secs, l) // l: left laid out, right not + if len(n["main-top"]) != 1 || len(n["main-bottom"]) != 1 || n["main-bottom"][0].ID != 2 || len(n["left-top"]) != 1 || len(n["left-bottom"]) != 1 || len(n["right-bottom"]) != 0 { + t.Errorf("left column only: %v", n) + } + n = placeNotices(secs, Layout{}) + if len(n["main-top"]) != 2 || n["main-top"][1].ID != 3 || len(n["main-bottom"]) != 2 || len(n) != 2 { // no columns: everything in main, order kept + t.Errorf("no columns: %v", n) + } +} + +func TestModuleKinds(t *testing.T) { + for area, kinds := range moduleKinds { + if _, ok := areaNames[area]; !ok { + t.Errorf("area %q has no name", area) + } + for _, k := range kinds { + if moduleNames[k] == "" { + t.Errorf("kind %q has no name", k) + } + } + } + if !allowedKind("header", "menu") || allowedKind("header", "archive") || allowedKind("above", "menu") || allowedKind("attic", "html") { + t.Error("allowedKind") + } + if !moduleHasSettings("html") || moduleHasSettings("rss") { + t.Error("moduleHasSettings") + } + if got := moduleSummary(store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { + t.Errorf("recent summary: %q", got) + } + if got := moduleSummary(store.Module{Kind: "html", Body: " <p>hi\n there</p> "}); got != "<p>hi there</p>" { + t.Errorf("html summary: %q", got) } - // The width only matters for the sidebar layout, and "normal" is blog.css's own value. - th.NavPosition = "top-bar" - if strings.Contains(th.CSS(), "width:240px") { - t.Error("sidebar width leaked into a horizontal menu") + if got := moduleSummary(store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") { + t.Errorf("long html summary: %q", got) } - th = ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}}) - if strings.Contains(th.CSS(), ".nav-left-sidebar") { - t.Error("normal width should emit nothing") + if got := moduleSummary(store.Module{Kind: "menu"}); got != "" { + t.Errorf("menu summary: %q", got) + } +} + +func TestValidLinkURL(t *testing.T) { + for _, ok := range []string{"https://example.org", "http://example.org/a?b=c", "mailto:me@example.org", "/about", "/news/hello"} { + if !validLinkURL(ok) { + t.Errorf("%q should be accepted", ok) + } + } + for _, bad := range []string{"", "javascript:alert(1)", "//evil.org", "ftp://x", "https://", "example.org", "https://a b", "\"><script>", strings.Repeat("/", 501)} { + if validLinkURL(bad) { + t.Errorf("%q should be rejected", bad) + } + } +} + +func TestGroupArchive(t *testing.T) { + d := func(y int, m time.Month, day int) time.Time { return time.Date(y, m, day, 12, 0, 0, 0, time.UTC) } + posts := []store.PostRef{{Title: "d", CreatedAt: d(2026, 9, 3)}, {Title: "c", CreatedAt: d(2026, 9, 1)}, {Title: "b", CreatedAt: d(2026, 2, 1)}, {Title: "a", CreatedAt: d(2025, 12, 31)}} + years := groupArchive(posts) + if len(years) != 2 || years[0].Year != 2026 || years[0].Count != 3 || years[1].Year != 2025 || years[1].Count != 1 { + t.Fatalf("years: %+v", years) + } + if len(years[0].Months) != 2 || years[0].Months[0].Name != "September" || len(years[0].Months[0].Posts) != 2 || years[0].Months[1].Name != "February" { + t.Errorf("months: %+v", years[0].Months) + } + if years[0].Months[0].Posts[0].Title != "d" || years[1].Months[0].Posts[0].Title != "a" { + t.Errorf("post order: %+v", years) + } + if len(groupArchive(nil)) != 0 { + t.Error("empty index") + } +} + +func TestMenuItemLinks(t *testing.T) { + id := int64(7) + page := store.MenuItem{PageID: &id, PageTitle: "About", PageSlug: "about"} + home := store.MenuItem{PageID: &id, PageTitle: "Home", PageSlug: "home", IsHome: true} + link := store.MenuItem{Label: "Photos", URL: "https://example.org/p"} + if page.Href() != "/about" || page.Text() != "About" || home.Href() != "/" || link.Href() != "https://example.org/p" || link.Text() != "Photos" { + t.Errorf("%q %q %q", page.Href(), home.Href(), link.Href()) + } +} + +func TestRedirectOKKeepsAnchor(t *testing.T) { + rec := httptest.NewRecorder() + redirectOK(rec, httptest.NewRequest("GET", "/", nil), "/b/a/layout#menu", "hi there") + if got := rec.Header().Get("Location"); got != "/b/a/layout?ok=hi+there#menu" { + t.Errorf("location: %q", got) } } @@ -142,7 +267,7 @@ func TestPresets(t *testing.T) { t.Fatal("expected several presets") } cur := DefaultTheme() - cur.BgImage, cur.NavPosition, cur.Font, cur.LinkHover = "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "top-bar", "mono", "#123456" + cur.BgImage, cur.LeftWidth, cur.Font, cur.LinkHover = "6ba7b810-9dad-11d1-80b4-00c04fd430c8", 30, "mono", "#123456" for _, p := range Presets() { for _, c := range []string{p.Bg, p.ContentBg, p.Text, p.Link, p.HeaderBg, p.HeaderText, p.NavBg, p.NavText, p.FooterBg, p.FooterText} { if !hexColor.MatchString(c) { @@ -153,7 +278,7 @@ func TestPresets(t *testing.T) { if th.BgColor != p.Bg || th.FooterColor != p.FooterText || th.LinkHover != "" { t.Errorf("preset %s not applied: %+v", p.Key, th) } - if th.BgImage != cur.BgImage || th.NavPosition != "top-bar" || th.Font != "mono" { + if th.BgImage != cur.BgImage || th.LeftWidth != 30 || th.Font != "mono" { t.Errorf("preset %s touched non-colour fields: %+v", p.Key, th) } } @@ -176,7 +301,7 @@ func TestAllTemplatesParse(t *testing.T) { tpl := newTemplates(false, funcs) for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html", - "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", + "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", "blog/page.html", "blog/post.html", "blog/404.html"} { if _, err := tpl.get(name); err != nil { t.Errorf("%s: %v", name, err) @@ -211,19 +336,20 @@ func TestAppendImageMD(t *testing.T) { } } -func TestSplitSections(t *testing.T) { - secs := []store.Section{{ID: 1, Placement: "above"}, {ID: 2, Placement: "below"}, {ID: 3, Placement: "sidebar"}} - above, below, sidebar := splitSections(secs, true) - if len(above) != 1 || len(below) != 1 || len(sidebar) != 1 || sidebar[0].ID != 3 { - t.Errorf("with sidebar: %v %v %v", above, below, sidebar) - } - above, _, sidebar = splitSections(secs, false) - if len(above) != 2 || len(sidebar) != 0 || above[1].ID != 3 { // sidebar falls back to above, keeping order - t.Errorf("without sidebar: %v %v", above, sidebar) - } +func TestNormalizeSection(t *testing.T) { sec := &store.Section{Placement: "footer", Style: "<script>"} normalizeSection(sec) - if sec.Placement != "above" || sec.Style != "note" { + if sec.Placement != "main-top" || sec.Style != "note" { t.Errorf("normalize: %+v", sec) } + sec = &store.Section{Placement: "right-bottom"} + normalizeSection(sec) + if sec.Placement != "right-bottom" || sec.Column() != "right" || sec.Position() != "bottom" { + t.Errorf("valid placement kept: %+v", sec) + } + sec = &store.Section{Placement: "left-sideways"} + normalizeSection(sec) + if sec.Placement != "left-top" { + t.Errorf("half-valid placement: %+v", sec) + } } |
