aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/handlers_layout.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web/handlers_layout.go')
-rw-r--r--internal/web/handlers_layout.go307
1 files changed, 307 insertions, 0 deletions
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.")
+}