aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/layout.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web/layout.go')
-rw-r--r--internal/web/layout.go222
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
+}