aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/handlers_design.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web/handlers_design.go')
-rw-r--r--internal/web/handlers_design.go188
1 files changed, 159 insertions, 29 deletions
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go
index 3e9e600..5853436 100644
--- a/internal/web/handlers_design.go
+++ b/internal/web/handlers_design.go
@@ -4,63 +4,187 @@ import (
"net/http"
"time"
+ "github.com/google/uuid"
"github.com/gramanas/blogspace/internal/store"
)
-func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) {
+// The Design tab: one form for the whole look of the blog — colours, fonts,
+// images, which areas are shown and how wide, the modules of each area and
+// the menu — saved in one go.
+
+// recentImages is how many library images the pickers list up front; the
+// rest are fetched by the script's library panel, page by page.
+const recentImages = 48
+
+// designView is the state the design page shows: what the database holds, or
+// what a rejected form sent, so no edit is lost to a validation message.
+type designView struct {
+ Theme Theme
+ Modules []store.Module
+ Menu []store.MenuItem
+}
+
+// designSections are the cards of the page, in order; the form says which one
+// was in view when it was sent so the flash can return there.
+var designSections = []string{"colours", "header", "menu", "content", "columns", "footer", "logo"}
+
+// modRow and menuRow are what the template renders a module or menu entry
+// from: the row's field index (unique across the form) and its position in
+// its list, the value of the no-script position box.
+type modRow struct {
+ I, Pos int
+ M store.Module
+}
+
+type menuRow struct {
+ I, Pos int
+ Item store.MenuItem
+}
+
+// modTemplate is a row the script clones when a module is added.
+type modTemplate struct {
+ Area, Kind string
+ M store.Module
+}
+
+func (s *Server) loadDesign(r *http.Request) (designView, error) {
blog := currentBlog(r)
- images, _, err := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0)
+ mods, err := blogStore(r).ListModules(r.Context())
if err != nil {
- s.serverError(w, err)
- return
+ return designView{}, err
+ }
+ menu, err := blogStore(r).ListMenu(r.Context())
+ if err != nil {
+ return designView{}, err
+ }
+ return designView{Theme: ParseTheme(blog.ThemeJSON), Modules: mods, Menu: menu}, nil
+}
+
+// designData is everything design.html needs; "today" is the sample for the date formats.
+func (s *Server) designData(r *http.Request, v designView) (map[string]any, error) {
+ bs := blogStore(r)
+ pages, err := bs.ListPages(r.Context())
+ if err != nil {
+ return nil, err
+ }
+ images, imageCount, err := bs.ListFiles(r.Context(), "image", "", recentImages, 0)
+ if err != nil {
+ return nil, err
+ }
+ // The pickers list the recent images; a chosen one older than that still
+ // needs its name for the select.
+ names := map[string]string{}
+ for _, f := range images {
+ names[f.ID.String()] = f.Filename
+ }
+ for _, id := range []string{v.Theme.BgImage, v.Theme.HeaderImage, v.Theme.Logo, v.Theme.Favicon} {
+ if id == "" || names[id] != "" {
+ continue
+ }
+ if f, err := bs.FileMeta(r.Context(), uuid.MustParse(id)); err == nil { // a deleted file was cleared from the theme already
+ names[id] = f.Filename
+ }
+ }
+ rows := map[string][]modRow{}
+ for i, m := range v.Modules {
+ rows[m.Area] = append(rows[m.Area], modRow{I: i, Pos: len(rows[m.Area]), M: m})
+ }
+ var menuRows []menuRow
+ inMenu := map[int64]bool{}
+ for i, it := range v.Menu {
+ menuRows = append(menuRows, menuRow{I: i, Pos: i, Item: it})
+ if it.PageID != nil {
+ inMenu[*it.PageID] = true
+ }
+ }
+ var freePages []store.Page // pages not yet in the menu
+ for _, p := range pages {
+ if !inMenu[p.ID] {
+ freePages = append(freePages, p)
+ }
}
- s.render(w, r, "dashboard/design.html", designData(ParseTheme(blog.ThemeJSON), images))
+ var templates []modTemplate
+ for _, area := range Areas {
+ for _, kind := range moduleKinds[area] {
+ templates = append(templates, modTemplate{Area: area, Kind: kind, M: newModule(area, kind, currentBlog(r).Language)})
+ }
+ }
+ var pageID int64
+ return map[string]any{
+ "theme": v.Theme, "rows": rows, "menuRows": menuRows, "freePages": freePages, "nextIndex": max(len(v.Modules), len(v.Menu)),
+ "templates": templates, "pageItem": store.MenuItem{PageID: &pageID}, "linkItem": store.MenuItem{},
+ "kinds": moduleKinds, "names": moduleNames, "areaNames": areaNames, "single": singleModuleAreas,
+ "counts": []int{3, 5, 8, 10, 15, 20}, "tagCounts": []int{5, 10, 20, 30, 50}, "perPage": postsPerPageChoices,
+ "images": images, "imageNames": names, "imageCount": imageCount, "presets": Presets(), "today": time.Now(),
+ }, nil
}
-// designData is what design.html shows; "today" is the sample for the date formats.
-func designData(theme Theme, images []store.File) map[string]any {
- return map[string]any{"theme": theme, "images": images, "presets": Presets(), "today": time.Now()}
+func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) {
+ v, err := s.loadDesign(r)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ d, err := s.designData(r, v)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "dashboard/design.html", d)
}
func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
- 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, "logo_file": &theme.Logo, "favicon_file": &theme.Favicon} {
+ lang := s.lang(r)
+ v := designView{Theme: ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form)}
+ // Without the script a scheme is a select, applied on top of the colour inputs.
+ if p, ok := PresetByKey(r.FormValue("preset")); ok {
+ v.Theme = v.Theme.WithPreset(p)
+ }
+ // The form is shown again with everything that was sent, plus the message.
+ fail := func(msg string) {
+ d, err := s.designData(r, v)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ d["error"] = msg
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", d)
+ }
+ // Optional direct uploads from the pickers.
+ for field, dst := range map[string]*string{"bg_image_file": &v.Theme.BgImage, "header_image_file": &v.Theme.HeaderImage, "logo_file": &v.Theme.Logo, "favicon_file": &v.Theme.Favicon} {
img, err := s.readUpload(r, field, true)
if err != nil {
- images, _, _ := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0)
- d := designData(theme, images)
- d["error"] = err.Error()
- s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", d)
+ fail(err.Error())
return
}
if img != nil {
*dst = img.ID.String()
}
}
- if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil {
+ var err error
+ if v.Modules, err = parseModules(r.Form, lang, blog.Language); err != nil {
+ fail(err.Error())
+ return
+ }
+ pages, err := blogStore(r).ListPages(r.Context())
+ if err != nil {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design saved. Refresh your blog to see it."))
-}
-
-// handleDesignPreset applies a colour scheme straight away; it is a separate
-// form so it cannot be confused with the unsaved edits of the main one.
-func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) {
- blog := currentBlog(r)
- p, ok := PresetByKey(r.FormValue("preset"))
- if !ok {
- s.plainError(w, http.StatusBadRequest, s.tr(r, "Unknown colour scheme."))
+ if v.Menu, err = parseMenu(r.Form, lang, pages); err != nil {
+ fail(err.Error())
return
}
- theme := ParseTheme(blog.ThemeJSON).WithPreset(p)
- if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil {
+ if err := blogStore(r).SaveDesign(r.Context(), v.Theme.JSON(), v.Modules, v.Menu); err != nil {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.trf(r, "Applied the %s scheme. Refresh your blog to see it.", s.tr(r, p.Name)))
+ back := "/b/" + blog.Subdomain + "/design"
+ if at := r.FormValue("at"); at != "" && pick(at, designSections...) == at {
+ back += "#" + at
+ }
+ redirectOK(w, r, back, s.tr(r, "Design saved. Refresh your blog to see it."))
}
// handleDesignReset restores the defaults, layout modules included; images
@@ -77,3 +201,9 @@ func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) {
}
redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design and layout reset to the defaults."))
}
+
+// handleLayoutRedirect keeps old links to the Layout tab working: it is the
+// columns section of the Design tab now.
+func (s *Server) handleLayoutRedirect(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/b/"+currentBlog(r).Subdomain+"/design#columns", http.StatusSeeOther)
+}