package web import ( "log" "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. The font // pickers list as many, with no panel behind them: a blog has a few fonts. 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 } fonts, _, err := bs.ListFiles(r.Context(), "font", "", recentImages, 0) if err != nil { return nil, err } // The pickers list the recent files; a chosen one older than that still // needs its name for the select. names := map[string]string{} for _, f := range append(images, fonts...) { names[f.ID.String()] = f.Filename } for _, id := range []string{v.Theme.BgImage, v.Theme.HeaderImage, v.Theme.Logo, v.Theme.Favicon, v.Theme.CustomFont, v.Theme.CustomHeadingFont} { 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, "fonts": fonts, "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) } // Every part of the form is read before any problem is reported, so the // form shown again carries all of it: a module or menu row that was left // out here would be deleted by the next save. The first problem is the // one reported. var problem error note := func(err error) { if problem == nil { problem = err } } var err error v.Modules, err = parseModules(r.Form, lang, blog.Language) note(err) pages, err := blogStore(r).ListPages(r.Context()) if err != nil { s.serverError(w, err) return } v.Menu, err = parseMenu(r.Form, lang, pages) note(err) // Optional direct uploads from the pickers. One that is stored stays // chosen in the form even when something else is rejected. for _, u := range []struct { field, kind string dst *string }{{"bg_image_file", "image", &v.Theme.BgImage}, {"header_image_file", "image", &v.Theme.HeaderImage}, {"logo_file", "image", &v.Theme.Logo}, {"favicon_file", "image", &v.Theme.Favicon}, {"custom_font_file", "font", &v.Theme.CustomFont}, {"custom_heading_font_file", "font", &v.Theme.CustomHeadingFont}} { f, err := s.readUpload(r, u.field, u.kind) if err != nil { note(err) continue } if f != nil { *u.dst = f.ID.String() } } if problem != nil { d, err := s.designData(r, v) if err != nil { s.serverError(w, err) return } d["error"] = problem.Error() s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", d) 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.")) } // handleDesignPreview renders the header as the design form would save it, // for the Menu card's preview frame: the form is read like a save (theme, // modules, menu), nothing is stored, and a bad row is shown rather than // reported. Login, ownership, CSRF and the body cap are withBlog's. The page // is on the root host, so the blog's files are linked through /b//media. func (s *Server) handleDesignPreview(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) th := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form) th.HeaderImage = "" // the banner would dwarf the menu, which is what the frame is for mods, _ := parseModules(r.Form, s.lang(r), blog.Language) pages, err := blogStore(r).ListPages(r.Context()) if err != nil { s.serverError(w, err) return } menu, _ := parseMenu(r.Form, s.lang(r), pages) media := "/b/" + blog.Subdomain + "/media" data := map[string]any{"theme": th, "css": th.CSSAt(media), "layout": buildLayout(th, mods), "menu": menu, "pages": pages} for i := range pages { // the home page is "current", so its link shows the mark if pages[i].IsHome { data["page"] = &pages[i] } } tpl, err := s.tpl.get(blog.Language, "blog/preview.html") if err != nil { s.serverError(w, err) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tpl.ExecuteTemplate(w, "preview", view{Blog: blog, Lang: blog.Language, Media: media, Data: data}); err != nil { log.Printf("render preview: %v", err) } } // 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) }