package web import ( "net/http" "time" "github.com/google/uuid" "github.com/gramanas/blogspace/internal/store" ) // 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) mods, err := blogStore(r).ListModules(r.Context()) if err != nil { 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) } } 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 } 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) 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 { fail(err.Error()) return } if img != nil { *dst = img.ID.String() } } 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 } if v.Menu, err = parseMenu(r.Form, lang, pages); err != nil { fail(err.Error()) return } if err := blogStore(r).SaveDesign(r.Context(), v.Theme.JSON(), v.Modules, v.Menu); err != nil { s.serverError(w, err) return } 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 // stay in the library and the menu is left alone. func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) if err := blogStore(r).UpdateTheme(r.Context(), DefaultTheme().JSON()); err != nil { s.serverError(w, err) return } if err := blogStore(r).ResetModules(r.Context()); err != nil { s.serverError(w, err) return } 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) }