package web import ( "errors" "net/http" "strconv" "strings" "github.com/gramanas/blogspace/internal/i18n" "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 := blogStore(r).ListModules(r.Context()) if err != nil { return nil, err } menu, err := blogStore(r).ListMenu(r.Context()) if err != nil { return nil, err } pages, err := blogStore(r).ListPages(r.Context()) 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 := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { s.serverError(w, err) return } redirectOK(w, r, s.layoutURL(r)+"#columns", s.tr(r, "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 := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { s.serverError(w, err) return } msg := s.trf(r, "%s hidden.", s.tr(r, areaNames[area])) if on { msg = s.trf(r, "%s shown again. Refresh your blog to see it.", s.tr(r, areaNames[area])) } redirectOK(w, r, s.layoutURL(r)+"#"+area, msg) } func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { area, kind := r.PathValue("area"), r.FormValue("kind") if !allowedKind(area, kind) { s.plainError(w, http.StatusBadRequest, s.tr(r, "That module cannot go in that area.")) return } if singleModuleAreas[area] { mods, err := blogStore(r).ListModules(r.Context()) if err != nil { s.serverError(w, err) return } for _, m := range mods { if m.Area == area { redirectOK(w, r, s.layoutURL(r)+"#main", s.tr(r, "There is already a module there; edit or remove it first.")) return } } } m := &store.Module{Area: area, Kind: kind, Count: 5} blogLang := currentBlog(r).Language // headings are content: the blog's language, not the viewer's switch kind { // sensible headings so the module reads well before it is edited case "archive": m.Title = i18n.T(blogLang, "Archive") case "recent": m.Title = i18n.T(blogLang, "Recent posts") case "sitemap": m.Title = i18n.T(blogLang, "Pages") case "tags": m.Title = i18n.T(blogLang, "Tags") m.Count = 10 case "tagcloud": m.Title = i18n.T(blogLang, "Tag cloud") case "search": if area != "header" { m.Title = i18n.T(blogLang, "Search") } } m, err := blogStore(r).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", s.tr(r, "Added. Now write what it should show.")) return } redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(area), s.trf(r, "%s added. Refresh your blog to see it.", s.tr(r, moduleNames[kind]))) } // 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 := blogStore(r).ModuleByID(r.Context(), 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) { http.Redirect(w, r, s.layoutURL(r), http.StatusSeeOther) return } s.render(w, r, "dashboard/module_form.html", s.moduleFormData(r, m, "")) } func (s *Server) moduleFormData(r *http.Request, m *store.Module, errMsg string) map[string]any { d := map[string]any{"module": m, "name": s.tr(r, moduleNames[m.Kind]), "area": s.tr(r, areaNames[m.Area]), "counts": []int{3, 5, 8, 10, 15, 20}, "tagCounts": []int{5, 10, 20, 30, 50}} 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 { if m.Kind == "tags" { // 0 = all of them m.Count = clampInt(n, 0, maxTagListCount) } else { m.Count = clampInt(n, 1, maxRecentCount) } } fail := func(msg string) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", s.moduleFormData(r, m, msg)) } switch { case len(m.Title) > 120: fail(s.tr(r, "Heading is too long (max 120 characters).")) return case len(m.Body) > maxModuleBytes: fail(s.tr(r, "Content is too long (20 KB max).")) return } if err := blogStore(r).UpdateModule(r.Context(), m); err != nil { s.serverError(w, err) return } redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), s.tr(r, "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 := blogStore(r).MoveModule(r.Context(), 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": s.trf(r, "the ā€œ%sā€ module and its text", s.tr(r, moduleNames[m.Kind])), "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 := blogStore(r).DeleteModule(r.Context(), m.ID); err != nil { s.serverError(w, err) return } redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), s.trf(r, "%s removed.", s.tr(r, moduleNames[m.Kind]))) } // ---- menu -------------------------------------------------------------------- func (s *Server) handleMenuAddPage(w http.ResponseWriter, r *http.Request) { pageID, _ := strconv.ParseInt(r.FormValue("page"), 10, 64) if pageID == 0 { redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Pick a page first.")) return } if err := blogStore(r).AddMenuPage(r.Context(), pageID); err != nil { s.serverError(w, err) return } redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Page added to the menu. Refresh your blog to see it.")) } func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) { 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", s.tr(r, "Give the link a short text (max 60 characters).")) return case !validLinkURL(link): redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "That address does not look right. Use a full web address like https://example.org or a path like /about.")) return } if err := blogStore(r).AddMenuLink(r.Context(), label, link); err != nil { s.serverError(w, err) return } redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "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 := blogStore(r).MoveMenuItem(r.Context(), 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 := blogStore(r).DeleteMenuItem(r.Context(), id); err != nil { s.serverError(w, err) return } redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Removed from the menu.")) }