diff options
Diffstat (limited to 'internal/web')
| -rw-r--r-- | internal/web/handlers_blog.go | 51 | ||||
| -rw-r--r-- | internal/web/handlers_design.go | 14 | ||||
| -rw-r--r-- | internal/web/handlers_layout.go | 307 | ||||
| -rw-r--r-- | internal/web/handlers_sections.go | 6 | ||||
| -rw-r--r-- | internal/web/layout.go | 222 | ||||
| -rw-r--r-- | internal/web/routes.go | 13 | ||||
| -rw-r--r-- | internal/web/server.go | 7 | ||||
| -rw-r--r-- | internal/web/static/blog.css | 50 | ||||
| -rw-r--r-- | internal/web/static/dashboard.css | 45 | ||||
| -rw-r--r-- | internal/web/templates.go | 11 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/design.html | 54 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/layout.html | 113 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/module_form.html | 34 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/overview.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/pages.html | 6 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/section_form.html | 14 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/sections.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/layouts/blog.html | 84 | ||||
| -rw-r--r-- | internal/web/templates/partials/dashnav.html | 1 | ||||
| -rw-r--r-- | internal/web/theme.go | 176 | ||||
| -rw-r--r-- | internal/web/web_test.go | 192 |
21 files changed, 1190 insertions, 214 deletions
diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index 9b2fe5e..42ec45b 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -12,42 +12,47 @@ import ( const postsPerPage = 10 -// blogView gathers what every public page needs: theme css, nav, announcements, the blog. +// blogView gathers what every public page needs: theme css, the layout with +// its modules and their data, the menu, announcements, the blog. func (s *Server) blogView(r *http.Request) (map[string]any, error) { blog := currentBlog(r) - pages, err := s.st.ListPages(r.Context(), blog.ID) + ctx := r.Context() + pages, err := s.st.ListPages(ctx, blog.ID) if err != nil { return nil, err } - sections, err := s.st.EnabledSections(r.Context(), blog.ID) + menu, err := s.st.ListMenu(ctx, blog.ID) + if err != nil { + return nil, err + } + mods, err := s.st.ListModules(ctx, blog.ID) + if err != nil { + return nil, err + } + sections, err := s.st.EnabledSections(ctx, blog.ID) if err != nil { return nil, err } theme := ParseTheme(blog.ThemeJSON) - var nav []store.Page - for _, p := range pages { - if p.ShowInNav && (theme.NavShowHome || !p.IsHome) { - nav = append(nav, p) + layout := buildLayout(theme, mods) + v := map[string]any{"theme": theme, "css": theme.CSS(), "layout": layout, "menu": menu, "pages": pages} + // Module data is only fetched when a visible module shows it. + if layout.NeedsArchive() { + index, err := s.st.PublishedPostIndex(ctx, blog.ID) + if err != nil { + return nil, err } + v["archive"] = groupArchive(index) } - above, below, sidebar := splitSections(sections, theme.NavPosition == "left-sidebar") - return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav, "above": above, "below": below, "sidebar": sidebar}, nil -} - -// splitSections groups announcements by placement. The sidebar only exists in -// the left-sidebar layout; elsewhere sidebar announcements go above the content. -func splitSections(secs []store.Section, hasSidebar bool) (above, below, sidebar []store.Section) { - for _, sec := range secs { - switch { - case sec.Placement == "below": - below = append(below, sec) - case sec.Placement == "sidebar" && hasSidebar: - sidebar = append(sidebar, sec) - default: - above = append(above, sec) + if n := layout.MaxRecent(); n > 0 { + recent, err := s.st.RecentPublishedPosts(ctx, blog.ID, n) + if err != nil { + return nil, err } + v["recent"] = recent } - return + v["notices"] = placeNotices(sections, layout) + return v, nil } func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go index 3da7ee3..1e18001 100644 --- a/internal/web/handlers_design.go +++ b/internal/web/handlers_design.go @@ -36,7 +36,7 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { } theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form) // Optional direct uploads from the design form. - for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage, "favicon_file": &theme.Favicon} { + for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage, "logo_file": &theme.Logo, "favicon_file": &theme.Favicon} { img, err := s.readUpload(r, field) if err != nil { images, _ := s.st.ListImages(r.Context(), blog.ID) @@ -71,14 +71,19 @@ func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) { redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Applied the "+p.Name+" scheme. Refresh your blog to see it.") } -// handleDesignReset restores the defaults; images stay in the library. +// handleDesignReset restores the defaults, layout modules included; images +// stay in the library and the menu is left alone. func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, DefaultTheme().JSON()); err != nil { s.serverError(w, err) return } - redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design reset to the defaults.") + if err := s.st.ResetModules(r.Context(), blog.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design and layout reset to the defaults.") } // readUpload stores the file from a multipart field, returning nil if the field is empty. @@ -218,6 +223,9 @@ func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) { if theme.Favicon == id.String() { theme.Favicon, changed = "", true } + if theme.Logo == id.String() { + theme.Logo, changed = "", true + } if changed { if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { s.serverError(w, err) diff --git a/internal/web/handlers_layout.go b/internal/web/handlers_layout.go new file mode 100644 index 0000000..b22c1b5 --- /dev/null +++ b/internal/web/handlers_layout.go @@ -0,0 +1,307 @@ +package web + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gramanas/blogspace/internal/store" +) + +// The Layout tab: which areas of the blog are shown, how wide the columns +// are, what modules each area holds, and the menu. + +const ( + maxModuleBytes = 20_000 + maxRecentCount = 20 + maxMenuLabel = 60 +) + +func (s *Server) layoutURL(r *http.Request) string { + return "/b/" + currentBlog(r).Subdomain + "/layout" +} + +// layoutData is everything layout.html needs; it is rebuilt after a failed form too. +func (s *Server) layoutData(r *http.Request) (map[string]any, error) { + blog := currentBlog(r) + mods, err := s.st.ListModules(r.Context(), blog.ID) + if err != nil { + return nil, err + } + menu, err := s.st.ListMenu(r.Context(), blog.ID) + if err != nil { + return nil, err + } + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + return nil, err + } + byArea := map[string][]store.Module{} + for _, m := range mods { + byArea[m.Area] = append(byArea[m.Area], m) + } + var freePages []store.Page // pages not yet in the menu + for _, p := range pages { + if !p.ShowInNav { + freePages = append(freePages, p) + } + } + return map[string]any{"theme": ParseTheme(blog.ThemeJSON), "areas": byArea, "menu": menu, "freePages": freePages, + "kinds": moduleKinds, "names": moduleNames, "areaNames": areaNames, "areaOrder": []string{"above", "below"}}, nil +} + +func (s *Server) handleLayout(w http.ResponseWriter, r *http.Request) { + data, err := s.layoutData(r) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/layout.html", data) +} + +func (s *Server) handleLayoutWidths(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + theme := LayoutFromForm(ParseTheme(blog.ThemeJSON), r.Form) + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#columns", "Columns saved. Refresh your blog to see them.") +} + +// handleAreaToggle shows or hides a whole area, keeping its modules. +func (s *Server) handleAreaToggle(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + area := r.PathValue("area") + if _, ok := areaNames[area]; !ok || singleModuleAreas[area] { + http.NotFound(w, r) + return + } + theme := ParseTheme(blog.ThemeJSON) + on := !theme.AreaOn(area) + theme.SetAreaOn(area, on) + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + msg := areaNames[area] + " hidden." + if on { + msg = areaNames[area] + " shown again. Refresh your blog to see it." + } + redirectOK(w, r, s.layoutURL(r)+"#"+area, msg) +} + +func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + area, kind := r.PathValue("area"), r.FormValue("kind") + if !allowedKind(area, kind) { + s.plainError(w, http.StatusBadRequest, "That module cannot go in that area.") + return + } + if singleModuleAreas[area] { + mods, err := s.st.ListModules(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + for _, m := range mods { + if m.Area == area { + redirectOK(w, r, s.layoutURL(r)+"#main", "There is already a module there; edit or remove it first.") + return + } + } + } + m := &store.Module{BlogID: blog.ID, Area: area, Kind: kind, Count: 5} + switch kind { // sensible headings so the module reads well before it is edited + case "archive": + m.Title = "Archive" + case "recent": + m.Title = "Recent posts" + case "sitemap": + m.Title = "Pages" + } + m, err := s.st.CreateModule(r.Context(), m) + if err != nil { + s.serverError(w, err) + return + } + if kind == "html" || kind == "text" { + redirectOK(w, r, s.layoutURL(r)+"/modules/"+strconv.FormatInt(m.ID, 10)+"/edit", "Added. Now write what it should show.") + return + } + redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(area), moduleNames[kind]+" added. Refresh your blog to see it.") +} + +// anchorFor is the layout.html card an area is edited in. +func anchorFor(area string) string { + if singleModuleAreas[area] { + return "main" + } + return area +} + +func (s *Server) loadModule(w http.ResponseWriter, r *http.Request) *store.Module { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + m, err := s.st.ModuleByID(r.Context(), currentBlog(r).ID, id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + } else { + s.serverError(w, err) + } + return nil + } + return m +} + +func (s *Server) handleModuleForm(w http.ResponseWriter, r *http.Request) { + m := s.loadModule(w, r) + if m == nil { + return + } + if !moduleHasSettings(m.Kind) { + http.Redirect(w, r, s.layoutURL(r), http.StatusSeeOther) + return + } + s.render(w, r, "dashboard/module_form.html", moduleFormData(m, "")) +} + +func moduleFormData(m *store.Module, errMsg string) map[string]any { + d := map[string]any{"module": m, "name": moduleNames[m.Kind], "area": areaNames[m.Area], "counts": []int{3, 5, 8, 10, 15, 20}} + if errMsg != "" { + d["error"] = errMsg + } + return d +} + +func (s *Server) handleModuleSave(w http.ResponseWriter, r *http.Request) { + m := s.loadModule(w, r) + if m == nil { + return + } + m.Title = strings.TrimSpace(r.FormValue("title")) + m.Body = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") + if n, err := strconv.Atoi(r.FormValue("count")); err == nil { + m.Count = clampInt(n, 1, maxRecentCount) + } + fail := func(msg string) { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", moduleFormData(m, msg)) + } + switch { + case len(m.Title) > 120: + fail("Heading is too long (max 120 characters).") + return + case len(m.Body) > maxModuleBytes: + fail("Content is too long (20 KB max).") + return + } + if err := s.st.UpdateModule(r.Context(), m); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), "Saved. Refresh your blog to see it.") +} + +func (s *Server) handleModuleMove(w http.ResponseWriter, r *http.Request) { + m := s.loadModule(w, r) + if m == nil { + return + } + dir := 1 + if r.FormValue("dir") == "up" { + dir = -1 + } + if err := s.st.MoveModule(r.Context(), m.BlogID, m.ID, dir); err != nil { + s.serverError(w, err) + return + } + http.Redirect(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), http.StatusSeeOther) +} + +// handleModuleDeleteConfirm guards modules with content the blogger typed in; +// the list removes the others directly. +func (s *Server) handleModuleDeleteConfirm(w http.ResponseWriter, r *http.Request) { + m := s.loadModule(w, r) + if m == nil { + return + } + if strings.TrimSpace(m.Body) == "" { + s.deleteModule(w, r, m) + return + } + s.render(w, r, "dashboard/confirm.html", map[string]any{"what": "the \"" + moduleNames[m.Kind] + "\" module and its text", "action": r.URL.Path, "back": s.layoutURL(r)}) +} + +func (s *Server) handleModuleDelete(w http.ResponseWriter, r *http.Request) { + if m := s.loadModule(w, r); m != nil { + s.deleteModule(w, r, m) + } +} + +func (s *Server) deleteModule(w http.ResponseWriter, r *http.Request, m *store.Module) { + if err := s.st.DeleteModule(r.Context(), m.BlogID, m.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), moduleNames[m.Kind]+" removed.") +} + +// ---- menu -------------------------------------------------------------------- + +func (s *Server) handleMenuAddPage(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pageID, _ := strconv.ParseInt(r.FormValue("page"), 10, 64) + if pageID == 0 { + redirectOK(w, r, s.layoutURL(r)+"#menu", "Pick a page first.") + return + } + if err := s.st.AddMenuPage(r.Context(), blog.ID, pageID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#menu", "Page added to the menu. Refresh your blog to see it.") +} + +func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + label, link := strings.TrimSpace(r.FormValue("label")), strings.TrimSpace(r.FormValue("url")) + if link != "" && !strings.HasPrefix(link, "/") && !strings.Contains(link, ":") { + link = "https://" + link // "example.org" is what people type + } + switch { + case label == "" || len(label) > maxMenuLabel: + redirectOK(w, r, s.layoutURL(r)+"#menu", "Give the link a short text (max 60 characters).") + return + case !validLinkURL(link): + redirectOK(w, r, s.layoutURL(r)+"#menu", "That address does not look right. Use a full web address like https://example.org or a path like /about.") + return + } + if err := s.st.AddMenuLink(r.Context(), blog.ID, label, link); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#menu", "Link added to the menu. Refresh your blog to see it.") +} + +func (s *Server) handleMenuMove(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + dir := 1 + if r.FormValue("dir") == "up" { + dir = -1 + } + if err := s.st.MoveMenuItem(r.Context(), currentBlog(r).ID, id, dir); err != nil { + s.serverError(w, err) + return + } + http.Redirect(w, r, s.layoutURL(r)+"#menu", http.StatusSeeOther) +} + +func (s *Server) handleMenuDelete(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err := s.st.DeleteMenuItem(r.Context(), currentBlog(r).ID, id); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, s.layoutURL(r)+"#menu", "Removed from the menu.") +} diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go index 21ea998..f5e2e59 100644 --- a/internal/web/handlers_sections.go +++ b/internal/web/handlers_sections.go @@ -42,7 +42,7 @@ func (s *Server) loadSection(w http.ResponseWriter, r *http.Request) *store.Sect } func (s *Server) handleSectionForm(w http.ResponseWriter, r *http.Request) { - sec := &store.Section{Placement: "above", Style: "note", Enabled: true} + sec := &store.Section{Placement: "main-top", Style: "note", Enabled: true} if r.PathValue("id") != "" { if sec = s.loadSection(w, r); sec == nil { return @@ -53,7 +53,7 @@ func (s *Server) handleSectionForm(w http.ResponseWriter, r *http.Request) { // normalizeSection clamps the enum fields so templates and CSS class names can trust them. func normalizeSection(sec *store.Section) { - sec.Placement = pick(sec.Placement, "above", "below", "sidebar") + sec.Placement = pick(sec.Column(), "main", "left", "right") + "-" + pick(sec.Position(), "top", "bottom") sec.Style = pick(sec.Style, "note", "plain", "warning") } @@ -67,7 +67,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { } sec.Title = strings.TrimSpace(r.FormValue("title")) sec.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") - sec.Placement = r.FormValue("placement") + sec.Placement = r.FormValue("column") + "-" + r.FormValue("position") sec.Style = r.FormValue("style") sec.Enabled = r.FormValue("enabled") == "on" normalizeSection(sec) diff --git a/internal/web/layout.go b/internal/web/layout.go new file mode 100644 index 0000000..521cb63 --- /dev/null +++ b/internal/web/layout.go @@ -0,0 +1,222 @@ +package web + +import ( + "net/url" + "strconv" + "strings" + "time" + + "github.com/gramanas/blogspace/internal/store" +) + +// Modules a blogger can put in each area. Header modules are the big +// blocks; the side columns take the small boxes; the footer its own set. +var moduleKinds = map[string][]string{ + "header": {"title", "logo", "menu"}, + "left": {"menu", "recent", "archive", "logo", "html"}, + "right": {"menu", "recent", "archive", "logo", "html"}, + "above": {"html"}, + "below": {"html"}, + "footer": {"text", "rss", "sitemap", "html"}, +} + +// Friendly names for the dashboard. +var moduleNames = map[string]string{ + "title": "Blog title & tagline", + "logo": "Logo", + "menu": "Menu", + "archive": "Archive (posts by month)", + "recent": "Recent posts", + "html": "Custom HTML", + "rss": "RSS link", + "text": "Footer text", + "sitemap": "Site map", +} + +var areaNames = map[string]string{ + "header": "Header", "left": "Left column", "right": "Right column", + "above": "Before the posts", "below": "After the posts", "footer": "Footer", +} + +// singleModuleAreas hold at most one module (the custom HTML before/after the posts). +var singleModuleAreas = map[string]bool{"above": true, "below": true} + +func allowedKind(area, kind string) bool { + for _, k := range moduleKinds[area] { + if k == kind { + return true + } + } + return false +} + +// moduleHasSettings says whether the module has an edit page. +func moduleHasSettings(kind string) bool { + switch kind { + case "html", "text", "recent", "archive", "sitemap": + return true + } + return false +} + +// moduleSummary is the one-liner the Layout page shows next to a module's name. +func moduleSummary(m store.Module) string { + switch m.Kind { + case "recent": + return strconv.Itoa(m.Count) + " posts" + headingNote(m) + case "html", "text": + body := strings.Join(strings.Fields(m.Body), " ") + if body == "" { + return "empty" + } + if len(body) > 60 { + body = body[:57] + "…" + } + return body + } + return strings.TrimPrefix(headingNote(m), ", ") +} + +func headingNote(m store.Module) string { + if m.Title == "" { + return "" + } + return ", heading “" + m.Title + "”" +} + +// validLinkURL accepts what a menu link may point to: a web address, a mail +// address, or a path on the blog itself. +func validLinkURL(s string) bool { + if s == "" || len(s) > 500 || strings.ContainsAny(s, " \t\n\r\"<>") { + return false + } + if strings.HasPrefix(s, "/") && !strings.HasPrefix(s, "//") { + return true + } + u, err := url.Parse(s) + if err != nil { + return false + } + switch u.Scheme { + case "http", "https": + return u.Host != "" + case "mailto": + return u.Opaque != "" + } + return false +} + +// Layout is the resolved page structure for one render: the modules of +// each area that is switched on, and which side columns are laid out. +type Layout struct { + Header, Left, Right, Above, Below, Footer []store.Module + // A column is laid out when it has something to show — or always, when + // the theme keeps the columns' space. + HasLeft, HasRight bool +} + +// buildLayout groups modules by area, dropping areas the theme has switched off. +func buildLayout(t Theme, mods []store.Module) Layout { + var l Layout + for _, m := range mods { + if !t.AreaOn(m.Area) { + continue + } + switch m.Area { + case "header": + l.Header = append(l.Header, m) + case "left": + l.Left = append(l.Left, m) + case "right": + l.Right = append(l.Right, m) + case "above": + l.Above = append(l.Above, m) + case "below": + l.Below = append(l.Below, m) + case "footer": + l.Footer = append(l.Footer, m) + } + } + l.HasLeft = t.KeepColumns || len(l.Left) > 0 + l.HasRight = t.KeepColumns || len(l.Right) > 0 + return l +} + +func (l Layout) all() []store.Module { + var out []store.Module + for _, a := range [][]store.Module{l.Header, l.Left, l.Right, l.Above, l.Below, l.Footer} { + out = append(out, a...) + } + return out +} + +// NeedsArchive says whether any visible module lists the archive. +func (l Layout) NeedsArchive() bool { + for _, m := range l.all() { + if m.Kind == "archive" { + return true + } + } + return false +} + +// MaxRecent is the longest "recent posts" list any visible module wants (0 = none). +func (l Layout) MaxRecent() int { + n := 0 + for _, m := range l.all() { + if m.Kind == "recent" && m.Count > n { + n = m.Count + } + } + return n +} + +// ---- archive --------------------------------------------------------------- + +type ArchiveMonth struct { + Name string // "September" + Posts []store.PostRef +} + +type ArchiveYear struct { + Year int + Months []ArchiveMonth + Count int +} + +// groupArchive turns the newest-first post index into years → months → posts. +func groupArchive(posts []store.PostRef) []ArchiveYear { + var years []ArchiveYear + var lastY, lastM int + for _, p := range posts { + y, m := p.CreatedAt.Year(), int(p.CreatedAt.Month()) + if len(years) == 0 || y != lastY { + years = append(years, ArchiveYear{Year: y}) + lastM = 0 + } + yr := &years[len(years)-1] + if len(yr.Months) == 0 || m != lastM { + yr.Months = append(yr.Months, ArchiveMonth{Name: time.Month(m).String()}) + } + mo := &yr.Months[len(yr.Months)-1] + mo.Posts = append(mo.Posts, p) + yr.Count++ + lastY, lastM = y, m + } + return years +} + +// placeNotices groups announcements by placement ("left-top", "main-bottom", …). +// Ones meant for a side column that is not laid out go to the main column instead. +func placeNotices(secs []store.Section, l Layout) map[string][]store.Section { + out := map[string][]store.Section{} + for _, sec := range secs { + col := sec.Column() + if (col == "left" && !l.HasLeft) || (col == "right" && !l.HasRight) { + col = "main" + } + key := col + "-" + sec.Position() + out[key] = append(out[key], sec) + } + return out +} diff --git a/internal/web/routes.go b/internal/web/routes.go index 84b19a6..4b3d14f 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -46,6 +46,19 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /b/{sub}/announcements/{id}/delete", s.withBlog(s.handleSectionDelete)) m.HandleFunc("POST /b/{sub}/announcements/{id}/move", s.withBlog(s.handleSectionMove)) m.HandleFunc("POST /b/{sub}/announcements/{id}/toggle", s.withBlog(s.handleSectionToggle)) + m.HandleFunc("GET /b/{sub}/layout", s.withBlog(s.handleLayout)) + m.HandleFunc("POST /b/{sub}/layout/widths", s.withBlog(s.handleLayoutWidths)) + m.HandleFunc("POST /b/{sub}/layout/areas/{area}/toggle", s.withBlog(s.handleAreaToggle)) + m.HandleFunc("POST /b/{sub}/layout/areas/{area}/add", s.withBlog(s.handleModuleAdd)) + m.HandleFunc("GET /b/{sub}/layout/modules/{id}/edit", s.withBlog(s.handleModuleForm)) + m.HandleFunc("POST /b/{sub}/layout/modules/{id}/edit", s.withBlog(s.handleModuleSave)) + m.HandleFunc("POST /b/{sub}/layout/modules/{id}/move", s.withBlog(s.handleModuleMove)) + m.HandleFunc("GET /b/{sub}/layout/modules/{id}/delete", s.withBlog(s.handleModuleDeleteConfirm)) + m.HandleFunc("POST /b/{sub}/layout/modules/{id}/delete", s.withBlog(s.handleModuleDelete)) + m.HandleFunc("POST /b/{sub}/layout/menu/add-page", s.withBlog(s.handleMenuAddPage)) + m.HandleFunc("POST /b/{sub}/layout/menu/add-link", s.withBlog(s.handleMenuAddLink)) + m.HandleFunc("POST /b/{sub}/layout/menu/{id}/move", s.withBlog(s.handleMenuMove)) + m.HandleFunc("POST /b/{sub}/layout/menu/{id}/delete", s.withBlog(s.handleMenuDelete)) m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm)) m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign)) m.HandleFunc("POST /b/{sub}/design/preset", s.withBlog(s.handleDesignPreset)) diff --git a/internal/web/server.go b/internal/web/server.go index cbeac5d..483663e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -254,9 +254,14 @@ func htmlEscape(s string) string { // redirectOK redirects with a flash message shown by the layout. func redirectOK(w http.ResponseWriter, r *http.Request, to, msg string) { + to, frag, _ := strings.Cut(to, "#") // keep a #anchor after the query sep := "?" if strings.Contains(to, "?") { sep = "&" } - http.Redirect(w, r, to+sep+"ok="+urlQuery(msg), http.StatusSeeOther) + to += sep + "ok=" + urlQuery(msg) + if frag != "" { + to += "#" + frag + } + http.Redirect(w, r, to, http.StatusSeeOther) } diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css index 778c169..c785ca6 100644 --- a/internal/web/static/blog.css +++ b/internal/web/static/blog.css @@ -5,6 +5,8 @@ img { max-width: 100%; height: auto; } .site-header { padding: 0; } .header-image img { display: block; width: 100%; max-height: 320px; object-fit: cover; } .header-text { padding: 1.4em 1em; } +.header-logo { padding: 1em; } +.header-logo img.logo { display: inline-block; width: auto; } .site-title { margin: 0; font-size: 2em; line-height: 1.2; } .site-title a { text-decoration: none; } .tagline { margin: 0.3em 0 0; opacity: 0.85; } @@ -12,7 +14,7 @@ img { max-width: 100%; height: auto; } .nav-inner { padding: 0.3em 1em; } .site-nav a { display: inline-block; padding: 0.5em 0.8em; text-decoration: none; font-weight: bold; } .site-nav a.active, .site-nav a:hover { text-decoration: underline; } -.body-wrap { padding: 1.5em 1em; overflow: hidden; } +.body-wrap { padding: 1.5em 1em; } .content { padding: 1.5em 2em; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.15); } .page-title { margin-top: 0; } .intro { padding-bottom: 1em; margin-bottom: 1.5em; border-bottom: 1px solid rgba(0,0,0,0.1); } @@ -43,19 +45,43 @@ img { max-width: 100%; height: auto; } .site-footer { margin-top: 1em; padding: 1.5em 0; text-align: center; } .site-footer p { margin: 0.3em 0; } .site-footer .small { font-size: 0.85em; opacity: 0.8; } +.site-footer .mod { background: none; box-shadow: none; padding: 0; margin: 0 0 0.8em; } +.site-footer .mod-title { font-size: 0.95em; margin: 0 0 0.3em; } +.site-footer ul { list-style: none; margin: 0; padding: 0; } +.site-footer .mod-sitemap li { display: inline-block; margin: 0 0.6em; } +.custom-html { margin: 0 0 1.5em; } +.custom-html-below { margin: 1.5em 0 0; } -/* left sidebar layout: float the menu beside the content */ -.nav-left-sidebar .body-wrap .site-nav { float: left; width: 180px; border: 0; border-radius: 6px; padding: 0.5em 0; } -.nav-left-sidebar .body-wrap .site-nav a { display: block; padding: 0.4em 1em; } -.nav-left-sidebar .body-wrap .site-nav .nav-inner { padding: 0; text-align: left; } /* alignment is a horizontal-menu option */ -.nav-left-sidebar .body-wrap .content { margin-left: 200px; } -.nav-left-sidebar .body-wrap .sidebar-notices { float: left; clear: left; width: 180px; margin-top: 1em; } -.nav-left-sidebar .body-wrap .sidebar-notices .notice { font-size: 0.9em; padding: 0.6em 0.8em; } +/* columns: main first in the HTML so it comes first on phones; on wide screens + flexbox puts the left column before it. Widths come from the theme CSS. + Browsers without flexbox see the columns stacked, which still reads fine. */ +.cols { display: flex; flex-wrap: wrap; align-items: flex-start; } +body.has-left .wrap, body.has-right .wrap { max-width: none; } /* side columns: use the whole page */ +.col { width: 100%; } +.col-left { order: -1; padding-right: 1.5em; } +.col-right { padding-left: 1.5em; } +/* side-column and footer modules */ +.mod { margin: 0 0 1.5em; padding: 1em 1.2em; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.15); font-size: 0.95em; } +.mod > :last-child { margin-bottom: 0; } +.mod-title { margin: 0 0 0.6em; font-size: 1em; text-transform: uppercase; letter-spacing: 0.05em; } +.mod ul { margin: 0; padding-left: 1.2em; } +.mod li { margin: 0.25em 0; } +.mod-logo { text-align: center; } +.mod-logo img.logo { width: auto; } +.col .site-nav { border: 0; padding: 0.5em 0; } +.col .site-nav .mod-title { padding: 0 1em; } +.col .site-nav .nav-inner { padding: 0; } +.col .site-nav a { display: block; padding: 0.4em 1em; } +.mod-archive details { margin: 0.2em 0; } +.mod-archive details details { margin-left: 1em; } +.mod-archive summary { cursor: pointer; padding: 0.15em 0; } +.mod-archive ul { margin: 0.2em 0 0.4em; } +.col .notice { font-size: 0.9em; padding: 0.6em 0.8em; } @media (max-width: 700px) { .content { padding: 1em; } .site-title { font-size: 1.5em; } - .nav-left-sidebar .body-wrap .site-nav { float: none; width: auto; margin-bottom: 1em; } - .nav-left-sidebar .body-wrap .site-nav a { display: inline-block; } - .nav-left-sidebar .body-wrap .content { margin-left: 0; } - .nav-left-sidebar .body-wrap .sidebar-notices { float: none; width: auto; margin: 0 0 1em; } + .cols { display: block; } + .col-left, .col-right { padding: 0; margin-top: 1.5em; } + .col-left { order: 0; } + .col-empty { display: none; } /* a reserved-but-empty column has nothing to stack */ } diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index 4ea0d43..7a66ba5 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -59,7 +59,7 @@ p { margin: 0.6em 0; } label { display: block; margin: 0.8em 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; } label .muted { text-transform: none; letter-spacing: 0; } label code { text-transform: none; letter-spacing: 0; } -input[type=text], input[type=password], input:not([type]), input[type=file], select, textarea { +input[type=text], input[type=password], input[type=number], input:not([type]), input[type=file], select, textarea { width: 100%; max-width: 100%; padding: 0.5em 0.6em; margin-top: 0.25em; border: 2px solid #1d1a17; font: inherit; font-family: -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; text-transform: none; letter-spacing: 0; background: #fffdf8; color: #1d1a17; } input:focus, select:focus, textarea:focus, button:focus, .btn:focus { outline: 2px solid #c8541a; outline-offset: 1px; } input[type=color] { width: 4em; height: 2.3em; padding: 0.1em; margin-top: 0.25em; border: 2px solid #1d1a17; background: #fffdf8; } @@ -145,19 +145,32 @@ details.help[open] summary { margin-bottom: 0.5em; } .presets .preset:hover { background: #ebe5d6; color: #1d1a17; } .presets .sw { display: inline-block; width: 22%; height: 2.2em; margin: 0 1%; border: 1px solid rgba(0,0,0,0.25); vertical-align: middle; } .presets .pname { display: block; margin-top: 0.4em; } -/* menu position tiles + options that only apply to one layout; the show/hide is - pure CSS (:checked ~), and browsers without it simply show everything */ -.navpos { overflow: hidden; } -.navpos > input[type=radio] { position: absolute; opacity: 0; width: 1px; height: 1px; } -.navpos .tile { float: left; width: 8.5em; margin: 0.3em 0.9em 0.9em 0; padding: 0.5em 0.4em 0.4em; border: 2px solid #ddd6c7; background: #fffdf8; text-align: center; cursor: pointer; font-size: 0.75em; } -.navpos .tile svg { display: block; width: 80px; height: 56px; margin: 0 auto 0.4em; border: 1px solid #ddd6c7; } -.navpos .tile span { display: block; } -.navpos .tile:hover { border-color: #1d1a17; } -.navpos > input:checked + .tile { border-color: #c8541a; box-shadow: 3px 3px 0 #c8541a; } -.navpos > input:focus + .tile { outline: 2px solid #c8541a; outline-offset: 1px; } -.navpos .only-horizontal, .navpos .only-sidebar { clear: both; } -#np-side:checked ~ .only-horizontal { display: none; } -#np-below:checked ~ .only-sidebar, #np-top:checked ~ .only-sidebar { display: none; } +/* layout page: the column diagram, area cards with their module tables */ +.lay { margin: 0 0 1em; border: 2px solid #1d1a17; background: #f4efe4; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; text-align: center; } +.lay-row { padding: 0.5em; background: #1d1a17; color: #f4efe4; } +.lay-cols { overflow: hidden; padding: 0.5em 0; } +.lay-col { float: left; padding: 1.6em 0.3em; border: 1px dashed #1d1a17; background: #fffdf8; } +.lay-col.lay-main { background: #ebe5d6; } +.lay .off { opacity: 0.4; text-decoration: line-through; } +.lay-col.off { text-decoration: none; } +.lay-col.empty { background: transparent; } +.row input[type=number] { width: 6em; } +.cardhead { overflow: hidden; } +.cardhead h2 { float: left; margin-right: 1em; } +.cardhead .switch { float: right; margin-top: -0.2em; } +.card.area-off { border-style: dashed; box-shadow: none; } +.card.area-off .modules, .card.area-off .addmod { opacity: 0.6; } +table.modules { margin: 0.5em 0 1em; } +table.modules td.order, table.modules td.actions { width: 1%; } +table.modules th { width: 10em; } +.addmod { border-top: 2px dashed #ddd6c7; padding-top: 0.6em; } +.addmod label, .add label { display: inline-block; margin: 0 0.8em 0.4em 0; min-width: 14em; } +.addmod select, .add select, .add input { width: auto; min-width: 14em; } +.addmod button, .add button { vertical-align: bottom; margin-bottom: 0.4em; } +.addrow { margin-top: 1em; border-top: 2px dashed #ddd6c7; padding-top: 0.6em; } +.addrow .add { float: left; width: 48%; margin-right: 4%; } +.addrow .add + .add { margin-right: 0; } +textarea.mono { font-size: 0.9em; } .hoverpick { margin: 0.8em 0; } .hoverpick > input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; } .hoverpick > label.check { display: inline; margin: 0; } @@ -174,7 +187,9 @@ details.reset { margin-top: 1.6em; } .cols .card, .row label, .thumb { float: none; width: auto; margin-right: 4px; } .imagepick .pick { width: 46%; margin-right: 4%; } .presets .preset { width: 46%; margin-right: 4%; } - .navpos .tile { width: 30%; margin-right: 3%; } + .addrow .add { float: none; width: auto; margin-right: 0; } + .cardhead h2, .cardhead .switch { float: none; margin-top: 0; } + .cardhead .switch { display: block; margin-bottom: 0.6em; } .cols .card + .card { margin-right: 4px; } .topbar .links { float: none; display: block; margin-top: 0.3em; } .topbar .links a, .topbar .links form { margin-left: 0; margin-right: 1em; } diff --git a/internal/web/templates.go b/internal/web/templates.go index 1e17654..e7e35f5 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -98,7 +98,18 @@ var funcs = template.FuncMap{ } return *p }, + "deref64": func(p *int64) int64 { + if p == nil { + return 0 + } + return *p + }, + // split turns a plain-text field into lines: {{range split .Body "\n"}}<p>{{.}}</p>{{end}} + "split": strings.Split, "lower": strings.ToLower, + // layout modules: one-line description and whether the kind has an edit page + "modsummary": moduleSummary, + "modsettings": moduleHasSettings, // hasPrefix marks the current dashboard tab: {{if hasPrefix .Path "/b/x/posts"}} "hasPrefix": strings.HasPrefix, // dict builds a map for passing several values to a partial: {{template "x" (dict "a" 1 "b" 2)}} diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html index af482fe..342cabe 100644 --- a/internal/web/templates/dashboard/design.html +++ b/internal/web/templates/dashboard/design.html @@ -5,7 +5,8 @@ <h1>Design</h1> <p class="lead muted">Changes are live as soon as you save; refresh <a href="{{.BlogURL}}" target="_blank">your blog ↗</a> to see them.</p> </div> -<p class="jumps"><a href="#schemes">Schemes</a><a href="#background">Background</a><a href="#text">Text</a><a href="#header">Header</a><a href="#menu">Menu</a><a href="#posts">Posts</a><a href="#footer">Footer</a><a href="#icon">Icon</a></p> +<p class="jumps"><a href="#schemes">Schemes</a><a href="#background">Background</a><a href="#text">Text</a><a href="#header">Header</a><a href="#menu">Menu</a><a href="#posts">Posts</a><a href="#footer">Footer</a><a href="#logo">Logo & icon</a></p> +<p class="muted small">What appears where — header, columns, footer and their modules — is arranged on the <a href="/b/{{.Blog.Subdomain}}/layout">Layout</a> tab.</p> <div class="card" id="schemes"> <h2>Colour schemes</h2> @@ -81,12 +82,13 @@ <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>Card (shadow, rounded corners)</option> <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>Flat</option> </select></label> - <label>Padding<br><select name="content_padding"> + <label>Padding <span class="muted">(inside the box)</span><br><select name="content_padding"> <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>Compact</option> <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>Normal</option> <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>Roomy</option> </select></label> </div> + <p class="muted small">Width only applies while there are no side columns; with a left or right column the blog uses the whole page.</p> </div> <div class="card" id="header"> @@ -104,43 +106,24 @@ <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>Tall</option> </select></label> </div> - <label class="check"><input type="checkbox" name="header_show_title"{{if $t.HeaderShowTitle}} checked{{end}}> Show blog title and tagline in the header</label> + <p class="muted small">The title, logo and menu are header modules — add, remove and order them on the <a href="/b/{{.Blog.Subdomain}}/layout#header">Layout</a> tab. The banner below is shown across the top of the header whenever one is chosen.</p> {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "images" .Data.images "label" "Header image (banner)")}} </div> <div class="card" id="menu"> <h2>Menu</h2> - <p class="muted small">The menu lists the pages you marked "show in menu". Pick where it goes; the options underneath follow your choice.</p> - <div class="navpos"> - <input type="radio" name="nav_position" value="below-header" id="np-below"{{if eq $t.NavPosition "below-header"}} checked{{end}}> - <label for="np-below" class="tile"><svg viewBox="0 0 80 56" width="80" height="56" aria-hidden="true"><rect x="0" y="0" width="80" height="56" fill="#f4efe4"/><rect x="0" y="0" width="80" height="14" fill="#1d1a17"/><rect x="0" y="16" width="80" height="6" fill="#c8541a"/><rect x="10" y="28" width="60" height="24" fill="#fffdf8" stroke="#1d1a17"/></svg><span>Below the header</span></label> - <input type="radio" name="nav_position" value="top-bar" id="np-top"{{if eq $t.NavPosition "top-bar"}} checked{{end}}> - <label for="np-top" class="tile"><svg viewBox="0 0 80 56" width="80" height="56" aria-hidden="true"><rect x="0" y="0" width="80" height="56" fill="#f4efe4"/><rect x="0" y="0" width="80" height="6" fill="#c8541a"/><rect x="0" y="8" width="80" height="14" fill="#1d1a17"/><rect x="10" y="28" width="60" height="24" fill="#fffdf8" stroke="#1d1a17"/></svg><span>Bar at the very top</span></label> - <input type="radio" name="nav_position" value="left-sidebar" id="np-side"{{if eq $t.NavPosition "left-sidebar"}} checked{{end}}> - <label for="np-side" class="tile"><svg viewBox="0 0 80 56" width="80" height="56" aria-hidden="true"><rect x="0" y="0" width="80" height="56" fill="#f4efe4"/><rect x="0" y="0" width="80" height="14" fill="#1d1a17"/><rect x="8" y="20" width="18" height="32" fill="#c8541a"/><rect x="30" y="20" width="42" height="32" fill="#fffdf8" stroke="#1d1a17"/></svg><span>Sidebar on the left</span></label> - <div class="row only-horizontal"> - <label>Alignment<br><select name="nav_align"> - <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>Left</option> - <option value="center"{{if eq $t.NavAlign "center"}} selected{{end}}>Centred</option> - <option value="right"{{if eq $t.NavAlign "right"}} selected{{end}}>Right</option> - </select></label> - </div> - <div class="row only-sidebar"> - <label>Sidebar width<br><select name="nav_sidebar_width"> - <option value="narrow"{{if eq $t.NavSidebarWidth "narrow"}} selected{{end}}>Narrow</option> - <option value="normal"{{if eq $t.NavSidebarWidth "normal"}} selected{{end}}>Normal</option> - <option value="wide"{{if eq $t.NavSidebarWidth "wide"}} selected{{end}}>Wide</option> - </select></label> - </div> - <p class="muted small only-sidebar">Links stack under each other; announcements placed "in the sidebar" appear beneath them. On phones the sidebar turns into a row of links above the content.</p> - </div> + <p class="muted small">Which links the menu shows, and where it sits, is set on the <a href="/b/{{.Blog.Subdomain}}/layout#menu">Layout</a> tab. Here you choose how it looks.</p> <div class="row"> + <label>Alignment <span class="muted">(menu in the header)</span><br><select name="nav_align"> + <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>Left</option> + <option value="center"{{if eq $t.NavAlign "center"}} selected{{end}}>Centred</option> + <option value="right"{{if eq $t.NavAlign "right"}} selected{{end}}>Right</option> + </select></label> <label>Link style<br><select name="nav_style"> <option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>Normal</option> <option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>Uppercase, spaced</option> </select></label> </div> - <label class="check"><input type="checkbox" name="nav_show_home"{{if $t.NavShowHome}} checked{{end}}> Include the home page in the menu</label> <h3>Menu colours</h3> <div class="row"> <label>Background<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label> @@ -167,16 +150,23 @@ <div class="card" id="footer"> <h2>Footer</h2> - <label>Footer text<br><input name="footer_text" value="{{$t.FooterText}}" maxlength="2000" placeholder="© 2026 Me"></label> + <p class="muted small">The footer text, RSS link and other footer modules live on the <a href="/b/{{.Blog.Subdomain}}/layout#footer">Layout</a> tab.</p> <div class="row"> <label>Background<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label> <label>Text colour<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label> </div> - <label class="check"><input type="checkbox" name="footer_show_rss"{{if $t.FooterShowRSS}} checked{{end}}> Show the RSS link in the footer</label> </div> -<div class="card" id="icon"> - <h2>Site icon</h2> +<div class="card" id="logo"> + <h2>Logo & site icon</h2> + <div class="row"> + <label>Logo size<br><select name="logo_size"> + <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>Small (48 px tall)</option> + <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>Medium (80 px)</option> + <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>Large (140 px)</option> + </select></label> + </div> + {{template "imagepick" (dict "name" "logo" "current" $t.Logo "images" .Data.images "label" "Logo" "hint" "Shown wherever you place a Logo module (Layout tab); it links to your front page.")}} {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "images" .Data.images "label" "Favicon" "hint" "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon." "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}} </div> diff --git a/internal/web/templates/dashboard/layout.html b/internal/web/templates/dashboard/layout.html new file mode 100644 index 0000000..7691b1e --- /dev/null +++ b/internal/web/templates/dashboard/layout.html @@ -0,0 +1,113 @@ +{{define "title"}}Layout · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$t := .Data.theme}} +{{$u := print "/b/" .Blog.Subdomain "/layout"}} +<div class="pagehead"> + <h1>Layout</h1> + <p class="lead muted">Decide what goes where on your blog. Every change here is saved straight away; refresh <a href="{{.BlogURL}}" target="_blank">your blog ↗</a> to see it. Colours and fonts are on the <a href="/b/{{.Blog.Subdomain}}/design">Design</a> tab.</p> +</div> +<p class="jumps"><a href="#columns">Columns</a><a href="#header">Header</a><a href="#left">Left column</a><a href="#main">Main content</a><a href="#right">Right column</a><a href="#footer">Footer</a><a href="#menu">Menu</a></p> + +<div class="card" id="columns"> + <h2>Columns</h2> + <div class="lay"> + <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">Header{{if not $t.HeaderOn}} (hidden){{end}}</div> + <div class="lay-cols"> + <div class="lay-col lay-side{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.areas "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">Left {{$t.LeftWidth}}%</div> + <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">Main content {{$t.MainWidth}}%</div> + <div class="lay-col lay-side{{if not $t.RightOn}} off{{end}}{{if not (index .Data.areas "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">Right {{$t.RightWidth}}%</div> + </div> + <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">Footer{{if not $t.FooterOn}} (hidden){{end}}</div> + </div> + <form method="post" action="{{$u}}/widths"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <div class="row"> + <label>Left column width<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label> + <label>Right column width<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label> + </div> + <p class="muted small">Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; as soon as one does, the blog spreads over the whole page. On phones the columns stack: main content first, then the left and right columns, then the footer.</p> + <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> Always keep the columns' space, even when a column is empty or hidden <span class="muted">(the main content then has the same width on every page)</span></label> + <p class="actionrow"><button type="submit">Save columns</button></p> + </form> +</div> + +{{template "area" (dict "area" "header" "root" . "hint" "The top of every page. Stack the title, a logo and the menu in the order you like. A banner image can be chosen on the Design tab.")}} +{{template "area" (dict "area" "left" "root" . "hint" "A narrow column beside your posts. Good for the menu, an archive or recent posts.")}} + +<div class="card" id="main"> + <div class="cardhead"><h2>Main content</h2></div> + <p class="muted small">Your posts and pages always show here. You can add your own HTML before and after them — an intro box, a newsletter form, a badge…</p> + <table class="modules"> + {{range $area := .Data.areaOrder}}{{$mods := index $.Data.areas $area}} + <tr><th>{{index $.Data.areaNames $area}}</th> + {{if $mods}}{{$m := index $mods 0}} + <td>{{index $.Data.names $m.Kind}}{{with modsummary $m}} <span class="muted">— {{.}}</span>{{end}}</td> + <td class="nowrap actions"><a href="{{$u}}/modules/{{$m.ID}}/edit">edit</a> <a class="danger" href="{{$u}}/modules/{{$m.ID}}/delete">remove</a></td> + {{else}} + <td class="muted">Nothing extra.</td> + <td class="nowrap actions"><form method="post" action="{{$u}}/areas/{{$area}}/add" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="kind" value="html"><button class="mini">+ Add custom HTML</button></form></td> + {{end}} + </tr>{{end}} + </table> +</div> + +{{template "area" (dict "area" "right" "root" . "hint" "A narrow column on the other side of your posts. Same choices as the left one.")}} +{{template "area" (dict "area" "footer" "root" . "hint" "The bottom of every page: a copyright line, the RSS link, a list of your pages.")}} + +<div class="card" id="menu"> + <h2>Menu</h2> + <p class="muted small">The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking "Show in menu" on a page does the same as adding it here.</p> + {{if .Data.menu}}<table class="modules"> + {{range .Data.menu}}<tr> + <td class="nowrap order"> + <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> + <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + </td> + <td>{{.Text}} {{if .PageID}}<span class="tag">page</span>{{else}}<span class="tag">link</span> <span class="muted small">{{.URL}}</span>{{end}}</td> + <td class="nowrap actions"><form method="post" action="{{$u}}/menu/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">Remove</button></form></td> + </tr>{{end}} + </table>{{else}}<p class="muted">The menu is empty.</p>{{end}} + <div class="cols addrow"> + <form method="post" action="{{$u}}/menu/add-page" class="add"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <h3>Add a page</h3> + {{if .Data.freePages}}<label>Page<br><select name="page">{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label> + <button type="submit" class="secondary">Add page</button> + {{else}}<p class="muted small">Every page is already in the menu.</p>{{end}} + </form> + <form method="post" action="{{$u}}/menu/add-link" class="add"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <h3>Add a link</h3> + <label>Text<br><input name="label" maxlength="60" placeholder="My photos"></label> + <label>Address<br><input name="url" maxlength="500" placeholder="https://example.org"></label> + <button type="submit" class="secondary">Add link</button> + </form> + </div> +</div> +{{end}} + +{{/* One area card: on/off switch, its modules in order, and an "add" form. */}} +{{define "area"}}{{$r := .root}}{{$area := .area}}{{$u := print "/b/" $r.Blog.Subdomain "/layout"}}{{$on := $r.Data.theme.AreaOn $area}}{{$mods := index $r.Data.areas $area}} +<div class="card{{if not $on}} area-off{{end}}" id="{{$area}}"> + <div class="cardhead"> + <h2>{{index $r.Data.areaNames $area}}{{if not $on}} <span class="tag off">hidden</span>{{end}}</h2> + <form method="post" action="{{$u}}/areas/{{$area}}/toggle" class="inline switch"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><button class="mini">{{if $on}}Hide this area{{else}}Show this area{{end}}</button></form> + </div> + <p class="muted small">{{.hint}}</p> + {{if $mods}}<table class="modules"> + {{range $mods}}<tr> + <td class="nowrap order"> + <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> + <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + </td> + <td>{{index $r.Data.names .Kind}}{{with modsummary .}} <span class="muted">— {{.}}</span>{{end}} + {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}<span class="tag off">no logo chosen</span> <span class="muted small">pick one on the <a href="/b/{{$r.Blog.Subdomain}}/design#logo">Design</a> tab</span>{{end}}</td> + <td class="nowrap actions">{{if modsettings .Kind}}<a href="{{$u}}/modules/{{.ID}}/edit">edit</a> {{end}}<a class="danger" href="{{$u}}/modules/{{.ID}}/delete">remove</a></td> + </tr>{{end}} + </table>{{else}}<p class="muted">Nothing here yet{{if ne $area "header"}} — this area is not shown until you add a module{{end}}.</p>{{end}} + <form method="post" action="{{$u}}/areas/{{$area}}/add" class="addmod"> + <input type="hidden" name="_csrf" value="{{$r.CSRF}}"> + <label>Add a module<br><select name="kind">{{range index $r.Data.kinds $area}}<option value="{{.}}">{{index $r.Data.names .}}</option>{{end}}</select></label> + <button type="submit" class="secondary">Add</button> + </form> +</div>{{end}} diff --git a/internal/web/templates/dashboard/module_form.html b/internal/web/templates/dashboard/module_form.html new file mode 100644 index 0000000..b63b405 --- /dev/null +++ b/internal/web/templates/dashboard/module_form.html @@ -0,0 +1,34 @@ +{{define "title"}}{{.Data.name}} · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$m := .Data.module}} +<div class="card"> + <h1>{{.Data.name}} <span class="muted small">in the {{lower .Data.area}}</span></h1> + <form method="post"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + {{if or (eq $m.Kind "recent") (eq $m.Kind "archive") (eq $m.Kind "sitemap") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}} + <label>Heading <span class="muted">(optional)</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}Recent posts{{else if eq $m.Kind "archive"}}Archive{{else if eq $m.Kind "sitemap"}}Pages{{end}}"></label> + {{end}} + {{if eq $m.Kind "recent"}} + <div class="row"><label>How many posts<br><select name="count">{{range $n := .Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> + <p class="muted small">The newest published posts from every page of your blog, newest first.</p> + {{else if eq $m.Kind "archive"}} + <p class="muted small">Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open.</p> + {{else if eq $m.Kind "sitemap"}} + <p class="muted small">Links to every page of your blog.</p> + {{else if eq $m.Kind "html"}} + <label for="mod-body">HTML</label> + <textarea id="mod-body" name="body" rows="14" class="mono" spellcheck="false" placeholder="<p>Hello!</p>">{{$m.Body}}</textarea> + <p class="muted small">Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most.</p> + {{else if eq $m.Kind "text"}} + <label for="mod-body">Text</label> + <textarea id="mod-body" name="body" rows="4" placeholder="© 2026 Me">{{$m.Body}}</textarea> + <p class="muted small">Plain text; each line becomes its own paragraph.</p> + {{end}} + <p class="actionrow"> + <button type="submit">Save</button> + <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog ↗</a> + <a href="/b/{{.Blog.Subdomain}}/layout#{{if or (eq $m.Area "above") (eq $m.Area "below")}}main{{else}}{{$m.Area}}{{end}}">Back to layout</a> + </p> + </form> +</div> +{{end}} diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html index fbe49f9..acd539d 100644 --- a/internal/web/templates/dashboard/overview.html +++ b/internal/web/templates/dashboard/overview.html @@ -7,7 +7,7 @@ <div class="card"> <h2>Quick actions</h2> <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">Write a new post</a></p> - <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> · <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> · <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p> + <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> · <a href="/b/{{.Blog.Subdomain}}/layout">Arrange the layout</a> · <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> · <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p> </div> <div class="card"> <h2>Pages</h2> diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html index 5e9aca5..eeb09c5 100644 --- a/internal/web/templates/dashboard/pages.html +++ b/internal/web/templates/dashboard/pages.html @@ -3,18 +3,18 @@ <div class="pagehead"> <h1>Pages</h1> <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ New page</a></div> - <p class="lead muted">Pages appear in your blog's menu in this order. Each page holds its own list of posts.</p> + <p class="lead muted">Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the <a href="/b/{{.Blog.Subdomain}}/layout#menu">Layout</a> tab.</p> </div> <div class="card"> <table> - <tr><th>Menu order</th><th>Title</th><th>Address</th><th>Posts</th><th></th></tr> + <tr><th>Order</th><th>Title</th><th>Address</th><th>Posts</th><th></th></tr> {{range .Data.pages}}<tr> <td class="nowrap"> <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> </td> <td><a href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/edit">{{.Title}}</a> - {{if .IsHome}}<span class="tag">home</span>{{end}}{{if not .ShowInNav}}<span class="tag">hidden</span>{{end}}</td> + {{if .IsHome}}<span class="tag">home</span>{{end}}{{if not .ShowInNav}}<span class="tag off">not in menu</span>{{end}}</td> <td><a href="{{$.BlogURL}}{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}" target="_blank">{{if .IsHome}}/{{else}}/{{.Slug}}{{end}} ↗</a></td> <td><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.PostCount}}</a></td> <td class="nowrap"> diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html index 9d4529c..7eeaf7c 100644 --- a/internal/web/templates/dashboard/section_form.html +++ b/internal/web/templates/dashboard/section_form.html @@ -7,10 +7,14 @@ <input type="hidden" name="_csrf" value="{{.CSRF}}"> <label>Title <span class="muted">(optional)</span><br><input name="title" value="{{$s.Title}}" maxlength="120" autofocus placeholder="Next meeting"></label> <div class="row"> - <label>Where<br><select name="placement"> - <option value="above"{{if eq $s.Placement "above"}} selected{{end}}>Above the posts</option> - <option value="below"{{if eq $s.Placement "below"}} selected{{end}}>Below the posts</option> - <option value="sidebar"{{if eq $s.Placement "sidebar"}} selected{{end}}>Sidebar, under the menu</option> + <label>Where<br><select name="column"> + <option value="main"{{if eq $s.Column "main"}} selected{{end}}>Main content (with the posts)</option> + <option value="left"{{if eq $s.Column "left"}} selected{{end}}>Left column</option> + <option value="right"{{if eq $s.Column "right"}} selected{{end}}>Right column</option> + </select></label> + <label>Position<br><select name="position"> + <option value="top"{{if eq $s.Position "top"}} selected{{end}}>At the top</option> + <option value="bottom"{{if eq $s.Position "bottom"}} selected{{end}}>At the bottom</option> </select></label> <label>Look<br><select name="style"> <option value="note"{{if eq $s.Style "note"}} selected{{end}}>Highlighted</option> @@ -18,7 +22,7 @@ <option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>Plain</option> </select></label> </div> - <p class="muted small">The sidebar spot only exists when the menu is a sidebar (see <a href="/b/{{.Blog.Subdomain}}/design">Design</a>); otherwise a sidebar announcement is shown above the posts.</p> + <p class="muted small">"Top" of a side column means above its modules, "bottom" below them. If that column is not shown on your blog (see <a href="/b/{{.Blog.Subdomain}}/layout">Layout</a>) the announcement moves to the main content instead.</p> <label for="ed-body">Text <span class="muted">(Markdown)</span></label> {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> Shown on the blog</label> diff --git a/internal/web/templates/dashboard/sections.html b/internal/web/templates/dashboard/sections.html index d2fade7..1834229 100644 --- a/internal/web/templates/dashboard/sections.html +++ b/internal/web/templates/dashboard/sections.html @@ -16,7 +16,7 @@ <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> </td> <td><a href="{{$u}}/{{.ID}}/edit">{{if .Title}}{{.Title}}{{else}}(untitled){{end}}</a>{{if not .Enabled}}<span class="tag off">hidden</span>{{end}}</td> - <td>{{if eq .Placement "above"}}Above the posts{{else if eq .Placement "below"}}Below the posts{{else}}Sidebar{{end}}</td> + <td>{{if eq .Column "main"}}Main content{{else if eq .Column "left"}}Left column{{else}}Right column{{end}}, {{.Position}}</td> <td>{{if eq .Style "note"}}Highlighted{{else if eq .Style "warning"}}Warning{{else}}Plain{{end}}</td> <td class="nowrap"> <form method="post" action="{{$u}}/{{.ID}}/toggle" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{if .Enabled}}Hide{{else}}Show{{end}}</button></form> diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index 3447e1f..deaaa75 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -11,30 +11,35 @@ {{end}}<link rel="stylesheet" href="/static/blog.css"> <style>{{css .Data.css}}</style> </head> -<body class="nav-{{.Data.theme.NavPosition}}"> -{{if eq .Data.theme.NavPosition "top-bar"}}{{template "blognav" .}}{{end}} -<div class="site-header"> +{{$l := .Data.layout}} +<body class="{{if $l.HasLeft}}has-left {{end}}{{if $l.HasRight}}has-right{{end}}"> +{{if or $l.Header .Data.theme.HeaderImage}}{{if .Data.theme.HeaderOn}}<div class="site-header"> {{if .Data.theme.HeaderImage}}<div class="header-image"><a href="/"><img src="/media/{{.Data.theme.HeaderImage}}" alt=""></a></div>{{end}} - {{if .Data.theme.HeaderShowTitle}}<div class="wrap header-text"> - <h1 class="site-title"><a href="/">{{.Blog.Title}}</a></h1> - {{if .Blog.Tagline}}<p class="tagline">{{.Blog.Tagline}}</p>{{end}} - </div>{{end}} -</div> -{{if eq .Data.theme.NavPosition "below-header"}}{{template "blognav" .}}{{end}} -<div class="wrap body-wrap"> - {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{if .Data.sidebar}}<div class="sidebar-notices">{{template "notices" .Data.sidebar}}</div>{{end}}{{end}} - <div class="content"> - {{template "notices" .Data.above}} + {{range $l.Header}}{{template "module" (dict "m" . "root" $)}}{{end}} +</div>{{end}}{{end}} +<div class="wrap body-wrap cols"> + {{$n := .Data.notices}} + <div class="col col-main"><div class="content"> + {{template "notices" (index $n "main-top")}} + {{range $l.Above}}{{template "module" (dict "m" . "root" $)}}{{end}} {{template "content" .}} - {{template "notices" .Data.below}} - </div> -</div> -<div class="site-footer"> - <div class="wrap footer-inner"> - {{if .Data.theme.FooterText}}<p>{{.Data.theme.FooterText}}</p>{{end}} - <p class="small">{{if .Data.theme.FooterShowRSS}}<a href="/feed.xml">RSS</a> · {{end}}{{.Blog.Title}}</p> - </div> + {{range $l.Below}}{{template "module" (dict "m" . "root" $)}}{{end}} + {{template "notices" (index $n "main-bottom")}} + </div></div> + {{if $l.HasLeft}}<div class="col col-left{{if and (not $l.Left) (not (index $n "left-top")) (not (index $n "left-bottom"))}} col-empty{{end}}"> + {{template "notices" (index $n "left-top")}} + {{range $l.Left}}{{template "module" (dict "m" . "root" $)}}{{end}} + {{template "notices" (index $n "left-bottom")}} + </div>{{end}} + {{if $l.HasRight}}<div class="col col-right{{if and (not $l.Right) (not (index $n "right-top")) (not (index $n "right-bottom"))}} col-empty{{end}}"> + {{template "notices" (index $n "right-top")}} + {{range $l.Right}}{{template "module" (dict "m" . "root" $)}}{{end}} + {{template "notices" (index $n "right-bottom")}} + </div>{{end}} </div> +{{if $l.Footer}}<div class="site-footer"><div class="wrap footer-inner"> + {{range $l.Footer}}{{template "module" (dict "m" . "root" $)}}{{end}} +</div></div>{{end}} </body> </html>{{end}} @@ -44,6 +49,37 @@ </aside> {{end}}{{end}} -{{define "blognav"}}<div class="site-nav"><div class="wrap nav-inner"> -{{range .Data.nav}}<a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}"{{if and $.Data.page (eq $.Data.page.ID .ID)}} class="active"{{end}}>{{.Title}}</a> -{{end}}</div></div>{{end}} +{{/* One layout module. .m is the store.Module, .root the page data; the same + kind renders differently in the header (big blocks) and elsewhere (boxes). */}} +{{define "module"}}{{$m := .m}}{{$r := .root}}{{$t := $r.Data.theme}} +{{- if eq $m.Kind "title"}}<div class="wrap header-text"> + <h1 class="site-title"><a href="/">{{$r.Blog.Title}}</a></h1> + {{if $r.Blog.Tagline}}<p class="tagline">{{$r.Blog.Tagline}}</p>{{end}} +</div> +{{- else if eq $m.Kind "logo"}}{{if $t.Logo}}{{if eq $m.Area "header"}}<div class="wrap header-logo"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div> +{{else}}<div class="mod mod-logo"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{end}}{{end}} +{{- else if eq $m.Kind "menu"}}{{if eq $m.Area "header"}}<div class="site-nav"><div class="wrap nav-inner">{{template "menulinks" $r}}</div></div> +{{else}}<div class="mod site-nav mod-menu">{{template "modtitle" $m}}<div class="nav-inner">{{template "menulinks" $r}}</div></div>{{end}} +{{- else if eq $m.Kind "recent"}}<div class="mod mod-recent">{{template "modtitle" $m}} + <ul>{{range $i, $p := $r.Data.recent}}{{if lt $i $m.Count}}<li><a href="/{{$p.PageSlug}}/{{$p.Slug}}">{{$p.Title}}</a></li>{{end}}{{end}}</ul> +</div> +{{- else if eq $m.Kind "archive"}}<div class="mod mod-archive">{{template "modtitle" $m}} + {{range $i, $y := $r.Data.archive}}<details{{if eq $i 0}} open{{end}}><summary>{{$y.Year}} <span class="muted">({{$y.Count}})</span></summary> + {{range $y.Months}}<details><summary>{{.Name}} <span class="muted">({{len .Posts}})</span></summary> + <ul>{{range .Posts}}<li><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></li>{{end}}</ul> + </details>{{end}} + </details>{{else}}<p class="muted">No posts yet.</p>{{end}} +</div> +{{- else if eq $m.Kind "html"}}{{if not $m.Body}}{{else if or (eq $m.Area "above") (eq $m.Area "below")}}<div class="custom-html custom-html-{{$m.Area}}">{{html $m.Body}}</div> +{{else}}<div class="mod mod-html">{{template "modtitle" $m}}{{html $m.Body}}</div>{{end}} +{{- else if eq $m.Kind "rss"}}<p class="mod mod-rss small"><a href="/feed.xml">RSS feed</a></p> +{{- else if eq $m.Kind "text"}}{{if $m.Body}}<div class="mod mod-text">{{range split $m.Body "\n"}}{{if .}}<p>{{.}}</p>{{end}}{{end}}</div>{{end}} +{{- else if eq $m.Kind "sitemap"}}<div class="mod mod-sitemap">{{template "modtitle" $m}} + <ul>{{range $r.Data.pages}}<li><a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}">{{.Title}}</a></li>{{end}}</ul> +</div> +{{- end}}{{end}} + +{{define "modtitle"}}{{if .Title}}<h3 class="mod-title">{{.Title}}</h3>{{end}}{{end}} + +{{define "menulinks"}}{{range .Data.menu}}<a href="{{.Href}}"{{if and .PageID $.Data.page (eq $.Data.page.ID (deref64 .PageID))}} class="active"{{end}}>{{.Text}}</a> +{{end}}{{end}} diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html index f54cb2d..e46d7c3 100644 --- a/internal/web/templates/partials/dashnav.html +++ b/internal/web/templates/partials/dashnav.html @@ -21,6 +21,7 @@ <a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>Posts</a> <a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>Pages</a> <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>Announcements</a> + <a href="{{$b}}layout"{{if hasPrefix .Path (print $b "layout")}} class="active"{{end}}>Layout</a> <a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>Design</a> <a href="{{$b}}images"{{if hasPrefix .Path (print $b "images")}} class="active"{{end}}>Images</a> <a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>Settings</a> diff --git a/internal/web/theme.go b/internal/web/theme.go index 5ee2398..46067ba 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/url" "regexp" + "strconv" "strings" "text/template" "time" @@ -23,50 +24,69 @@ type Theme struct { LinkColor string `json:"link_color"` LinkHover string `json:"link_hover"` // "" = same as LinkColor LinkUnderline string `json:"link_underline"` // always | hover | never - ContentWidth string `json:"content_width"` // narrow | medium | wide + ContentWidth string `json:"content_width"` // narrow | medium | wide (no side columns) ContentStyle string `json:"content_style"` // card | flat ContentPadding string `json:"content_padding"` // normal | compact | roomy Font string `json:"font"` // sans | serif | mono HeadingFont string `json:"heading_font"` // same | sans | serif | mono FontSize string `json:"font_size"` // small | normal | large - HeaderShowTitle bool `json:"header_show_title"` HeaderImage string `json:"header_image"` HeaderImageHeight string `json:"header_image_height"` // short | medium | tall HeaderAlign string `json:"header_align"` // left | center HeaderBg string `json:"header_bg"` HeaderText string `json:"header_text"` - NavPosition string `json:"nav_position"` // below-header | top-bar | left-sidebar - NavAlign string `json:"nav_align"` // left | center | right (horizontal menus) - NavSidebarWidth string `json:"nav_sidebar_width"` // narrow | normal | wide (sidebar only) - NavStyle string `json:"nav_style"` // normal | uppercase - NavBg string `json:"nav_bg"` - NavText string `json:"nav_text"` - NavHover string `json:"nav_hover"` // "" = same as NavText - NavShowHome bool `json:"nav_show_home"` + NavAlign string `json:"nav_align"` // left | center | right (header menu) + NavStyle string `json:"nav_style"` // normal | uppercase + NavBg string `json:"nav_bg"` + NavText string `json:"nav_text"` + NavHover string `json:"nav_hover"` // "" = same as NavText ShowDates bool `json:"show_dates"` DateFormat string `json:"date_format"` // long | short | iso - FooterText string `json:"footer_text"` - FooterBg string `json:"footer_bg"` - FooterColor string `json:"footer_color"` - FooterShowRSS bool `json:"footer_show_rss"` - - Favicon string `json:"favicon"` // image uuid or "" for the Blogspace default + FooterBg string `json:"footer_bg"` + FooterColor string `json:"footer_color"` + + Logo string `json:"logo"` // image uuid or "" + LogoSize string `json:"logo_size"` // small | medium | large + Favicon string `json:"favicon"` // image uuid or "" for the Blogspace default + + // Layout: which areas are shown and how wide the side columns are. What + // each area contains lives in the modules table (store.Module). + HeaderOn bool `json:"header_on"` + LeftOn bool `json:"left_on"` + RightOn bool `json:"right_on"` + FooterOn bool `json:"footer_on"` + LeftWidth int `json:"left_width"` // percent of the page + RightWidth int `json:"right_width"` // percent of the page + // KeepColumns lays the side columns out even when empty or hidden, so + // the main content keeps the same width on every page. + KeepColumns bool `json:"keep_columns"` } +// Areas of the blog page, in page order. above/below sit inside the main column. +var Areas = []string{"header", "left", "above", "below", "right", "footer"} + +const ( + minColumnWidth = 15 + maxColumnWidth = 40 + maxColumnsSum = 70 // main keeps at least 30% +) + func DefaultTheme() Theme { return Theme{ BgColor: "#e9e6df", BgMode: "cover", ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", LinkUnderline: "always", ContentWidth: "medium", ContentStyle: "card", ContentPadding: "normal", Font: "sans", HeadingFont: "same", FontSize: "normal", - HeaderShowTitle: true, HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", - NavPosition: "below-header", NavAlign: "left", NavSidebarWidth: "normal", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true, + HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", + NavAlign: "left", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", ShowDates: true, DateFormat: "long", - FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterShowRSS: true, + FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", + LogoSize: "medium", + HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, } } @@ -131,9 +151,7 @@ func (t *Theme) normalize() { t.HeaderAlign = pick(t.HeaderAlign, "left", "center") t.HeaderBg = color(t.HeaderBg, d.HeaderBg) t.HeaderText = color(t.HeaderText, d.HeaderText) - t.NavPosition = pick(t.NavPosition, "below-header", "top-bar", "left-sidebar") t.NavAlign = pick(t.NavAlign, "left", "center", "right") - t.NavSidebarWidth = pick(t.NavSidebarWidth, "normal", "narrow", "wide") t.NavStyle = pick(t.NavStyle, "normal", "uppercase") t.NavBg = color(t.NavBg, d.NavBg) t.NavText = color(t.NavText, d.NavText) @@ -141,10 +159,24 @@ func (t *Theme) normalize() { t.DateFormat = pick(t.DateFormat, "long", "short", "iso") t.FooterBg = color(t.FooterBg, d.FooterBg) t.FooterColor = color(t.FooterColor, d.FooterColor) - if len(t.FooterText) > 2000 { - t.FooterText = t.FooterText[:2000] - } + t.Logo = imageID(t.Logo) + t.LogoSize = pick(t.LogoSize, "medium", "small", "large") t.Favicon = imageID(t.Favicon) + t.LeftWidth = clampInt(t.LeftWidth, minColumnWidth, maxColumnWidth) + t.RightWidth = clampInt(t.RightWidth, minColumnWidth, maxColumnWidth) + if t.LeftWidth+t.RightWidth > maxColumnsSum { // the right column gives way + t.RightWidth = maxColumnsSum - t.LeftWidth + } +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v } // ThemeFromForm reads the design form on top of the current theme. @@ -172,32 +204,71 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.Font = get("font") t.HeadingFont = get("heading_font") t.FontSize = get("font_size") - t.HeaderShowTitle = on("header_show_title") t.HeaderImageHeight = get("header_image_height") t.HeaderAlign = get("header_align") t.HeaderBg = get("header_bg") t.HeaderText = get("header_text") - t.NavPosition = get("nav_position") t.NavAlign = get("nav_align") - t.NavSidebarWidth = get("nav_sidebar_width") t.NavStyle = get("nav_style") t.NavBg = get("nav_bg") t.NavText = get("nav_text") t.NavHover = hover("nav_hover") - t.NavShowHome = on("nav_show_home") t.ShowDates = on("show_dates") t.DateFormat = get("date_format") - t.FooterText = get("footer_text") t.FooterBg = get("footer_bg") t.FooterColor = get("footer_color") - t.FooterShowRSS = on("footer_show_rss") + t.LogoSize = get("logo_size") t.BgImage = pickImage(t.BgImage, get("bg_image")) t.HeaderImage = pickImage(t.HeaderImage, get("header_image")) t.Favicon = pickImage(t.Favicon, get("favicon")) + t.Logo = pickImage(t.Logo, get("logo")) t.normalize() return t } +// LayoutFromForm reads the columns form of the Layout page; the area +// toggles have their own buttons (SetAreaOn). +func LayoutFromForm(cur Theme, f url.Values) Theme { + t := cur + t.LeftWidth, _ = strconv.Atoi(strings.TrimSpace(f.Get("left_width"))) + t.RightWidth, _ = strconv.Atoi(strings.TrimSpace(f.Get("right_width"))) + t.KeepColumns = f.Get("keep_columns") == "on" + t.normalize() + return t +} + +// AreaOn reports whether an area is switched on; above/below are part of the main column and always on. +func (t Theme) AreaOn(area string) bool { + switch area { + case "header": + return t.HeaderOn + case "left": + return t.LeftOn + case "right": + return t.RightOn + case "footer": + return t.FooterOn + } + return true +} + +// SetAreaOn flips an area's switch; unknown areas are ignored. +func (t *Theme) SetAreaOn(area string, on bool) { + switch area { + case "header": + t.HeaderOn = on + case "left": + t.LeftOn = on + case "right": + t.RightOn = on + case "footer": + t.FooterOn = on + } +} + +// MainWidth is what the main column gets when both side columns are shown. +func (t Theme) MainWidth() int { return 100 - t.LeftWidth - t.RightWidth } + // pickImage applies an imagepick radio: "none" clears, an id selects, and an // empty value (no radio in the form) keeps what was there. func pickImage(cur, v string) string { @@ -324,29 +395,15 @@ func (t Theme) ContentPaddingCSS() (desktop, phone string) { func (t Theme) PaddingDesktop() string { d, _ := t.ContentPaddingCSS(); return d } func (t Theme) PaddingPhone() string { _, p := t.ContentPaddingCSS(); return p } -// SidebarWidthPx is the sidebar width when it differs from blog.css's 180px, else "". -func (t Theme) SidebarWidthPx() string { - if t.NavPosition != "left-sidebar" { - return "" - } - switch t.NavSidebarWidth { - case "narrow": +// LogoMaxHeight sizes the logo image wherever the logo module appears. +func (t Theme) LogoMaxHeight() string { + switch t.LogoSize { + case "small": + return "48px" + case "large": return "140px" - case "wide": - return "240px" } - return "" -} - -// SidebarContentMargin leaves the same 20px gutter blog.css uses. -func (t Theme) SidebarContentMargin() string { - switch t.NavSidebarWidth { - case "narrow": - return "160px" - case "wide": - return "260px" - } - return "" + return "80px" } func (t Theme) HeaderImageMaxHeight() string { @@ -359,7 +416,7 @@ func (t Theme) HeaderImageMaxHeight() string { return "320px" } -var themeCSS = template.Must(template.New("theme").Parse(` +var themeCSS = template.Must(template.New("theme").Funcs(template.FuncMap{"sub": func(a, b int) int { return a - b }}).Parse(` body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.55; {{- if .BgImage}} background-image:url(/media/{{.BgImage}}); {{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat; @@ -383,12 +440,15 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl {{- if .NavHover}} .site-nav a:hover { color:{{.NavHover}}; } {{- end}} -{{- with .SidebarWidthPx}} -.nav-left-sidebar .body-wrap .site-nav, .nav-left-sidebar .body-wrap .sidebar-notices { width:{{.}}; } -.nav-left-sidebar .body-wrap .content { margin-left:{{$.SidebarContentMargin}}; } -@media (max-width: 700px) { .nav-left-sidebar .body-wrap .site-nav, .nav-left-sidebar .body-wrap .sidebar-notices { width:auto; } .nav-left-sidebar .body-wrap .content { margin-left:0; } } -{{- end}} -.content { background:{{.ContentBg}};{{with .PaddingDesktop}} padding:{{.}};{{end}}{{if eq .ContentStyle "flat"}} border-radius:0; box-shadow:none;{{end}} } +.content, .mod { background:{{.ContentBg}};{{if eq .ContentStyle "flat"}} border-radius:0; box-shadow:none;{{end}} } +.content {{"{"}}{{with .PaddingDesktop}} padding:{{.}};{{end}} } +.logo { max-height:{{.LogoMaxHeight}}; } +@media (min-width: 701px) { + .col-left { width:{{.LeftWidth}}%; } .col-right { width:{{.RightWidth}}%; } + body.has-left.has-right .col-main { width:{{.MainWidth}}%; } + body.has-left:not(.has-right) .col-main { width:{{sub 100 .LeftWidth}}%; } + body.has-right:not(.has-left) .col-main { width:{{sub 100 .RightWidth}}%; } +} {{- with .PaddingPhone}} @media (max-width: 700px) { .content { padding:{{.}}; } } {{- end}} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 9afa11a..b8f761a 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -51,7 +51,7 @@ func TestHostRoutingWithoutDB(t *testing.T) { func TestRootRoutePrecedence(t *testing.T) { cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} s := NewServer(cfg, nil) - for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/account/password": 303} { + for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/account/password": 303} { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", path, nil) req.Host = "example.com" @@ -68,19 +68,25 @@ func TestRootRoutePrecedence(t *testing.T) { } func TestThemeNormalizeAndCSS(t *testing.T) { - th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","nav_position":"left-sidebar","link_hover":"blue","heading_font":"wingdings","favicon":"x"}`)) - if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.NavPosition != "left-sidebar" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" { + th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","logo":"../etc","link_hover":"blue","heading_font":"wingdings","favicon":"x","logo_size":"huge","left_width":5,"right_width":90}`)) + if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.Logo != "" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" || th.LogoSize != "medium" { t.Errorf("normalize: %+v", th) } - // Themes saved before the new options existed keep today's look. - if !th.ShowDates || !th.FooterShowRSS || th.LinkUnderline != "always" || th.ContentStyle != "card" || th.DateFormat != "long" { + if th.LeftWidth != 15 || th.RightWidth != 40 { + t.Errorf("width clamp: %+v", th) + } + // Themes saved before the new options existed keep today's look: every area on, columns a quarter each. + if !th.ShowDates || th.LinkUnderline != "always" || th.ContentStyle != "card" || th.DateFormat != "long" { t.Errorf("old theme defaults: %+v", th) } - f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "header_show_title": {"on"}, "bg_image": {"not-a-uuid"}, + if !th.HeaderOn || !th.LeftOn || !th.RightOn || !th.FooterOn || th.LeftWidth != 15 { + t.Errorf("old theme layout defaults: %+v", th) + } + f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "bg_image": {"not-a-uuid"}, "logo_size": {"large"}, "link_hover": {"#ff0000"}, "nav_hover": {"#00ff00"}, "nav_hover_custom": {"on"}, "nav_style": {"uppercase"}, "nav_align": {"center"}, "link_underline": {"hover"}, "heading_font": {"serif"}, "content_style": {"flat"}, "content_padding": {"roomy"}, "date_format": {"iso"}} th = ThemeFromForm(DefaultTheme(), f) - if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || !th.HeaderShowTitle || th.BgImage != "" { + if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || th.BgImage != "" || th.LogoSize != "large" { t.Errorf("from form: %+v", th) } if th.LinkHover != "" || th.NavHover != "#00ff00" || th.ShowDates || th.DateFormat != "iso" { @@ -88,7 +94,7 @@ func TestThemeNormalizeAndCSS(t *testing.T) { } css := th.CSS() for _, want := range []string{"background-color:#abcdef", "max-width:1100px", "text-transform:uppercase", ".site-nav a:hover { color:#00ff00", "a:hover { text-decoration:underline", - "h1, h2, h3, h4, h5, h6 { font-family:Georgia", "text-align:center", "border-radius:0", "padding:2.2em 3em", "@media (max-width: 700px)"} { + "h1, h2, h3, h4, h5, h6 { font-family:Georgia", "text-align:center", "border-radius:0", "padding:2.2em 3em", "@media (max-width: 700px)", ".logo { max-height:140px"} { if !strings.Contains(css, want) { t.Errorf("css missing %q:\n%s", want, css) } @@ -102,20 +108,139 @@ func TestThemeNormalizeAndCSS(t *testing.T) { } } -func TestSidebarWidthCSS(t *testing.T) { - th := ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}, "nav_sidebar_width": {"wide"}}) +func TestLayoutTheme(t *testing.T) { + th := LayoutFromForm(DefaultTheme(), url.Values{"left_width": {"40"}, "right_width": {"40"}, "keep_columns": {"on"}}) + if th.LeftWidth != 40 || th.RightWidth != 30 || th.MainWidth() != 30 || !th.KeepColumns { + t.Errorf("the right column should give way: %+v", th) + } + th = LayoutFromForm(th, url.Values{"left_width": {"junk"}, "right_width": {"20"}}) + if th.LeftWidth != 15 || th.RightWidth != 20 || th.KeepColumns { + t.Errorf("junk clamps to the minimum: %+v", th) + } css := th.CSS() - if !strings.Contains(css, "width:240px") || !strings.Contains(css, "margin-left:260px") || !strings.Contains(css, "margin-left:0") { - t.Errorf("sidebar css: %s", css) + for _, want := range []string{"@media (min-width: 701px)", ".col-left { width:15%", ".col-right { width:20%", "body.has-left.has-right .col-main { width:65%", + "body.has-left:not(.has-right) .col-main { width:85%", "body.has-right:not(.has-left) .col-main { width:80%"} { + if !strings.Contains(css, want) { + t.Errorf("css missing %q:\n%s", want, css) + } + } + th.SetAreaOn("left", false) + th.SetAreaOn("bogus", false) + if th.AreaOn("left") || !th.AreaOn("right") || !th.AreaOn("above") { + t.Errorf("area switches: %+v", th) + } +} + +func TestBuildLayout(t *testing.T) { + mods := []store.Module{{ID: 1, Area: "header", Kind: "title"}, {ID: 2, Area: "left", Kind: "recent", Count: 3}, {ID: 3, Area: "right", Kind: "recent", Count: 8}, + {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}} + th := DefaultTheme() + l := buildLayout(th, mods) + if !l.HasLeft || !l.HasRight || len(l.Header) != 1 || len(l.Footer) != 1 || len(l.Above) != 1 || l.MaxRecent() != 8 || !l.NeedsArchive() { + t.Errorf("layout: %+v", l) + } + th.RightOn, th.FooterOn = false, false + l = buildLayout(th, mods) + if l.HasRight || len(l.Footer) != 0 || l.MaxRecent() != 3 || l.NeedsArchive() { + t.Errorf("switched-off areas should drop their modules: %+v", l) + } + if buildLayout(th, nil).HasLeft { + t.Error("an empty column is not shown") + } + th.KeepColumns = true + if k := buildLayout(th, nil); !k.HasLeft || !k.HasRight || len(k.Right) != 0 { + t.Errorf("keep_columns reserves empty and hidden columns: %+v", k) + } + th.KeepColumns = false + // announcements go to their column, or to the main one when that column is not laid out + secs := []store.Section{{ID: 1, Placement: "main-top"}, {ID: 2, Placement: "right-bottom"}, {ID: 3, Placement: "left-top"}, {ID: 4, Placement: "left-bottom"}} + n := placeNotices(secs, l) // l: left laid out, right not + if len(n["main-top"]) != 1 || len(n["main-bottom"]) != 1 || n["main-bottom"][0].ID != 2 || len(n["left-top"]) != 1 || len(n["left-bottom"]) != 1 || len(n["right-bottom"]) != 0 { + t.Errorf("left column only: %v", n) + } + n = placeNotices(secs, Layout{}) + if len(n["main-top"]) != 2 || n["main-top"][1].ID != 3 || len(n["main-bottom"]) != 2 || len(n) != 2 { // no columns: everything in main, order kept + t.Errorf("no columns: %v", n) + } +} + +func TestModuleKinds(t *testing.T) { + for area, kinds := range moduleKinds { + if _, ok := areaNames[area]; !ok { + t.Errorf("area %q has no name", area) + } + for _, k := range kinds { + if moduleNames[k] == "" { + t.Errorf("kind %q has no name", k) + } + } + } + if !allowedKind("header", "menu") || allowedKind("header", "archive") || allowedKind("above", "menu") || allowedKind("attic", "html") { + t.Error("allowedKind") + } + if !moduleHasSettings("html") || moduleHasSettings("rss") { + t.Error("moduleHasSettings") + } + if got := moduleSummary(store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { + t.Errorf("recent summary: %q", got) + } + if got := moduleSummary(store.Module{Kind: "html", Body: " <p>hi\n there</p> "}); got != "<p>hi there</p>" { + t.Errorf("html summary: %q", got) } - // The width only matters for the sidebar layout, and "normal" is blog.css's own value. - th.NavPosition = "top-bar" - if strings.Contains(th.CSS(), "width:240px") { - t.Error("sidebar width leaked into a horizontal menu") + if got := moduleSummary(store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") { + t.Errorf("long html summary: %q", got) } - th = ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}}) - if strings.Contains(th.CSS(), ".nav-left-sidebar") { - t.Error("normal width should emit nothing") + if got := moduleSummary(store.Module{Kind: "menu"}); got != "" { + t.Errorf("menu summary: %q", got) + } +} + +func TestValidLinkURL(t *testing.T) { + for _, ok := range []string{"https://example.org", "http://example.org/a?b=c", "mailto:me@example.org", "/about", "/news/hello"} { + if !validLinkURL(ok) { + t.Errorf("%q should be accepted", ok) + } + } + for _, bad := range []string{"", "javascript:alert(1)", "//evil.org", "ftp://x", "https://", "example.org", "https://a b", "\"><script>", strings.Repeat("/", 501)} { + if validLinkURL(bad) { + t.Errorf("%q should be rejected", bad) + } + } +} + +func TestGroupArchive(t *testing.T) { + d := func(y int, m time.Month, day int) time.Time { return time.Date(y, m, day, 12, 0, 0, 0, time.UTC) } + posts := []store.PostRef{{Title: "d", CreatedAt: d(2026, 9, 3)}, {Title: "c", CreatedAt: d(2026, 9, 1)}, {Title: "b", CreatedAt: d(2026, 2, 1)}, {Title: "a", CreatedAt: d(2025, 12, 31)}} + years := groupArchive(posts) + if len(years) != 2 || years[0].Year != 2026 || years[0].Count != 3 || years[1].Year != 2025 || years[1].Count != 1 { + t.Fatalf("years: %+v", years) + } + if len(years[0].Months) != 2 || years[0].Months[0].Name != "September" || len(years[0].Months[0].Posts) != 2 || years[0].Months[1].Name != "February" { + t.Errorf("months: %+v", years[0].Months) + } + if years[0].Months[0].Posts[0].Title != "d" || years[1].Months[0].Posts[0].Title != "a" { + t.Errorf("post order: %+v", years) + } + if len(groupArchive(nil)) != 0 { + t.Error("empty index") + } +} + +func TestMenuItemLinks(t *testing.T) { + id := int64(7) + page := store.MenuItem{PageID: &id, PageTitle: "About", PageSlug: "about"} + home := store.MenuItem{PageID: &id, PageTitle: "Home", PageSlug: "home", IsHome: true} + link := store.MenuItem{Label: "Photos", URL: "https://example.org/p"} + if page.Href() != "/about" || page.Text() != "About" || home.Href() != "/" || link.Href() != "https://example.org/p" || link.Text() != "Photos" { + t.Errorf("%q %q %q", page.Href(), home.Href(), link.Href()) + } +} + +func TestRedirectOKKeepsAnchor(t *testing.T) { + rec := httptest.NewRecorder() + redirectOK(rec, httptest.NewRequest("GET", "/", nil), "/b/a/layout#menu", "hi there") + if got := rec.Header().Get("Location"); got != "/b/a/layout?ok=hi+there#menu" { + t.Errorf("location: %q", got) } } @@ -142,7 +267,7 @@ func TestPresets(t *testing.T) { t.Fatal("expected several presets") } cur := DefaultTheme() - cur.BgImage, cur.NavPosition, cur.Font, cur.LinkHover = "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "top-bar", "mono", "#123456" + cur.BgImage, cur.LeftWidth, cur.Font, cur.LinkHover = "6ba7b810-9dad-11d1-80b4-00c04fd430c8", 30, "mono", "#123456" for _, p := range Presets() { for _, c := range []string{p.Bg, p.ContentBg, p.Text, p.Link, p.HeaderBg, p.HeaderText, p.NavBg, p.NavText, p.FooterBg, p.FooterText} { if !hexColor.MatchString(c) { @@ -153,7 +278,7 @@ func TestPresets(t *testing.T) { if th.BgColor != p.Bg || th.FooterColor != p.FooterText || th.LinkHover != "" { t.Errorf("preset %s not applied: %+v", p.Key, th) } - if th.BgImage != cur.BgImage || th.NavPosition != "top-bar" || th.Font != "mono" { + if th.BgImage != cur.BgImage || th.LeftWidth != 30 || th.Font != "mono" { t.Errorf("preset %s touched non-colour fields: %+v", p.Key, th) } } @@ -176,7 +301,7 @@ func TestAllTemplatesParse(t *testing.T) { tpl := newTemplates(false, funcs) for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html", - "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", + "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", "blog/page.html", "blog/post.html", "blog/404.html"} { if _, err := tpl.get(name); err != nil { t.Errorf("%s: %v", name, err) @@ -211,19 +336,20 @@ func TestAppendImageMD(t *testing.T) { } } -func TestSplitSections(t *testing.T) { - secs := []store.Section{{ID: 1, Placement: "above"}, {ID: 2, Placement: "below"}, {ID: 3, Placement: "sidebar"}} - above, below, sidebar := splitSections(secs, true) - if len(above) != 1 || len(below) != 1 || len(sidebar) != 1 || sidebar[0].ID != 3 { - t.Errorf("with sidebar: %v %v %v", above, below, sidebar) - } - above, _, sidebar = splitSections(secs, false) - if len(above) != 2 || len(sidebar) != 0 || above[1].ID != 3 { // sidebar falls back to above, keeping order - t.Errorf("without sidebar: %v %v", above, sidebar) - } +func TestNormalizeSection(t *testing.T) { sec := &store.Section{Placement: "footer", Style: "<script>"} normalizeSection(sec) - if sec.Placement != "above" || sec.Style != "note" { + if sec.Placement != "main-top" || sec.Style != "note" { t.Errorf("normalize: %+v", sec) } + sec = &store.Section{Placement: "right-bottom"} + normalizeSection(sec) + if sec.Placement != "right-bottom" || sec.Column() != "right" || sec.Position() != "bottom" { + t.Errorf("valid placement kept: %+v", sec) + } + sec = &store.Section{Placement: "left-sideways"} + normalizeSection(sec) + if sec.Placement != "left-top" { + t.Errorf("half-valid placement: %+v", sec) + } } |
