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.") }