aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md76
-rw-r--r--README.md9
-rw-r--r--cmd/blogspace/seed.go12
-rw-r--r--internal/db/migrations/00003_layout.sql56
-rw-r--r--internal/db/migrations/00004_section_placement.sql13
-rw-r--r--internal/store/blogs.go10
-rw-r--r--internal/store/menu.go128
-rw-r--r--internal/store/modules.go136
-rw-r--r--internal/store/pages.go49
-rw-r--r--internal/store/posts.go27
-rw-r--r--internal/store/sections.go7
-rw-r--r--internal/web/handlers_blog.go51
-rw-r--r--internal/web/handlers_design.go14
-rw-r--r--internal/web/handlers_layout.go307
-rw-r--r--internal/web/handlers_sections.go6
-rw-r--r--internal/web/layout.go222
-rw-r--r--internal/web/routes.go13
-rw-r--r--internal/web/server.go7
-rw-r--r--internal/web/static/blog.css50
-rw-r--r--internal/web/static/dashboard.css45
-rw-r--r--internal/web/templates.go11
-rw-r--r--internal/web/templates/dashboard/design.html54
-rw-r--r--internal/web/templates/dashboard/layout.html113
-rw-r--r--internal/web/templates/dashboard/module_form.html34
-rw-r--r--internal/web/templates/dashboard/overview.html2
-rw-r--r--internal/web/templates/dashboard/pages.html6
-rw-r--r--internal/web/templates/dashboard/section_form.html14
-rw-r--r--internal/web/templates/dashboard/sections.html2
-rw-r--r--internal/web/templates/layouts/blog.html84
-rw-r--r--internal/web/templates/partials/dashnav.html1
-rw-r--r--internal/web/theme.go176
-rw-r--r--internal/web/web_test.go192
32 files changed, 1679 insertions, 248 deletions
diff --git a/AGENTS.md b/AGENTS.md
index b552615..132e9c2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -29,6 +29,11 @@ table and deployment notes.
- Changes are live immediately — there is no draft/preview system. The UX
is "save, then refresh your blog tab"; keep the "View blog ↗" links.
- Theme customisation is a structured form only; **no custom CSS input**.
+ The one escape hatch is the **custom HTML module** (Layout tab), which the
+ owner chose to store and output **unsanitised**. The blast radius is the
+ blogger's own origin: the session cookie is host-only on the base domain
+ and `HttpOnly`, management POSTs need the HMAC `_csrf` token, and the
+ base-domain blog is only editable by the superadmin.
- App runs plain HTTP behind a reverse proxy; auth is a JWT cookie.
- Stdlib `net/http` ServeMux with method+pattern routes. No router library.
@@ -38,14 +43,16 @@ table and deployment notes.
cmd/blogspace/ main.go (serve|seed|migrate, superadmin + root blog bootstrap), seed.go
internal/config/ env → Config; RootSubdomain = "www"
internal/db/ pgxpool + goose; migrations/*.sql embedded
-internal/store/ models + hand-written SQL (users, blogs, pages, posts, images, sections)
+internal/store/ models + hand-written SQL (users, blogs, pages, posts, images, sections, modules, menu)
internal/auth/ bcrypt, JWT issue/parse, cookie, HMAC CSRF
internal/markdown/ Render(md) → sanitized HTML
internal/slug/ Make/Valid/WithSuffix (Greek transliteration included)
internal/web/ server.go (host router, middleware, render helpers)
- routes.go (all routes), handlers_*.go (sections = announcements), theme.go
+ routes.go (all routes), handlers_*.go (sections = announcements, layout = modules + menu)
+ theme.go (colours/fonts/layout switches), layout.go (module kinds, archive grouping)
templates/ (embedded; layouts/, partials/, auth/, dashboard/, admin/, blog/)
- static/ (dashboard.css "paper & ink" look — plain CSS, no variables/flex; blog.css)
+ static/ (dashboard.css "paper & ink" look — plain CSS, no variables/flex;
+ blog.css — floats except the three-column `.cols`, which is flexbox)
```
## Key mechanics
@@ -79,27 +86,53 @@ internal/web/ server.go (host router, middleware, render helpers)
Handlers call `s.render(w, r, "dir/file.html", map[string]any{...})`;
page data is under `.Data.<key>`, common fields (`.User`, `.Blog`,
`.CSRF`, `.Flash`, `.Error`, `.BlogURL`, `.RootURL`) are top level.
- Flash messages travel as `?ok=` query param via `redirectOK`. Add new
+ Flash messages travel as `?ok=` query param via `redirectOK` (a `#anchor`
+ in the target is kept after the query). Add new
templates to `TestAllTemplatesParse`.
- **Theme** (`web/theme.go`): struct stored as jsonb on `blogs.theme`.
`ParseTheme` merges over `DefaultTheme()` and `normalize()` clamps every
- value to an allowlist (hex colours, enum strings, uuid image ids) — this
- is what makes inlining the generated CSS safe. Add new options in the
- struct, `normalize`, `ThemeFromForm`, the CSS template and
- `dashboard/design.html`; defaults must reproduce the look blogs had
+ value to an allowlist (hex colours, enum strings, uuid image ids, column
+ percentages) — this is what makes inlining the generated CSS safe. Add
+ new options in the struct, `normalize`, `ThemeFromForm`, the CSS template
+ and `dashboard/design.html`; defaults must reproduce the look blogs had
before the option existed. Non-CSS options (`ShowDates`, `DateFormat` via
- `FormatDate`, `FooterShowRSS`, `Favicon`) are read by the blog templates.
+ `FormatDate`, `Favicon`, `Logo`) are read by the blog templates.
Optional colours (`LinkHover`, `NavHover`) are `""` = inherit and come
- from a colour input paired with a `<name>_custom` checkbox. Image fields
+ from a colour input paired with a `<name>_custom` checkbox, shown/hidden
+ with CSS only (`.hoverpick > input:not(:checked) ~ …`). Image fields
use the `imagepick` partial's radios: `none` clears, a uuid selects, no
value keeps (`pickImage`); an upload in `<name>_file` wins.
- Options that only apply to one menu layout (`NavAlign` for horizontal
- menus, `NavSidebarWidth` for the sidebar) are shown/hidden in
- `design.html` with CSS only (`#np-*:checked ~ .only-*` in dashboard.css,
- same trick for the `_custom` hover checkboxes) — no JS, and browsers
- without `:checked` just show everything.
`Presets()`/`WithPreset` are the colour schemes (`POST /b/{sub}/design/preset`,
- colours only); `POST /b/{sub}/design/reset` stores `DefaultTheme()`.
+ colours only); `POST /b/{sub}/design/reset` stores `DefaultTheme()` and
+ calls `ResetModules`.
+ The layout switches (`HeaderOn/LeftOn/RightOn/FooterOn`, `LeftWidth`,
+ `RightWidth`, `KeepColumns`) also live in the theme but are edited from the Layout tab
+ (`LayoutFromForm`, `SetAreaOn`); `ThemeFromForm` never touches them.
+- **Layout** (`modules` table, `store/modules.go`, `web/layout.go`,
+ `handlers_layout.go`, `/b/{sub}/layout…`): a blog page is six areas —
+ `header`, `left`, `above`, `below` (the two custom-HTML slots inside the
+ main column, one module at most), `right`, `footer` — each an ordered
+ list of modules (`kind`: title, logo, menu, archive, recent, html, rss,
+ text, sitemap; `moduleKinds` says which kinds an area accepts). Modules
+ have an optional `title` (heading), `body` (raw HTML or footer text) and
+ `count` (recent posts). `blogView` builds a `Layout` (`buildLayout`,
+ areas that are switched off are dropped) and only fetches the archive
+ index / recent posts when a visible module needs them. A side column is
+ laid out only when it has modules, or always with `theme.keep_columns`
+ (`HasLeft/HasRight` → `body.has-*`
+ classes, which also lift the `.wrap` max-width so the page goes
+ full-width; column widths in the theme CSS under `@media (min-width: 701px)`);
+ below 700px `.cols` becomes block flow so the order is main → left →
+ right → footer. Everything is rendered by the `module` template in
+ `layouts/blog.html`. New blogs get `insertDefaultModules` (title + menu
+ in the header, RSS in the footer) — keep it in step with the layout
+ migration's defaults.
+- **Menu** (`menu_items` table, `store/menu.go`): the single ordered list
+ the Menu module shows — page entries (`page_id`, unique per page, cascade
+ on delete) and custom links (`label` + `url`, validated by
+ `validLinkURL`: http(s), mailto or a `/path`). `Page.ShowInNav` is
+ derived (`EXISTS` on `menu_items`); `CreatePage`/`UpdatePage` add or
+ remove the entry so the page form's checkbox and the Layout tab agree.
- **Favicon**: `static/favicon.svg` + `favicon.png` are the defaults, linked
from both layouts. A blog can set `theme.favicon` (an image id) which the
blog layout links instead. `GET /favicon.ico` on every host
@@ -116,9 +149,11 @@ internal/web/ server.go (host router, middleware, render helpers)
`s.readUpload(r, "inline_image")` + `appendImageMD` (the no-JS path).
- **Announcements** (`sections` table, `store/sections.go`,
`handlers_sections.go`, `/b/{sub}/announcements…`): per-blog notices with
- `placement` (above|below|sidebar), `style` (plain|note|warning), `enabled`
- and `sort_order`. `blogView` loads the enabled ones and `splitSections`
- routes `sidebar` to the left-sidebar layout only (otherwise above).
+ `placement` (`<column>-<position>`: left|main|right × top|bottom, split by
+ `Section.Column()/Position()`), `style` (plain|note|warning), `enabled`
+ and `sort_order`. `blogView` loads the enabled ones and `placeNotices`
+ groups them by placement, moving ones for a side column that is not laid
+ out into the main column.
Rendered by the `notices` template in `layouts/blog.html`; CSS classes
`notice notice-<style>` use translucent colours so they fit any theme.
- **Dashboard nav**: `dashnav.html` marks the current tab with `hasPrefix
@@ -160,4 +195,5 @@ superadmin password to `admin`. Production refuses both.
## Things deliberately not built (ask before adding)
Drafts/preview, custom CSS, comments, multiple blogs per user, email,
-user self-registration, custom domains per blog, image resizing, search.
+user self-registration, custom domains per blog, image resizing, search,
+sanitising the custom HTML module (owner's decision, see above).
diff --git a/README.md b/README.md
index 8ccc78a..d8c11f2 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,14 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones.
inserted straight from the editor (file picker, paste or drag-and-drop; with JavaScript
off the file is appended on save).
- **Announcements**: blog-wide notices (next meeting, this month's book, a closure)
- shown above or below the posts, or under the menu; each can be hidden without deleting.
+ shown at the top or bottom of the main content or of a side column; each can be hidden without deleting.
- Every blog has **pages** (Home, About, News, …); each page holds posts.
-- **Design** form: colour schemes, background colour/image, fonts, link style, content box, header, menu, post dates, footer, site icon (favicon).
+- **Layout** tab: the page is five areas — header, left column, main content, right
+ column, footer — each holding modules in any order: blog title, logo, menu, archive
+ (posts by year and month), recent posts, custom HTML, footer text, RSS link, site map.
+ Column widths are percentages; on phones the columns stack under the posts. The
+ **menu** mixes blog pages and outside links.
+- **Design** form: colour schemes, background colour/image, fonts, link style, content box, header, menu, post dates, footer, logo and site icon (favicon).
- Changes are live immediately: save, then refresh the blog tab.
- A **superadmin** creates bloggers, resets passwords, disables or deletes accounts.
- The **root domain is itself a blog**, owned by the superadmin and managed like any other.
diff --git a/cmd/blogspace/seed.go b/cmd/blogspace/seed.go
index 010eff5..7862363 100644
--- a/cmd/blogspace/seed.go
+++ b/cmd/blogspace/seed.go
@@ -81,10 +81,20 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
theme := web.DefaultTheme()
theme.BgImage = bg.ID.String()
theme.BgMode = "tile"
- theme.FooterText = "© Alice — made with Blogspace"
if err := st.UpdateBlogTheme(ctx, blog.ID, theme.JSON()); err != nil {
return err
}
+ // a footer line and a right column, so the layout options have something to show
+ for _, m := range []store.Module{
+ {Area: "footer", Kind: "text", Body: "© Alice — made with Blogspace"},
+ {Area: "right", Kind: "recent", Title: "Recent posts", Count: 5},
+ {Area: "right", Kind: "archive", Title: "Archive"},
+ } {
+ m.BlogID = blog.ID
+ if _, err := st.CreateModule(ctx, &m); err != nil {
+ return err
+ }
+ }
// a second page of posts on the home page, to show pagination
for i := 0; i < 10; i++ {
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 &#8599;</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 &amp; 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 &amp; 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 &#8599;</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">&#9650;</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">&#9660;</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">&#9650;</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">&#9660;</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="&lt;p&gt;Hello!&lt;/p&gt;">{{$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 &#8599;</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> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> &middot; <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p>
+ <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> &middot; <a href="/b/{{.Blog.Subdomain}}/layout">Arrange the layout</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> &middot; <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">&#9650;</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">&#9660;</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}} &#8599;</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">&#9660;</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> &middot; {{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)
+ }
}