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_layout.go | 307 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 internal/web/handlers_layout.go (limited to 'internal/web/handlers_layout.go') 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.") +} -- cgit v1.2.3