From d11e4c9544f544b3a024c7ac705a674b4096cf50 Mon Sep 17 00:00:00 2001 From: grm Date: Sun, 13 Sep 2026 00:38:24 +0300 Subject: Add the Layout tab: five areas with modules, columns and a menu editor A blog page is now header, left column, main content, right column and footer, each holding an ordered list of modules (blog title, logo, menu, archive by year/month, recent posts, custom HTML, footer text, RSS link, site map). Side columns have percentage widths, can be hidden, and can keep their space when empty; on phones they stack under the posts. The menu becomes its own ordered list mixing pages and outside links. Announcements are placed per column at its top or bottom. Modules and menu items get tables; the migration derives them from each blog's old theme (nav position, footer text, show-in-nav pages) so existing blogs look the same. Custom HTML is deliberately stored and served as-is (owner's decision; see AGENTS.md for why the blast radius is the blogger's own origin). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_blog.go | 51 ++-- internal/web/handlers_design.go | 14 +- internal/web/handlers_layout.go | 307 +++++++++++++++++++++ internal/web/handlers_sections.go | 6 +- internal/web/layout.go | 222 +++++++++++++++ internal/web/routes.go | 13 + internal/web/server.go | 7 +- internal/web/static/blog.css | 50 +++- internal/web/static/dashboard.css | 45 ++- internal/web/templates.go | 11 + internal/web/templates/dashboard/design.html | 54 ++-- internal/web/templates/dashboard/layout.html | 113 ++++++++ internal/web/templates/dashboard/module_form.html | 34 +++ internal/web/templates/dashboard/overview.html | 2 +- internal/web/templates/dashboard/pages.html | 6 +- internal/web/templates/dashboard/section_form.html | 14 +- internal/web/templates/dashboard/sections.html | 2 +- internal/web/templates/layouts/blog.html | 84 ++++-- internal/web/templates/partials/dashnav.html | 1 + internal/web/theme.go | 176 ++++++++---- internal/web/web_test.go | 192 ++++++++++--- 21 files changed, 1190 insertions(+), 214 deletions(-) create mode 100644 internal/web/handlers_layout.go create mode 100644 internal/web/layout.go create mode 100644 internal/web/templates/dashboard/layout.html create mode 100644 internal/web/templates/dashboard/module_form.html (limited to 'internal/web') 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"}}

{{.}}

{{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 @@

Design

Changes are live as soon as you save; refresh your blog ↗ to see them.

-

SchemesBackgroundTextHeaderMenuPostsFooterIcon

+

SchemesBackgroundTextHeaderMenuPostsFooterLogo & icon

+

What appears where — header, columns, footer and their modules — is arranged on the Layout tab.

Colour schemes

@@ -81,12 +82,13 @@ -
+

Width only applies while there are no side columns; with a left or right column the blog uses the whole page.

- +

The title, logo and menu are header modules — add, remove and order them on the Layout tab. The banner below is shown across the top of the header whenever one is chosen.

{{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "images" .Data.images "label" "Header image (banner)")}}