diff options
| author | grm <grm@eyesin.space> | 2026-09-13 00:38:24 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-13 00:38:24 +0300 |
| commit | d11e4c9544f544b3a024c7ac705a674b4096cf50 (patch) | |
| tree | a60be98a37b764dfcb655b2738c8a991a248103b /internal/web/layout.go | |
| parent | e974eb6f3feb06c8951f85c1d34161938905f807 (diff) | |
| download | blogspace-d11e4c9544f544b3a024c7ac705a674b4096cf50.tar.gz blogspace-d11e4c9544f544b3a024c7ac705a674b4096cf50.tar.bz2 blogspace-d11e4c9544f544b3a024c7ac705a674b4096cf50.zip | |
Add the Layout tab: five areas with modules, columns and a menu editor
A blog page is now header, left column, main content, right column and
footer, each holding an ordered list of modules (blog title, logo, menu,
archive by year/month, recent posts, custom HTML, footer text, RSS link,
site map). Side columns have percentage widths, can be hidden, and can
keep their space when empty; on phones they stack under the posts. The
menu becomes its own ordered list mixing pages and outside links.
Announcements are placed per column at its top or bottom.
Modules and menu items get tables; the migration derives them from each
blog's old theme (nav position, footer text, show-in-nav pages) so
existing blogs look the same. Custom HTML is deliberately stored and
served as-is (owner's decision; see AGENTS.md for why the blast radius
is the blogger's own origin).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/layout.go')
| -rw-r--r-- | internal/web/layout.go | 222 |
1 files changed, 222 insertions, 0 deletions
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 +} |
