diff options
Diffstat (limited to 'internal/web')
| -rw-r--r-- | internal/web/design_form.go | 228 | ||||
| -rw-r--r-- | internal/web/handlers_blog.go | 24 | ||||
| -rw-r--r-- | internal/web/handlers_design.go | 188 | ||||
| -rw-r--r-- | internal/web/handlers_files.go | 8 | ||||
| -rw-r--r-- | internal/web/handlers_layout.go | 315 | ||||
| -rw-r--r-- | internal/web/routes.go | 15 | ||||
| -rw-r--r-- | internal/web/static/dashboard.css | 106 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/design.html | 435 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/layout.html | 113 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/module_form.html | 41 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/overview.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/pages.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/section_form.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/partials/dashnav.html | 1 | ||||
| -rw-r--r-- | internal/web/templates/partials/imagepick.html | 21 | ||||
| -rw-r--r-- | internal/web/theme.go | 88 | ||||
| -rw-r--r-- | internal/web/web_test.go | 90 |
17 files changed, 1010 insertions, 669 deletions
diff --git a/internal/web/design_form.go b/internal/web/design_form.go new file mode 100644 index 0000000..9edf8e3 --- /dev/null +++ b/internal/web/design_form.go @@ -0,0 +1,228 @@ +package web + +import ( + "errors" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/gramanas/blogspace/internal/i18n" + "github.com/gramanas/blogspace/internal/store" +) + +// The design form carries the whole layout as indexed fields: every module +// row is mod.<i>.{id,area,kind,title,body,count,pos,del} and every menu +// entry menu.<i>.{id,page,label,url,pos,del}. The index only tells rows +// apart; the order is pos (renumbered from the page by the script, typed in +// by hand without it). add.<area>, menu_add_page and menu_add_label/url are +// the no-script way of adding: they are appended on save. + +const ( + maxModuleBytes = 20_000 + maxRecentCount = 20 + maxMenuLabel = 60 +) + +// newModule is a module as it is first added: sensible headings, in the +// blog's language, so it reads well before it is edited. +func newModule(area, kind, blogLang string) store.Module { + m := store.Module{Area: area, Kind: kind, Count: 5} + switch kind { + 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") + } + return m +} + +// formIndexes lists the row indexes present under a prefix ("mod." → the i +// of every mod.<i>.<field>), in numeric order. +func formIndexes(f url.Values, prefix string) []int { + seen := map[int]bool{} + for k := range f { + if !strings.HasPrefix(k, prefix) { + continue + } + rest := k[len(prefix):] + dot := strings.IndexByte(rest, '.') + if dot < 0 { + continue + } + if i, err := strconv.Atoi(rest[:dot]); err == nil && !seen[i] { + seen[i] = true + } + } + out := make([]int, 0, len(seen)) + for i := range seen { + out = append(out, i) + } + sort.Ints(out) + return out +} + +type formRow struct { + pos, idx int +} + +// byPos orders rows by their pos field, the index breaking ties (so rows +// without one keep the order they were rendered in). +func byPos(rows []formRow) { + sort.SliceStable(rows, func(a, b int) bool { + if rows[a].pos != rows[b].pos { + return rows[a].pos < rows[b].pos + } + return rows[a].idx < rows[b].idx + }) +} + +// parseModules reads the module rows of the design form, in display order +// area by area, and appends the add.<area> choices. lang is the viewer's, +// for the messages; blogLang the blog's, for new headings. The modules come +// back even with an error, so the form can be shown again as it was sent. +func parseModules(f url.Values, lang, blogLang string) ([]store.Module, error) { + get := func(i int, k string) string { return f.Get("mod." + strconv.Itoa(i) + "." + k) } + var rows []formRow + for _, i := range formIndexes(f, "mod.") { + if get(i, "del") == "on" { + continue + } + pos, _ := strconv.Atoi(get(i, "pos")) + rows = append(rows, formRow{pos, i}) + } + byPos(rows) + var mods []store.Module + var err error + fail := func(msg string) { // the first problem is the one reported + if err == nil { + err = errors.New(msg) + } + } + for _, r := range rows { + i := r.idx + m := store.Module{Area: get(i, "area"), Kind: get(i, "kind")} + m.ID, _ = strconv.ParseInt(get(i, "id"), 10, 64) + if !allowedKind(m.Area, m.Kind) { // not something the form offers: drop it + continue + } + m.Title = strings.TrimSpace(get(i, "title")) + m.Body = strings.ReplaceAll(get(i, "body"), "\r\n", "\n") + if n, err := strconv.Atoi(get(i, "count")); err == nil { + if m.Kind == "tags" { // 0 = all of them + m.Count = clampInt(n, 0, maxTagListCount) + } else { + m.Count = clampInt(n, 1, maxRecentCount) + } + } + switch { + case len(m.Title) > 120: + fail(i18n.T(lang, "Heading is too long (max 120 characters).")) + case len(m.Body) > maxModuleBytes: + fail(i18n.T(lang, "Content is too long (20 KB max).")) + } + mods = append(mods, m) + } + for _, area := range Areas { + if kind := f.Get("add." + area); kind != "" && allowedKind(area, kind) { + mods = append(mods, newModule(area, kind, blogLang)) + } + } + // stable: the rows are already in order within each area + sort.SliceStable(mods, func(a, b int) bool { return areaIndex(mods[a].Area) < areaIndex(mods[b].Area) }) + seen := map[string]bool{} + for _, m := range mods { + if singleModuleAreas[m.Area] && seen[m.Area] { + fail(i18n.T(lang, "Only one module fits before or after the posts; remove one first.")) + } + seen[m.Area] = true + } + return mods, err +} + +func areaIndex(area string) int { + for i, a := range Areas { + if a == area { + return i + } + } + return len(Areas) +} + +// parseMenu reads the menu rows of the design form, in display order, and +// appends the "add a page" / "add a link" fields. Page entries are filled in +// from pages (title, slug, home) so the form can be shown again as sent — +// which it is, with a bad link as a row to fix, when there is an error. +func parseMenu(f url.Values, lang string, pages []store.Page) ([]store.MenuItem, error) { + byID := map[int64]store.Page{} + for _, p := range pages { + byID[p.ID] = p + } + get := func(i int, k string) string { return strings.TrimSpace(f.Get("menu." + strconv.Itoa(i) + "." + k)) } + var rows []formRow + for _, i := range formIndexes(f, "menu.") { + if get(i, "del") == "on" { + continue + } + pos, _ := strconv.Atoi(get(i, "pos")) + rows = append(rows, formRow{pos, i}) + } + byPos(rows) + var menu []store.MenuItem + var err error + fail := func(msg string) { // the first problem is the one reported + if err == nil { + err = errors.New(msg) + } + } + inMenu := map[int64]bool{} + addPage := func(id int64) { + p, ok := byID[id] + if !ok || inMenu[id] { // deleted meanwhile, or listed twice: drop it + return + } + inMenu[id] = true + menu = append(menu, store.MenuItem{PageID: &p.ID, PageTitle: p.Title, PageSlug: p.Slug, IsHome: p.IsHome}) + } + addLink := func(label, typed string) { + link := typed + if link != "" && !strings.HasPrefix(link, "/") && !strings.Contains(link, ":") { + link = "https://" + link // "example.org" is what people type + } + switch { + case label == "" || len(label) > maxMenuLabel: + fail(i18n.T(lang, "Give the link a short text (max 60 characters).")) + case !validLinkURL(link): + fail(i18n.T(lang, "That address does not look right. Use a full web address like https://example.org or a path like /about.")) + link = typed // shown again as typed, to be fixed + } + menu = append(menu, store.MenuItem{Label: label, URL: link}) + } + for _, r := range rows { + i := r.idx + id, _ := strconv.ParseInt(get(i, "id"), 10, 64) + n := len(menu) + if pageID, _ := strconv.ParseInt(get(i, "page"), 10, 64); pageID != 0 { + addPage(pageID) + } else { + addLink(get(i, "label"), get(i, "url")) + } + if len(menu) > n { + menu[n].ID = id + } + } + if pageID, _ := strconv.ParseInt(strings.TrimSpace(f.Get("menu_add_page")), 10, 64); pageID != 0 { + addPage(pageID) + } + if label, link := strings.TrimSpace(f.Get("menu_add_label")), strings.TrimSpace(f.Get("menu_add_url")); label != "" || link != "" { + addLink(label, link) + } + return menu, err +} diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index 6538c34..53ab891 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -13,8 +13,6 @@ import ( "github.com/gramanas/blogspace/internal/store" ) -const postsPerPage = 10 - // blogView gathers what every public page needs: theme css, the layout with // its modules and their data, the menu, announcements, the blog. func (s *Server) blogView(r *http.Request) (map[string]any, error) { @@ -102,13 +100,13 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store. s.serverError(w, err) return } - n := pageNum(r) - posts, total, err := blogStore(r).PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage) + n, per := pageNum(r), v["theme"].(Theme).PostsPerPage + posts, total, err := blogStore(r).PublishedPosts(r.Context(), page.ID, per, (n-1)*per) if err != nil { s.serverError(w, err) return } - last := (total + postsPerPage - 1) / postsPerPage + last := (total + per - 1) / per v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last // The pager appends ?p=N to base: no trailing slash, which /{page} would not match. v["base"] = "/" + page.Slug @@ -126,22 +124,22 @@ func (s *Server) handleBlogTag(w http.ResponseWriter, r *http.Request) { s.blogNotFound(w, r) return } - n := pageNum(r) - posts, total, err := blogStore(r).PublishedPostsByTag(r.Context(), tag.ID, postsPerPage, (n-1)*postsPerPage) + v, err := s.blogView(r) if err != nil { s.serverError(w, err) return } - if total == 0 { - s.blogNotFound(w, r) - return - } - v, err := s.blogView(r) + n, per := pageNum(r), v["theme"].(Theme).PostsPerPage + posts, total, err := blogStore(r).PublishedPostsByTag(r.Context(), tag.ID, per, (n-1)*per) if err != nil { s.serverError(w, err) return } - last := (total + postsPerPage - 1) / postsPerPage + if total == 0 { + s.blogNotFound(w, r) + return + } + last := (total + per - 1) / per v["tag"], v["posts"], v["pageNum"], v["lastPage"], v["base"] = tag, posts, n, last, "/tag/"+tag.Slug s.render(w, r, "blog/tag.html", v) } 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) +} diff --git a/internal/web/handlers_files.go b/internal/web/handlers_files.go index 2b68871..576f2ca 100644 --- a/internal/web/handlers_files.go +++ b/internal/web/handlers_files.go @@ -129,6 +129,14 @@ func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } + if wantsJSON(r) { // the design page's library panel: one page of files at a time + files := []map[string]string{} + for _, f := range d["files"].([]store.File) { + files = append(files, map[string]string{"id": f.ID.String(), "filename": f.Filename}) + } + writeJSON(w, http.StatusOK, map[string]any{"files": files, "page": d["pageNum"], "last": d["lastPage"]}) + return + } s.render(w, r, "dashboard/files.html", d) } diff --git a/internal/web/handlers_layout.go b/internal/web/handlers_layout.go deleted file mode 100644 index db4f47c..0000000 --- a/internal/web/handlers_layout.go +++ /dev/null @@ -1,315 +0,0 @@ -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") - } - 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.")) -} diff --git a/internal/web/routes.go b/internal/web/routes.go index 563639c..d846160 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -47,22 +47,9 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /b/{sub}/announcements/{id}/delete", s.withBlog(s.handleSectionDelete)) m.HandleFunc("POST /b/{sub}/announcements/{id}/move", s.withBlog(s.handleSectionMove)) m.HandleFunc("POST /b/{sub}/announcements/{id}/toggle", s.withBlog(s.handleSectionToggle)) - m.HandleFunc("GET /b/{sub}/layout", s.withBlog(s.handleLayout)) - m.HandleFunc("POST /b/{sub}/layout/widths", s.withBlog(s.handleLayoutWidths)) - m.HandleFunc("POST /b/{sub}/layout/areas/{area}/toggle", s.withBlog(s.handleAreaToggle)) - m.HandleFunc("POST /b/{sub}/layout/areas/{area}/add", s.withBlog(s.handleModuleAdd)) - m.HandleFunc("GET /b/{sub}/layout/modules/{id}/edit", s.withBlog(s.handleModuleForm)) - m.HandleFunc("POST /b/{sub}/layout/modules/{id}/edit", s.withBlog(s.handleModuleSave)) - m.HandleFunc("POST /b/{sub}/layout/modules/{id}/move", s.withBlog(s.handleModuleMove)) - m.HandleFunc("GET /b/{sub}/layout/modules/{id}/delete", s.withBlog(s.handleModuleDeleteConfirm)) - m.HandleFunc("POST /b/{sub}/layout/modules/{id}/delete", s.withBlog(s.handleModuleDelete)) - m.HandleFunc("POST /b/{sub}/layout/menu/add-page", s.withBlog(s.handleMenuAddPage)) - m.HandleFunc("POST /b/{sub}/layout/menu/add-link", s.withBlog(s.handleMenuAddLink)) - m.HandleFunc("POST /b/{sub}/layout/menu/{id}/move", s.withBlog(s.handleMenuMove)) - m.HandleFunc("POST /b/{sub}/layout/menu/{id}/delete", s.withBlog(s.handleMenuDelete)) + m.HandleFunc("GET /b/{sub}/layout", s.withBlog(s.handleLayoutRedirect)) // the old tab m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm)) m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign)) - m.HandleFunc("POST /b/{sub}/design/preset", s.withBlog(s.handleDesignPreset)) m.HandleFunc("POST /b/{sub}/design/reset", s.withBlog(s.handleDesignReset)) m.HandleFunc("GET /b/{sub}/files", s.withBlog(s.handleFiles)) m.HandleFunc("POST /b/{sub}/files/upload", s.withBlogFiles(maxUploadFiles, s.handleFileUpload)) diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index 3ef96c2..32561bf 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -195,28 +195,25 @@ table.files td.name { word-break: break-all; } table.files .copy { width: 100%; min-width: 11em; padding: 0.3em 0.4em; margin: 0; font-family: var(--mono); font-size: 0.75em; } .pager { margin: 0 0 1.4em; text-align: center; font-size: 0.92em; } .pager a, .pager span { margin: 0 0.8em; } -.imagepick { margin-top: 0.8em; padding-top: 0.6em; border-top: 1px solid var(--border); } -.imagepick > label { margin-bottom: 0.3em; } -.imagepick .picks { display: flex; flex-wrap: wrap; gap: 0.7em; margin: 0.4em 0 0; } -.imagepick .pick { width: 120px; margin: 0; padding: 0.4em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); text-align: center; font-size: 0.75em; font-weight: normal; cursor: pointer; } -.imagepick .pick:hover { border-color: var(--border-strong); background: var(--surface-2); } -.imagepick .pick input { display: block; width: auto; margin: 0 auto 0.3em; } -.imagepick .pick img, .imagepick .pick .swatch { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); } -.imagepick .pick img { object-fit: contain; } -.imagepick .pick .swatch { line-height: 64px; font-size: 1.6em; color: var(--muted); } -.imagepick .pick .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.imagepick .pick input:checked ~ img, .imagepick .pick input:checked ~ .swatch { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); } - -/* ---- design page ---------------------------------------------------------- */ -.jumps { display: flex; flex-wrap: wrap; gap: 0.4em; margin: 0 0 1.2em; font-size: 0.85em; } -.jumps a { display: inline-block; padding: 0.2em 0.75em; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--text); text-decoration: none; } -.jumps a:hover { border-color: var(--accent); color: var(--accent); } +/* ---- design page: side index, one form, module rows, image pickers ---------- */ +.design { display: grid; grid-template-columns: 11em minmax(0, 1fr); gap: 0 1.4em; align-items: start; } +.sidenav { position: sticky; top: 1em; display: flex; flex-direction: column; gap: 0.15em; font-size: 0.9em; } +.sidenav a { padding: 0.35em 0.7em; border-radius: var(--radius-sm); color: var(--muted); text-decoration: none; font-weight: 500; } +.sidenav a:hover { background: var(--surface); color: var(--text); } +.sidenav a.active { background: var(--accent-soft); color: var(--accent-hover); } +.design .card h3 { margin-top: 1.4em; padding-top: 0.9em; border-top: 1px solid var(--border); } +.design .card h3:first-of-type { margin-top: 0.8em; } +.design .card h2 + h3 { margin-top: 0; padding-top: 0; border-top: 0; } +.design h4 { font-size: 0.9em; font-weight: 600; margin: 1em 0 0.2em; } +/* what only makes sense with, or without, the script */ +form.js .nojs { display: none !important; } +form:not(.js) .jsonly { display: none !important; } .presets { display: flex; flex-wrap: wrap; gap: 0.8em; } .presets .preset { width: 8.5em; margin: 0; padding: 0.55em 0.45em; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.8em; font-weight: 500; text-align: center; } .presets .preset:hover { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); } .presets .sw { display: inline-block; width: 22%; height: 2.2em; margin: 0 1%; border: 1px solid rgba(0,0,0,0.15); border-radius: 3px; vertical-align: middle; } .presets .pname { display: block; margin-top: 0.4em; } -/* layout page: the column diagram, area cards with their module tables */ +/* the column diagram */ .lay { margin: 0 0 1em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-2); font-size: 0.75em; font-weight: 500; text-align: center; overflow: hidden; } .lay-row { padding: 0.5em; background: var(--text); color: #fff; } .lay-cols { display: flex; padding: 0.5em; gap: 0.3em; } @@ -226,38 +223,85 @@ table.files .copy { width: 100%; min-width: 11em; padding: 0.3em 0.4em; margin: .lay-col.off { text-decoration: none; } .lay-col.empty { background: transparent; } .row input[type=number] { width: 6em; } -.cardhead { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 1em; } -.cardhead h2 { margin: 0 0 0.6em; } -.cardhead .switch { margin: 0 0 0.6em auto; } -.card.area-off { border-style: dashed; box-shadow: none; } -.card.area-off .modules, .card.area-off .addmod { opacity: 0.6; } -table.modules { margin: 0.5em 0 1em; } -table.modules td.order, table.modules td.actions { width: 1%; } -table.modules th { width: 10em; } -.addmod { border-top: 1px solid var(--border); padding-top: 0.6em; } -.addmod label, .add label { display: inline-block; margin: 0 0.8em 0.4em 0; min-width: 14em; } -.addmod select, .add select, .add input { width: auto; min-width: 14em; } -.addmod button, .add button { vertical-align: bottom; margin-bottom: 0.4em; } +/* module and menu rows */ +.arealist { margin: 0.6em 0 0; } +.arealist.area-off .modlist, .arealist.area-off .addmod { opacity: 0.55; } +.modlist { margin: 0.4em 0; } +.modlist:empty { display: none; } +.modrow { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4em 0.7em; padding: 0.45em 0.6em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); margin: 0 0 0.4em; } +.modrow .order { display: inline-flex; gap: 0.2em; } +.modrow .order .pos { width: 3.6em; margin: 0; padding: 0.2em 0.3em; font-size: 0.85em; } +.modrow .modname { font-weight: 500; } +.modrow .tag { margin-left: 0; } +.modrow .modact { margin-left: auto; } +.modrow .modact label.check { display: inline; margin: 0; font-size: 0.85em; } +.modrow .modset { flex-basis: 100%; font-size: 0.95em; } +.modrow .modset summary { cursor: pointer; color: var(--muted); font-size: 0.9em; } +.modrow .modset summary .modsum { font-style: italic; } +.modrow .modset[open] summary { color: var(--text); } +.modrow .modset label { margin: 0.5em 0; } +.modrow .modset .row { gap: 0 1em; } +.modrow .modset .row label { flex: 0 1 14em; min-width: 10em; } +.modrow .modset p:last-child { margin-bottom: 0.3em; } +.modrow .linkfields { display: inline-flex; flex-wrap: wrap; gap: 0.4em; flex: 1 1 14em; } +.modrow .linkfields input { flex: 1 1 8em; width: auto; margin: 0; padding: 0.25em 0.45em; font-size: 0.9em; } +.addmod label { display: inline-block; margin: 0.3em 0 0.4em; min-width: 14em; } +.addmod select { width: auto; min-width: 14em; } .addrow { margin-top: 1em; border-top: 1px solid var(--border); padding-top: 0.6em; margin-bottom: 0; } +.addrow h3 { margin: 0.5em 0 0.2em; padding: 0; border: 0; } +.addrow select, .addrow input { width: auto; min-width: 14em; } +.addrow button { vertical-align: bottom; margin-bottom: 0.4em; } textarea.mono { font-size: 0.9em; } .hoverpick { margin: 0.8em 0; } .hoverpick > input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; } .hoverpick > label.check { display: inline; margin: 0; } .hoverpick .hover-color { margin: 0.4em 0 0 1.6em; } .hoverpick > input:not(:checked) ~ .hover-color { display: none; } -.savebar { position: sticky; bottom: 0; margin: 0; padding: 0.8em 0; background: var(--bg); border-top: 1px solid var(--border-strong); display: flex; gap: 0.8em; align-items: center; } +/* image fields: the chosen image, the select (or the library button) and an upload */ +.imagepick { margin: 0.9em 0 0; } +.imagepick > label { margin-bottom: 0.3em; } +.pickrow { display: flex; gap: 1em; align-items: flex-start; } +.pickrow .preview { flex: 0 0 auto; } +.pickrow .preview img, .pickrow .preview .swatch { display: block; width: 96px; height: 72px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); object-fit: contain; } +.pickrow .preview .swatch { line-height: 72px; text-align: center; font-size: 1.6em; color: var(--muted); } +.pickrow .pickctl { flex: 1 1 auto; min-width: 0; } +.pickrow .pickctl select { max-width: 24em; margin: 0 0 0.5em; } +.pickrow .pickctl .upload { margin: 0.5em 0 0; font-size: 0.85em; } +.pickrow .pickctl .upload input { max-width: 24em; } +.picker { position: fixed; inset: 0; z-index: 20; background: rgba(16, 24, 40, 0.45); padding: 3vh 1em; overflow: auto; } +.picker-box { max-width: 860px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 30px rgba(16, 24, 40, 0.25); padding: 1em 1.2em; } +.picker-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6em; margin-bottom: 0.8em; } +.picker-head input { flex: 1 1 12em; width: auto; margin: 0; padding: 0.4em 0.6em; } +.picker .picks { display: flex; flex-wrap: wrap; gap: 0.7em; } +.picker .pick { width: 120px; margin: 0; padding: 0.4em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); text-align: center; font-size: 0.75em; font-weight: normal; cursor: pointer; } +.picker .pick:hover { border-color: var(--accent); background: var(--surface-2); color: var(--text); } +.picker .pick img { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); object-fit: contain; } +.picker .pick .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.picker .pager { margin: 0.8em 0 0; } +.picker .pager button[disabled] { opacity: 0.4; cursor: default; } +.savebar { position: sticky; bottom: 0; z-index: 5; margin: 0; padding: 0.8em 0; background: var(--bg); border-top: 1px solid var(--border-strong); display: flex; flex-wrap: wrap; gap: 0.8em; align-items: center; } +.savebar .unsaved { font-size: 0.85em; font-weight: 600; color: #92400e; background: #fef3c7; border: 1px solid #fcd34d; border-radius: 999px; padding: 0.15em 0.7em; } +.savebar .viewlink { margin-left: auto; font-size: 0.9em; } details.reset { margin-top: 1.6em; } /* ---- login --------------------------------------------------------------- */ .login-brand { text-align: center; font-weight: 700; font-size: 1.2em; letter-spacing: -0.01em; margin: 2.5em 0 1em; } +@media (max-width: 900px) { + .design { display: block; } + .sidenav { position: static; flex-direction: row; flex-wrap: wrap; margin: 0 0 1em; gap: 0.3em; } + .sidenav a { border: 1px solid var(--border); background: var(--surface); } +} @media (max-width: 700px) { body { font-size: 15px; } .cols { grid-template-columns: 1fr; gap: 1.2em; } .row { gap: 0; } .row label { flex-basis: 100%; } - .imagepick .pick { width: 46%; } + .picker .pick { width: 46%; } .presets .preset { width: 46%; } + .sidenav { flex-wrap: nowrap; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; margin: 0 -1em 1em; padding: 0 1em; } + .pickrow { flex-wrap: wrap; } + .modrow .modact label.check { font-size: 0.8em; } .topbar .links { margin-left: 0; flex-basis: 100%; gap: 1em; } .blogbar .topbar-inner { display: block; } .blogbar .blogname { display: block; max-width: none; padding-bottom: 0.3em; } diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html index e961388..261eb69 100644 --- a/internal/web/templates/dashboard/design.html +++ b/internal/web/templates/dashboard/design.html @@ -1,31 +1,44 @@ {{define "title"}}{{t "Design"}} · {{.Blog.Title}}{{end}} {{define "content"}} {{$t := .Data.theme}} +{{$sub := .Blog.Subdomain}} <div class="pagehead"> <h1>{{t "Design"}}</h1> - <p class="lead muted">{{t "Changes are live as soon as you save; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} ↗</a> {{t "to see them."}}</p> + <p class="lead muted">{{t "Everything about how your blog looks and what goes where, saved together with the one button at the bottom. Changes are live as soon as you save; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} ↗</a> {{t "to see them."}}</p> </div> -<p class="jumps"><a href="#schemes">{{t "Schemes"}}</a><a href="#background">{{t "Background"}}</a><a href="#text">{{t "Text"}}</a><a href="#header">{{t "Header"}}</a><a href="#menu">{{t "Menu"}}</a><a href="#posts">{{t "Posts"}}</a><a href="#footer">{{t "Footer"}}</a><a href="#logo">{{t "Logo & icon"}}</a></p> -<p class="muted small">{{t "What appears where — header, columns, footer and their modules — is arranged on the"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a> {{t "tab."}}</p> -<div class="card" id="schemes"> - <h2>{{t "Colour schemes"}}</h2> - <p class="muted small">{{t "A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept."}}</p> - <form method="post" action="/b/{{.Blog.Subdomain}}/design/preset" class="presets"> - <input type="hidden" name="_csrf" value="{{.CSRF}}"> - {{range .Data.presets}}<button type="submit" name="preset" value="{{.Key}}" class="preset" title="{{tf "Apply %s" (t .Name)}}"> +<div class="design"> +<nav class="sidenav"> + <a href="#colours">{{t "Colours & fonts"}}</a> + <a href="#header">{{t "Header"}}</a> + <a href="#menu">{{t "Menu"}}</a> + <a href="#content">{{t "Content"}}</a> + <a href="#columns">{{t "Side columns"}}</a> + <a href="#footer">{{t "Footer"}}</a> + <a href="#logo">{{t "Logo & icon"}}</a> +</nav> + +<form method="post" action="/b/{{$sub}}/design" enctype="multipart/form-data" id="design" data-next="{{.Data.nextIndex}}" data-sub="{{$sub}}"> +<input type="hidden" name="_csrf" value="{{.CSRF}}"> +<input type="hidden" name="at" value=""> + +<div class="card" id="colours"> + <h2>{{t "Colours & fonts"}}</h2> + <h3>{{t "Colour schemes"}}</h3> + <p class="muted small">{{t "A quick start: a scheme fills in every colour below. Images, fonts and layout are kept."}}</p> + <div class="presets jsonly"> + {{range .Data.presets}}<button type="button" class="preset" data-colors="{{.Colors}}" title="{{tf "Apply %s" (t .Name)}}"> <span class="sw" style="background:{{.HeaderBg}}"></span><span class="sw" style="background:{{.Bg}}"></span><span class="sw" style="background:{{.ContentBg}}"></span><span class="sw" style="background:{{.Link}}"></span> <span class="pname">{{t .Name}}</span> </button> {{end}} - </form> -</div> - -<form method="post" action="/b/{{.Blog.Subdomain}}/design" enctype="multipart/form-data"> -<input type="hidden" name="_csrf" value="{{.CSRF}}"> + </div> + <label class="nojs">{{t "Apply a scheme when saving"}}<br><select name="preset"> + <option value="">{{t "Keep the colours below"}}</option> + {{range .Data.presets}}<option value="{{.Key}}">{{t .Name}}</option>{{end}} + </select></label> -<div class="card" id="background"> - <h2>{{t "Background"}}</h2> + <h3>{{t "Page background"}}</h3> <div class="row"> <label>{{t "Colour"}}<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label> <label>{{t "Image style"}}<br><select name="bg_mode"> @@ -34,21 +47,25 @@ <option value="tile"{{if eq $t.BgMode "tile"}} selected{{end}}>{{t "Repeat (tile)"}}</option> </select></label> </div> - {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" .Blog.Subdomain "images" .Data.images "label" (t "Background image"))}} -</div> + {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Background image"))}} -<div class="card" id="text"> - <h2>{{t "Text & content area"}}</h2> + <h3>{{t "Text & links"}}</h3> <div class="row"> - <label>{{t "Content background"}}<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label> <label>{{t "Text colour"}}<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label> <label>{{t "Link colour"}}<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label> + <label>{{t "Underline links"}}<br><select name="link_underline"> + <option value="always"{{if eq $t.LinkUnderline "always"}} selected{{end}}>{{t "Always"}}</option> + <option value="hover"{{if eq $t.LinkUnderline "hover"}} selected{{end}}>{{t "Only on hover"}}</option> + <option value="never"{{if eq $t.LinkUnderline "never"}} selected{{end}}>{{t "Never"}}</option> + </select></label> </div> <div class="hoverpick"> <input type="checkbox" name="link_hover_custom" id="link-hover-custom"{{if $t.LinkHover}} checked{{end}}> <label for="link-hover-custom" class="check">{{t "Change colour when the mouse is over a link"}}</label> <label class="hover-color">{{t "Hover colour"}}<br><input type="color" name="link_hover" value="{{if $t.LinkHover}}{{$t.LinkHover}}{{else}}{{$t.LinkColor}}{{end}}"></label> </div> + + <h3>{{t "Fonts"}}</h3> <div class="row"> <label>{{t "Font"}}<br><select name="font"> <option value="sans"{{if eq $t.Font "sans"}} selected{{end}}>Sans-serif (Helvetica/Arial)</option> @@ -66,28 +83,12 @@ <option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>{{t "Normal"}}</option> <option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>{{t "Large"}}</option> </select></label> - <label>{{t "Underline links"}}<br><select name="link_underline"> - <option value="always"{{if eq $t.LinkUnderline "always"}} selected{{end}}>{{t "Always"}}</option> - <option value="hover"{{if eq $t.LinkUnderline "hover"}} selected{{end}}>{{t "Only on hover"}}</option> - <option value="never"{{if eq $t.LinkUnderline "never"}} selected{{end}}>{{t "Never"}}</option> - </select></label> </div> - <div class="row"> - <label>{{t "Box style"}}<br><select name="content_style"> - <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>{{t "Card (shadow, rounded corners)"}}</option> - <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>{{t "Flat"}}</option> - </select></label> - <label>{{t "Padding"}} <span class="muted">({{t "inside the box"}})</span><br><select name="content_padding"> - <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>{{t "Compact"}}</option> - <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>{{t "Normal"}}</option> - <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>{{t "Roomy"}}</option> - </select></label> - </div> - <p class="muted small">{{t "The content spans the whole page; how much of it the posts get is set by the column widths on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#columns">{{t "Layout"}}</a> {{t "tab."}}</p> </div> <div class="card" id="header"> <h2>{{t "Header"}}</h2> + <label class="check"><input type="checkbox" name="header_on" data-area="header"{{if $t.HeaderOn}} checked{{end}}> {{t "Show the header"}} <span class="muted">({{t "the top of every page"}})</span></label> <div class="row"> <label>{{t "Background"}}<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label> <label>{{t "Text colour"}}<br><input type="color" name="header_text" value="{{$t.HeaderText}}"></label> @@ -95,19 +96,44 @@ <option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>{{t "Left"}}</option> <option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>{{t "Centred"}}</option> </select></label> - <label>{{t "Image height"}}<br><select name="header_image_height"> - <option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>{{t "Short"}}</option> - <option value="medium"{{if eq $t.HeaderImageHeight "medium"}} selected{{end}}>{{t "Medium"}}</option> - <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>{{t "Tall"}}</option> + <label>{{t "Title size"}}<br><select name="title_size"> + <option value="small"{{if eq $t.TitleSize "small"}} selected{{end}}>{{t "Small"}}</option> + <option value="normal"{{if eq $t.TitleSize "normal"}} selected{{end}}>{{t "Normal"}}</option> + <option value="large"{{if eq $t.TitleSize "large"}} selected{{end}}>{{t "Large"}}</option> </select></label> </div> - <p class="muted small">{{t "The title, logo and menu are header modules — add, remove and order them on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#header">{{t "Layout"}}</a> {{t "tab."}} {{t "The banner below is shown across the top of the header whenever one is chosen."}}</p> - {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" .Blog.Subdomain "images" .Data.images "label" (t "Header image (banner)"))}} + {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Banner image") "hint" (t "Shown across the top of the header whenever one is chosen."))}} + <div class="row"><label>{{t "Banner height"}}<br><select name="header_image_height"> + <option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>{{t "Short"}}</option> + <option value="medium"{{if eq $t.HeaderImageHeight "medium"}} selected{{end}}>{{t "Medium"}}</option> + <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>{{t "Tall"}}</option> + </select></label></div> + {{template "arealist" (dict "area" "header" "root" . "hint" (t "Stack the title, a logo, the menu and a search box in the order you like."))}} </div> <div class="card" id="menu"> <h2>{{t "Menu"}}</h2> - <p class="muted small">{{t "Which links the menu shows, and where it sits, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#menu">{{t "Layout"}}</a> {{t "tab."}} {{t "Here you choose how it looks."}}</p> + <p class="muted small">{{t "The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking “Show in menu” on a page does the same as adding it here."}}</p> + <ul class="modlist plain" id="menulist"> + {{range .Data.menuRows}}{{template "menurow" (dict "i" .I "item" .Item "pos" .Pos)}}{{end}} + </ul> + <p class="muted empty"{{if .Data.menuRows}} hidden{{end}}>{{t "The menu is empty."}}</p> + <div class="addrow cols"> + <div> + <h3>{{t "Add a page"}}</h3> + {{if .Data.freePages}}<label>{{t "Page"}}<br><select name="menu_add_page"><option value="">—</option>{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label> + <button type="button" class="secondary jsonly" data-menu="page">{{t "Add page"}}</button> + {{end}}<p class="muted small nopages"{{if .Data.freePages}} hidden{{end}}>{{t "Every page is already in the menu."}}</p> + </div> + <div> + <h3>{{t "Add a link"}}</h3> + <label>{{t "Text"}}<br><input name="menu_add_label" maxlength="60" placeholder="{{t "My photos"}}"></label> + <label>{{t "Address"}}<br><input name="menu_add_url" maxlength="500" placeholder="https://example.org"></label> + <button type="button" class="secondary jsonly" data-menu="link">{{t "Add link"}}</button> + </div> + </div> + <p class="muted small nojs">{{t "A page or link filled in here is added when you save."}}</p> + <h3>{{t "How the menu looks"}}</h3> <div class="row"> <label>{{t "Alignment"}} <span class="muted">({{t "menu in the header"}})</span><br><select name="nav_align"> <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>{{t "Left"}}</option> @@ -118,9 +144,6 @@ <option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>{{t "Normal"}}</option> <option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>{{t "Uppercase, spaced"}}</option> </select></label> - </div> - <h3>{{t "Menu colours"}}</h3> - <div class="row"> <label>{{t "Background"}}<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label> <label>{{t "Text"}}<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label> </div> @@ -131,8 +154,22 @@ </div> </div> -<div class="card" id="posts"> - <h2>{{t "Posts & dates"}}</h2> +<div class="card" id="content"> + <h2>{{t "Content"}}</h2> + <p class="muted small">{{t "The box your posts and pages are shown in. It spans whatever the side columns leave."}}</p> + <div class="row"> + <label>{{t "Background"}}<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label> + <label>{{t "Box style"}}<br><select name="content_style"> + <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>{{t "Card (shadow, rounded corners)"}}</option> + <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>{{t "Flat"}}</option> + </select></label> + <label>{{t "Padding"}} <span class="muted">({{t "inside the box"}})</span><br><select name="content_padding"> + <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>{{t "Compact"}}</option> + <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>{{t "Normal"}}</option> + <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>{{t "Roomy"}}</option> + </select></label> + </div> + <h3>{{t "Posts"}}</h3> <label class="check"><input type="checkbox" name="show_dates"{{if $t.ShowDates}} checked{{end}}> {{t "Show the date on posts"}}</label> <div class="row"> <label>{{t "Date format"}}<br><select name="date_format"> @@ -140,43 +177,315 @@ <option value="short"{{if eq $t.DateFormat "short"}} selected{{end}}>{{postdate "short" .Data.today}}</option> <option value="iso"{{if eq $t.DateFormat "iso"}} selected{{end}}>{{postdate "iso" .Data.today}}</option> </select></label> + <label>{{t "Posts per page"}}<br><select name="posts_per_page"> + {{range .Data.perPage}}<option value="{{.}}"{{if eq . $t.PostsPerPage}} selected{{end}}>{{.}}</option>{{end}} + </select></label> </div> + <h3>{{t "Around the posts"}}</h3> + <p class="muted small">{{t "Your own HTML before and after the posts — an intro box, a newsletter form, a badge…"}}</p> + {{template "arealist" (dict "area" "above" "root" .)}} + {{template "arealist" (dict "area" "below" "root" .)}} +</div> + +<div class="card" id="columns"> + <h2>{{t "Side columns"}}</h2> + <div class="lay"> + <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">{{t "Header"}}</div> + <div class="lay-cols"> + <div class="lay-col lay-side lay-left{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.rows "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">{{t "Left"}} <span class="pct">{{$t.LeftWidth}}</span>%</div> + <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">{{t "Main content"}} <span class="pct">{{$t.MainWidth}}</span>%</div> + <div class="lay-col lay-side lay-right{{if not $t.RightOn}} off{{end}}{{if not (index .Data.rows "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">{{t "Right"}} <span class="pct">{{$t.RightWidth}}</span>%</div> + </div> + <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">{{t "Footer"}}</div> + </div> + <div class="row"> + <label>{{t "Left column width"}}<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label> + <label>{{t "Right column width"}}<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label> + </div> + <p class="muted small">{{t "Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer."}}</p> + <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> {{t "Always keep the columns' space, even when a column is empty or hidden"}} <span class="muted">({{t "the main content then keeps its width and stays centred"}})</span></label> + <h3>{{t "Left column"}}</h3> + <label class="check"><input type="checkbox" name="left_on" data-area="left"{{if $t.LeftOn}} checked{{end}}> {{t "Show the left column"}}</label> + {{template "arealist" (dict "area" "left" "root" . "hint" (t "A narrow column beside your posts. Good for the menu, an archive or recent posts."))}} + <h3>{{t "Right column"}}</h3> + <label class="check"><input type="checkbox" name="right_on" data-area="right"{{if $t.RightOn}} checked{{end}}> {{t "Show the right column"}}</label> + {{template "arealist" (dict "area" "right" "root" . "hint" (t "A narrow column on the other side of your posts. Same choices as the left one."))}} </div> <div class="card" id="footer"> <h2>{{t "Footer"}}</h2> - <p class="muted small">{{t "The footer text, RSS link and other footer modules live on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#footer">{{t "Layout"}}</a> {{t "tab."}}</p> + <label class="check"><input type="checkbox" name="footer_on" data-area="footer"{{if $t.FooterOn}} checked{{end}}> {{t "Show the footer"}} <span class="muted">({{t "the bottom of every page"}})</span></label> <div class="row"> <label>{{t "Background"}}<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label> <label>{{t "Text colour"}}<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label> + <label>{{t "Alignment"}}<br><select name="footer_align"> + <option value="center"{{if eq $t.FooterAlign "center"}} selected{{end}}>{{t "Centred"}}</option> + <option value="left"{{if eq $t.FooterAlign "left"}} selected{{end}}>{{t "Left"}}</option> + </select></label> </div> + {{template "arealist" (dict "area" "footer" "root" . "hint" (t "A copyright line, the RSS link, a list of your pages."))}} </div> <div class="card" id="logo"> <h2>{{t "Logo & site icon"}}</h2> - <div class="row"> - <label>{{t "Logo size"}}<br><select name="logo_size"> - <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>{{t "Small (48 px tall)"}}</option> - <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>{{t "Medium (80 px)"}}</option> - <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>{{t "Large (140 px)"}}</option> - </select></label> - </div> - {{template "imagepick" (dict "name" "logo" "current" $t.Logo "sub" .Blog.Subdomain "images" .Data.images "label" (t "Logo") "hint" (t "Shown wherever you place a Logo module (Layout tab); it links to your front page."))}} - {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "sub" .Blog.Subdomain "images" .Data.images "label" (t "Favicon") "hint" (t "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon.") "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}} + {{template "imagepick" (dict "name" "logo" "current" $t.Logo "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Logo") "hint" (t "Shown wherever you place a Logo module; it links to your front page."))}} + <div class="row"><label>{{t "Logo size"}}<br><select name="logo_size"> + <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>{{t "Small (48 px tall)"}}</option> + <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>{{t "Medium (80 px)"}}</option> + <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>{{t "Large (140 px)"}}</option> + </select></label></div> + {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Favicon") "hint" (t "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon.") "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}} </div> <div class="savebar"> - <button type="submit">{{t "Save design"}}</button> - <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} ↗</a> + <button type="submit">{{t "Save"}}</button> + <a class="btn secondary" href="/b/{{$sub}}/design" id="discard">{{t "Discard changes"}}</a> + <span class="unsaved" hidden>{{t "Unsaved changes"}}</span> + <a class="viewlink" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} ↗</a> </div> + +{{/* Row templates the script clones: one per area and kind, and the two menu entries. */}} +{{range .Data.templates}}<template id="tpl-{{.Area}}-{{.Kind}}">{{template "modrow" (dict "i" "__I__" "m" .M "pos" 0 "root" $)}}</template> +{{end}}<template id="tpl-menu-page">{{template "menurow" (dict "i" "__I__" "item" .Data.pageItem "pos" 0)}}</template> +<template id="tpl-menu-link">{{template "menurow" (dict "i" "__I__" "item" .Data.linkItem "pos" 0)}}</template> </form> <details class="help reset"> <summary>{{t "Reset to defaults"}}</summary> <p>{{t "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library)."}}</p> - <form method="post" action="/b/{{.Blog.Subdomain}}/design/reset"> + <form method="post" action="/b/{{$sub}}/design/reset"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> <button type="submit" class="danger">{{t "Reset everything"}}</button> </form> </details> +</div> + +{{/* The library panel: filled by the script from /files?kind=image as JSON, one page at a time. */}} +<div class="picker" id="picker" hidden> + <div class="picker-box"> + <div class="picker-head"> + <input type="search" placeholder="{{t "Filter by name…"}}" aria-label="{{t "Filter by name…"}}"> + <span class="muted small pcount">{{tf "%d images" .Data.imageCount}}</span> + <button type="button" class="secondary small" data-pick="close">{{t "Close"}}</button> + </div> + <div class="picks"></div> + <p class="muted small pnone" hidden>{{t "No images match."}}</p> + <div class="pager"><button type="button" class="mini" data-pick="prev">← {{t "Newer"}}</button> <span class="pnum"></span> <button type="button" class="mini" data-pick="next">{{t "Older"}} →</button></div> + </div> +</div> + +<script> +(function () { + var form = document.getElementById("design"); + if (!form || !("content" in document.createElement("template")) || !form.classList || !form.closest) return; + form.className += " js"; + var next = +form.getAttribute("data-next") || 0, dirty = false, sending = false; + function q(sel, el) { return (el || document).querySelector(sel); } + function qa(sel, el) { return Array.prototype.slice.call((el || document).querySelectorAll(sel)); } + function touch() { dirty = true; q(".unsaved").hidden = false; } + form.addEventListener("input", touch); + form.addEventListener("change", touch); + window.addEventListener("beforeunload", function (e) { if (dirty && !sending) { e.preventDefault(); e.returnValue = ""; } }); + q("#discard").addEventListener("click", function (e) { if (dirty && !confirm({{t "Throw away your unsaved changes?"}})) e.preventDefault(); else dirty = false; }); + + // ---- module and menu rows: order, remove, add + function rows(list) { return qa(".modrow", list); } + function refresh(list) { // the empty note and the "add" control of a one-module area + var n = rows(list).length, card = list.parentNode; + var empty = q(".empty", card); if (empty) empty.hidden = n > 0; + var add = q(".addmod", card); if (add && list.getAttribute("data-max")) add.hidden = n >= +list.getAttribute("data-max"); + } + function clone(id) { // a row template with a fresh index in its field names + var tpl = document.getElementById(id); + if (!tpl) return null; + var frag = document.importNode(tpl.content, true), row = q(".modrow", frag), i = next++; + qa("[name]", row).forEach(function (el) { el.name = el.name.replace("__I__", i); }); + return row; + } + form.addEventListener("click", function (e) { + var b = e.target.closest("[data-act],[data-menu],[data-pick],.preset"); + if (!b) return; + var row = b.closest(".modrow"), list = row && row.parentNode; + switch (b.getAttribute("data-act")) { + case "up": if (row.previousElementSibling) list.insertBefore(row, row.previousElementSibling); touch(); return; + case "down": if (row.nextElementSibling) list.insertBefore(row.nextElementSibling, row); touch(); return; + case "remove": + if (row.getAttribute("data-confirm") && !confirm({{t "Remove this module and its text?"}})) return; + list.removeChild(row); refresh(list); touch(); return; + } + switch (b.getAttribute("data-menu")) { + case "page": + var sel = q("[name=menu_add_page]"), opt = sel && sel.options[sel.selectedIndex]; + if (!opt || !opt.value) return; + var pr = clone("tpl-menu-page"); + q("[data-field=page]", pr).value = opt.value; q(".modname", pr).textContent = opt.text; + q("#menulist").appendChild(pr); sel.removeChild(opt); sel.selectedIndex = 0; + q(".nopages", sel.parentNode).hidden = sel.options.length > 1; + refresh(q("#menulist")); touch(); return; + case "link": + var lab = q("[name=menu_add_label]"), url = q("[name=menu_add_url]"); + if (!lab.value.trim() || !url.value.trim()) { (lab.value.trim() ? url : lab).focus(); return; } + var lr = clone("tpl-menu-link"); + q("[data-field=label]", lr).value = lab.value.trim(); q("[data-field=url]", lr).value = url.value.trim(); + q("#menulist").appendChild(lr); lab.value = url.value = ""; + refresh(q("#menulist")); touch(); return; + } + if (b.className.indexOf("preset") >= 0) { // fill the colour inputs; nothing is saved yet + var c = JSON.parse(b.getAttribute("data-colors")); + for (var k in c) { var inp = q("[name=" + k + "]", form); if (inp) inp.value = c[k]; } + q("[name=link_hover_custom]").checked = q("[name=nav_hover_custom]").checked = false; + touch(); return; + } + pickAction(b); + }); + form.addEventListener("change", function (e) { + var t = e.target; + if (t.getAttribute("data-add") && t.value) { // a chosen kind becomes a row at once + var list = q(".modlist", t.closest(".arealist")), row = clone("tpl-" + list.getAttribute("data-area") + "-" + t.value); + if (row) { list.appendChild(row); var d = q("details", row); if (d) d.open = true; refresh(list); } + t.selectedIndex = 0; + } + if (t.getAttribute("data-area")) q(".arealist[data-area=" + t.getAttribute("data-area") + "]").classList.toggle("area-off", !t.checked); + }); + // a heading or text typed into a row shows in its folded summary + form.addEventListener("input", function (e) { + var t = e.target, row = t.closest(".modrow"); + if (!row || !t.getAttribute("data-sum")) return; + var s = q(".modsum", row); if (s) s.textContent = t.value.replace(/\s+/g, " ").slice(0, 60); + }); + qa(".modlist").forEach(refresh); + // the column diagram follows the width boxes + function widths() { + var l = +q("[name=left_width]").value || 0, r = +q("[name=right_width]").value || 0, m = 100 - l - r; + q(".lay-left").style.width = l + "%"; q(".lay-right").style.width = r + "%"; q(".lay-main").style.width = m + "%"; + q(".lay-left .pct").textContent = l; q(".lay-right .pct").textContent = r; q(".lay-main .pct").textContent = m; + } + q("[name=left_width]").addEventListener("input", widths); + q("[name=right_width]").addEventListener("input", widths); + // the side index marks the section in view + var cards = qa(".card[id]", form), links = qa(".sidenav a"), spy; + function section() { + var y = window.pageYOffset + 120, at = cards[0]; + cards.forEach(function (c) { if (c.offsetTop <= y) at = c; }); + return at.id; + } + function mark() { var id = section(); links.forEach(function (a) { a.className = a.getAttribute("href") === "#" + id ? "active" : ""; }); } + window.addEventListener("scroll", function () { clearTimeout(spy); spy = setTimeout(mark, 80); }); + mark(); + + form.addEventListener("submit", function () { + sending = true; + qa(".modlist").forEach(function (list) { rows(list).forEach(function (r, i) { q(".pos", r).value = i; }); }); + q("[name=at]").value = section(); // so the page comes back to the section in view + }); + + // ---- the image library panel: one shared panel, fetched page by page + var panel = document.getElementById("picker"), pick = null, page = 1, filter = "", timer; + if (!window.fetch) { qa(".imagepick .jsonly").forEach(function (el) { el.hidden = true; }); qa(".imagepick select").forEach(function (el) { el.className = ""; }); return; } + function pickAction(b) { + var act = b.getAttribute("data-pick"), ip = b.closest(".imagepick"); + if (act === "open") { pick = ip; panel.hidden = false; q("input", panel).value = filter = ""; load(1); q("input", panel).focus(); } + else if (act === "clear") { choose(ip, "none", ""); } + } + panel.addEventListener("click", function (e) { + var b = e.target.closest("[data-pick],.pick"); + if (!b) return; + if (b.className.indexOf("pick") === 0) { choose(pick, b.getAttribute("data-id"), b.getAttribute("data-name")); panel.hidden = true; return; } + switch (b.getAttribute("data-pick")) { + case "close": panel.hidden = true; break; + case "prev": load(page - 1); break; + case "next": load(page + 1); break; + } + }); + panel.addEventListener("keydown", function (e) { if (e.key === "Escape") panel.hidden = true; }); + q("input", panel).addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(function () { filter = q("input", panel).value.trim(); load(1); }, 250); }); + function load(n) { + var url = "/b/" + form.getAttribute("data-sub") + "/files?kind=image&p=" + n + (filter ? "&q=" + encodeURIComponent(filter) : ""); + fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" }).then(function (r) { return r.json(); }).then(function (d) { + page = d.page; + var grid = q(".picks", panel); grid.innerHTML = ""; + (d.files || []).forEach(function (f) { + var b = document.createElement("button"), img = document.createElement("img"), s = document.createElement("span"); + b.type = "button"; b.className = "pick"; b.setAttribute("data-id", f.id); b.setAttribute("data-name", f.filename); + img.loading = "lazy"; img.src = "/b/" + form.getAttribute("data-sub") + "/media/" + f.id; img.alt = ""; + s.className = "name"; s.textContent = f.filename; + b.appendChild(img); b.appendChild(s); grid.appendChild(b); + }); + q(".pnone", panel).hidden = !!(d.files && d.files.length); + q(".pnum", panel).textContent = d.last > 1 ? d.page + " / " + d.last : ""; + q("[data-pick=prev]", panel).disabled = d.page <= 1; q("[data-pick=next]", panel).disabled = d.page >= d.last; + }); + } + function choose(ip, id, name) { // the select stays the value the form sends + var sel = q("select", ip), pv = q(".preview", ip), found = false; + for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === id) found = true; + if (!found) { var o = document.createElement("option"); o.value = id; o.textContent = name; sel.appendChild(o); } + sel.value = id; + pv.innerHTML = id === "none" ? '<span class="swatch">×</span>' : ""; + if (id !== "none") { var img = document.createElement("img"); img.src = "/b/" + form.getAttribute("data-sub") + "/media/" + id; img.alt = ""; pv.appendChild(img); } + touch(); + } +})(); +</script> {{end}} + +{{/* One area's modules: the list, its empty note and the "add" select. */}} +{{define "arealist"}}{{$r := .root}}{{$area := .area}}{{$rows := index $r.Data.rows $area}}{{$single := index $r.Data.single $area}} +<div class="arealist{{if not ($r.Data.theme.AreaOn $area)}} area-off{{end}}" data-area="{{$area}}"> + {{if $single}}<h4>{{t (index $r.Data.areaNames $area)}}</h4>{{end}} + {{with .hint}}<p class="muted small">{{.}}</p>{{end}} + <ul class="modlist plain" data-area="{{$area}}"{{if $single}} data-max="1"{{end}}> + {{range $rows}}{{template "modrow" (dict "i" .I "m" .M "pos" .Pos "root" $r)}}{{end}} + </ul> + <p class="muted small empty"{{if $rows}} hidden{{end}}>{{if $single}}{{t "Nothing extra."}}{{else if eq $area "header"}}{{t "Nothing here yet."}}{{else}}{{t "Nothing here yet — this area is not shown until you add a module."}}{{end}}</p> + <div class="addmod"{{if and $single $rows}} hidden{{end}}> + <label>{{if $single}}{{t "Add custom HTML"}}{{else}}{{t "Add a module"}}{{end}}<br><select name="add.{{$area}}" data-add="1"><option value="">—</option>{{range index $r.Data.kinds $area}}<option value="{{.}}">{{t (index $r.Data.names .)}}</option>{{end}}</select></label> + </div> +</div>{{end}} + +{{/* One module row. .i is the field index (or __I__ in a template), .m the module. */}} +{{define "modrow"}}{{$i := .i}}{{$m := .m}}{{$r := .root}} +<li class="modrow" data-kind="{{$m.Kind}}"{{if $m.Body}} data-confirm="1"{{end}}> + <input type="hidden" name="mod.{{$i}}.id" value="{{$m.ID}}"><input type="hidden" name="mod.{{$i}}.area" value="{{$m.Area}}"><input type="hidden" name="mod.{{$i}}.kind" value="{{$m.Kind}}"> + <span class="order"><button type="button" class="mini jsonly" data-act="up" title="{{t "Move up"}}">▲</button><button type="button" class="mini jsonly" data-act="down" title="{{t "Move down"}}">▼</button><input type="number" class="nojs pos" name="mod.{{$i}}.pos" value="{{.pos}}" min="0" title="{{t "Position"}}"></span> + <span class="modname">{{t (index $r.Data.names $m.Kind)}}</span> + {{if and (eq $m.Kind "logo") (not $r.Data.theme.Logo)}}<span class="tag off">{{t "no logo chosen"}}</span> <a class="small" href="#logo">{{t "pick one"}}</a>{{end}} + <span class="modact"><label class="check nojs"><input type="checkbox" name="mod.{{$i}}.del"> {{t "Remove"}}</label><button type="button" class="mini danger jsonly" data-act="remove" title="{{t "Remove"}}">✕</button></span> + {{if modsettings $m}}<details class="modset"> + <summary>{{t "Settings"}} <span class="muted modsum">{{modsummary $m}}</span></summary> + {{if or (ne $m.Kind "html") (and (ne $m.Area "above") (ne $m.Area "below"))}}{{if ne $m.Kind "text"}} + <label>{{t "Heading"}} <span class="muted">({{t "optional"}})</span><br><input name="mod.{{$i}}.title" value="{{$m.Title}}" maxlength="120" data-sum="1"></label> + {{end}}{{end}} + {{if eq $m.Kind "recent"}} + <div class="row"><label>{{t "How many posts"}}<br><select name="mod.{{$i}}.count">{{range $n := $r.Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> + <p class="muted small">{{t "The newest published posts from every page of your blog, newest first."}}</p> + {{else if eq $m.Kind "archive"}} + <p class="muted small">{{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}</p> + {{else if eq $m.Kind "sitemap"}} + <p class="muted small">{{t "Links to every page of your blog."}}</p> + {{else if eq $m.Kind "tags"}} + <div class="row"><label>{{t "How many tags"}}<br><select name="mod.{{$i}}.count"><option value="0"{{if eq $m.Count 0}} selected{{end}}>{{t "All"}}</option>{{range $n := $r.Data.tagCounts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> + <p class="muted small">{{t "Your tags with the number of posts in each, most used first. Only published posts count."}}</p> + {{else if eq $m.Kind "tagcloud"}} + <p class="muted small">{{t "Every tag in alphabetical order, bigger the more posts use it."}}</p> + {{else if eq $m.Kind "search"}} + <p class="muted small">{{t "A box that searches the published posts of every page by title and text; the results open on your blog’s search page. Case does not matter, and several words find posts that contain them in that order, with anything in between."}}</p> + {{else if eq $m.Kind "html"}} + <label>HTML<br><textarea name="mod.{{$i}}.body" rows="6" class="mono" spellcheck="false" placeholder="<p>{{t "Hello!"}}</p>" data-sum="1">{{$m.Body}}</textarea></label> + <p class="muted small">{{t "Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most."}}</p> + {{else if eq $m.Kind "text"}} + <label>{{t "Text"}}<br><textarea name="mod.{{$i}}.body" rows="3" placeholder="{{t "© 2026 Me"}}" data-sum="1">{{$m.Body}}</textarea></label> + <p class="muted small">{{t "Plain text; each line becomes its own paragraph."}}</p> + {{end}} + </details>{{end}} +</li>{{end}} + +{{/* One menu entry: a page (fixed) or a link (text and address editable). */}} +{{define "menurow"}}{{$i := .i}}{{$it := .item}} +<li class="modrow menurow"> + <input type="hidden" name="menu.{{$i}}.id" value="{{$it.ID}}"> + <span class="order"><button type="button" class="mini jsonly" data-act="up" title="{{t "Move up"}}">▲</button><button type="button" class="mini jsonly" data-act="down" title="{{t "Move down"}}">▼</button><input type="number" class="nojs pos" name="menu.{{$i}}.pos" value="{{.pos}}" min="0" title="{{t "Position"}}"></span> + {{if $it.PageID}}<input type="hidden" name="menu.{{$i}}.page" value="{{deref64 $it.PageID}}" data-field="page"><span class="modname">{{$it.PageTitle}}</span> <span class="tag">{{t "page"}}</span> + {{else}}<span class="tag">{{t "link"}}</span><span class="linkfields"><input name="menu.{{$i}}.label" value="{{$it.Label}}" maxlength="60" placeholder="{{t "Text"}}" data-field="label"><input name="menu.{{$i}}.url" value="{{$it.URL}}" maxlength="500" placeholder="https://example.org" data-field="url"></span>{{end}} + <span class="modact"><label class="check nojs"><input type="checkbox" name="menu.{{$i}}.del"> {{t "Remove"}}</label><button type="button" class="mini danger jsonly" data-act="remove" title="{{t "Remove"}}">✕</button></span> +</li>{{end}} diff --git a/internal/web/templates/dashboard/layout.html b/internal/web/templates/dashboard/layout.html deleted file mode 100644 index 5c9332a..0000000 --- a/internal/web/templates/dashboard/layout.html +++ /dev/null @@ -1,113 +0,0 @@ -{{define "title"}}{{t "Layout"}} · {{.Blog.Title}}{{end}} -{{define "content"}} -{{$t := .Data.theme}} -{{$u := print "/b/" .Blog.Subdomain "/layout"}} -<div class="pagehead"> - <h1>{{t "Layout"}}</h1> - <p class="lead muted">{{t "Decide what goes where on your blog. Every change here is saved straight away; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} ↗</a> {{t "to see it."}} {{t "Colours and fonts are on the"}} <a href="/b/{{.Blog.Subdomain}}/design">{{t "Design"}}</a> {{t "tab."}}</p> -</div> -<p class="jumps"><a href="#columns">{{t "Columns"}}</a><a href="#header">{{t "Header"}}</a><a href="#left">{{t "Left column"}}</a><a href="#main">{{t "Main content"}}</a><a href="#right">{{t "Right column"}}</a><a href="#footer">{{t "Footer"}}</a><a href="#menu">{{t "Menu"}}</a></p> - -<div class="card" id="columns"> - <h2>{{t "Columns"}}</h2> - <div class="lay"> - <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">{{t "Header"}}{{if not $t.HeaderOn}} ({{t "hidden"}}){{end}}</div> - <div class="lay-cols"> - <div class="lay-col lay-side{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.areas "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">{{t "Left"}} {{$t.LeftWidth}}%</div> - <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">{{t "Main content"}} {{$t.MainWidth}}%</div> - <div class="lay-col lay-side{{if not $t.RightOn}} off{{end}}{{if not (index .Data.areas "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">{{t "Right"}} {{$t.RightWidth}}%</div> - </div> - <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">{{t "Footer"}}{{if not $t.FooterOn}} ({{t "hidden"}}){{end}}</div> - </div> - <form method="post" action="{{$u}}/widths"> - <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <div class="row"> - <label>{{t "Left column width"}}<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label> - <label>{{t "Right column width"}}<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label> - </div> - <p class="muted small">{{t "Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer."}}</p> - <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> {{t "Always keep the columns' space, even when a column is empty or hidden"}} <span class="muted">({{t "the main content then keeps its width and stays centred"}})</span></label> - <p class="actionrow"><button type="submit">{{t "Save columns"}}</button></p> - </form> -</div> - -{{template "area" (dict "area" "header" "root" . "hint" (t "The top of every page. Stack the title, a logo and the menu in the order you like. A banner image can be chosen on the Design tab."))}} -{{template "area" (dict "area" "left" "root" . "hint" (t "A narrow column beside your posts. Good for the menu, an archive or recent posts."))}} - -<div class="card" id="main"> - <div class="cardhead"><h2>{{t "Main content"}}</h2></div> - <p class="muted small">{{t "Your posts and pages always show here. You can add your own HTML before and after them — an intro box, a newsletter form, a badge…"}}</p> - <table class="modules"> - {{range $area := .Data.areaOrder}}{{$mods := index $.Data.areas $area}} - <tr><th>{{t (index $.Data.areaNames $area)}}</th> - {{if $mods}}{{$m := index $mods 0}} - <td>{{t (index $.Data.names $m.Kind)}}{{with modsummary $m}} <span class="muted">— {{.}}</span>{{end}}</td> - <td class="nowrap actions"><a href="{{$u}}/modules/{{$m.ID}}/edit">{{t "edit"}}</a> <a class="danger" href="{{$u}}/modules/{{$m.ID}}/delete">{{t "remove"}}</a></td> - {{else}} - <td class="muted">{{t "Nothing extra."}}</td> - <td class="nowrap actions"><form method="post" action="{{$u}}/areas/{{$area}}/add" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="kind" value="html"><button class="mini">+ {{t "Add custom HTML"}}</button></form></td> - {{end}} - </tr>{{end}} - </table> -</div> - -{{template "area" (dict "area" "right" "root" . "hint" (t "A narrow column on the other side of your posts. Same choices as the left one."))}} -{{template "area" (dict "area" "footer" "root" . "hint" (t "The bottom of every page: a copyright line, the RSS link, a list of your pages."))}} - -<div class="card" id="menu"> - <h2>{{t "Menu"}}</h2> - <p class="muted small">{{t "The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking “Show in menu” on a page does the same as adding it here."}}</p> - {{if .Data.menu}}<table class="modules"> - {{range .Data.menu}}<tr> - <td class="nowrap order"> - <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">▲</button></form> - <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">▼</button></form> - </td> - <td>{{.Text}} {{if .PageID}}<span class="tag">{{t "page"}}</span>{{else}}<span class="tag">{{t "link"}}</span> <span class="muted small">{{.URL}}</span>{{end}}</td> - <td class="nowrap actions"><form method="post" action="{{$u}}/menu/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">{{t "Remove"}}</button></form></td> - </tr>{{end}} - </table>{{else}}<p class="muted">{{t "The menu is empty."}}</p>{{end}} - <div class="cols addrow"> - <form method="post" action="{{$u}}/menu/add-page" class="add"> - <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <h3>{{t "Add a page"}}</h3> - {{if .Data.freePages}}<label>{{t "Page"}}<br><select name="page">{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label> - <button type="submit" class="secondary">{{t "Add page"}}</button> - {{else}}<p class="muted small">{{t "Every page is already in the menu."}}</p>{{end}} - </form> - <form method="post" action="{{$u}}/menu/add-link" class="add"> - <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <h3>{{t "Add a link"}}</h3> - <label>{{t "Text"}}<br><input name="label" maxlength="60" placeholder="{{t "My photos"}}"></label> - <label>{{t "Address"}}<br><input name="url" maxlength="500" placeholder="https://example.org"></label> - <button type="submit" class="secondary">{{t "Add link"}}</button> - </form> - </div> -</div> -{{end}} - -{{/* One area card: on/off switch, its modules in order, and an "add" form. */}} -{{define "area"}}{{$r := .root}}{{$area := .area}}{{$u := print "/b/" $r.Blog.Subdomain "/layout"}}{{$on := $r.Data.theme.AreaOn $area}}{{$mods := index $r.Data.areas $area}} -<div class="card{{if not $on}} area-off{{end}}" id="{{$area}}"> - <div class="cardhead"> - <h2>{{t (index $r.Data.areaNames $area)}}{{if not $on}} <span class="tag off">{{t "hidden"}}</span>{{end}}</h2> - <form method="post" action="{{$u}}/areas/{{$area}}/toggle" class="inline switch"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><button class="mini">{{if $on}}{{t "Hide this area"}}{{else}}{{t "Show this area"}}{{end}}</button></form> - </div> - <p class="muted small">{{.hint}}</p> - {{if $mods}}<table class="modules"> - {{range $mods}}<tr> - <td class="nowrap order"> - <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">▲</button></form> - <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">▼</button></form> - </td> - <td>{{t (index $r.Data.names .Kind)}}{{with modsummary .}} <span class="muted">— {{.}}</span>{{end}} - {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}<span class="tag off">{{t "no logo chosen"}}</span> <span class="muted small">{{t "pick one on the"}} <a href="/b/{{$r.Blog.Subdomain}}/design#logo">{{t "Design"}}</a> {{t "tab."}}</span>{{end}}</td> - <td class="nowrap actions">{{if modsettings .}}<a href="{{$u}}/modules/{{.ID}}/edit">{{t "edit"}}</a> {{end}}<a class="danger" href="{{$u}}/modules/{{.ID}}/delete">{{t "remove"}}</a></td> - </tr>{{end}} - </table>{{else}}<p class="muted">{{if ne $area "header"}}{{t "Nothing here yet — this area is not shown until you add a module."}}{{else}}{{t "Nothing here yet."}}{{end}}</p>{{end}} - <form method="post" action="{{$u}}/areas/{{$area}}/add" class="addmod"> - <input type="hidden" name="_csrf" value="{{$r.CSRF}}"> - <label>{{t "Add a module"}}<br><select name="kind">{{range index $r.Data.kinds $area}}<option value="{{.}}">{{t (index $r.Data.names .)}}</option>{{end}}</select></label> - <button type="submit" class="secondary">{{t "Add"}}</button> - </form> -</div>{{end}} diff --git a/internal/web/templates/dashboard/module_form.html b/internal/web/templates/dashboard/module_form.html deleted file mode 100644 index a0fe95b..0000000 --- a/internal/web/templates/dashboard/module_form.html +++ /dev/null @@ -1,41 +0,0 @@ -{{define "title"}}{{.Data.name}} · {{.Blog.Title}}{{end}} -{{define "content"}} -{{$m := .Data.module}} -<div class="card"> - <h1>{{.Data.name}} <span class="muted small">{{tf "in the %s" (lower .Data.area)}}</span></h1> - <form method="post"> - <input type="hidden" name="_csrf" value="{{.CSRF}}"> - {{if or (eq $m.Kind "recent") (eq $m.Kind "archive") (eq $m.Kind "sitemap") (eq $m.Kind "tags") (eq $m.Kind "tagcloud") (eq $m.Kind "search") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}} - <label>{{t "Heading"}} <span class="muted">({{t "optional"}})</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}{{t "Recent posts"}}{{else if eq $m.Kind "archive"}}{{t "Archive"}}{{else if eq $m.Kind "sitemap"}}{{t "Pages"}}{{else if eq $m.Kind "tags"}}{{t "Tags"}}{{else if eq $m.Kind "tagcloud"}}{{t "Tag cloud"}}{{else if eq $m.Kind "search"}}{{t "Search"}}{{end}}"></label> - {{end}} - {{if eq $m.Kind "recent"}} - <div class="row"><label>{{t "How many posts"}}<br><select name="count">{{range $n := .Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> - <p class="muted small">{{t "The newest published posts from every page of your blog, newest first."}}</p> - {{else if eq $m.Kind "archive"}} - <p class="muted small">{{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}</p> - {{else if eq $m.Kind "sitemap"}} - <p class="muted small">{{t "Links to every page of your blog."}}</p> - {{else if eq $m.Kind "tags"}} - <div class="row"><label>{{t "How many tags"}}<br><select name="count"><option value="0"{{if eq $m.Count 0}} selected{{end}}>{{t "All"}}</option>{{range $n := .Data.tagCounts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> - <p class="muted small">{{t "Your tags with the number of posts in each, most used first. Only published posts count."}}</p> - {{else if eq $m.Kind "tagcloud"}} - <p class="muted small">{{t "Every tag in alphabetical order, bigger the more posts use it."}}</p> - {{else if eq $m.Kind "search"}} - <p class="muted small">{{t "A box that searches the published posts of every page by title and text; the results open on your blog’s search page. Case does not matter, and several words find posts that contain them in that order, with anything in between."}}</p> - {{else if eq $m.Kind "html"}} - <label for="mod-body">HTML</label> - <textarea id="mod-body" name="body" rows="14" class="mono" spellcheck="false" placeholder="<p>{{t "Hello!"}}</p>">{{$m.Body}}</textarea> - <p class="muted small">{{t "Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most."}}</p> - {{else if eq $m.Kind "text"}} - <label for="mod-body">{{t "Text"}}</label> - <textarea id="mod-body" name="body" rows="4" placeholder="{{t "© 2026 Me"}}">{{$m.Body}}</textarea> - <p class="muted small">{{t "Plain text; each line becomes its own paragraph."}}</p> - {{end}} - <p class="actionrow"> - <button type="submit">{{t "Save"}}</button> - <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} ↗</a> - <a href="/b/{{.Blog.Subdomain}}/layout#{{if or (eq $m.Area "above") (eq $m.Area "below")}}main{{else}}{{$m.Area}}{{end}}">{{t "Back to layout"}}</a> - </p> - </form> -</div> -{{end}} diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html index 0f611cd..e239445 100644 --- a/internal/web/templates/dashboard/overview.html +++ b/internal/web/templates/dashboard/overview.html @@ -7,7 +7,7 @@ <div class="card"> <h2>{{t "Quick actions"}}</h2> <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">{{t "Write a new post"}}</a></p> - <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> · <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> · <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> · <a href="/b/{{.Blog.Subdomain}}/files">{{t "Upload files"}}</a></p> + <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> · <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> · <a href="/b/{{.Blog.Subdomain}}/files">{{t "Upload files"}}</a></p> </div> <div class="card"> <h2>{{t "Pages"}}</h2> diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html index cb40eb4..6c3b56d 100644 --- a/internal/web/templates/dashboard/pages.html +++ b/internal/web/templates/dashboard/pages.html @@ -3,7 +3,7 @@ <div class="pagehead"> <h1>{{t "Pages"}}</h1> <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ {{t "New page"}}</a></div> - <p class="lead muted">{{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#menu">{{t "Layout"}}</a> {{t "tab."}}</p> + <p class="lead muted">{{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/design#menu">{{t "Design"}}</a> {{t "tab."}}</p> </div> <div class="card"> <table> diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html index d5b4d25..0171aaa 100644 --- a/internal/web/templates/dashboard/section_form.html +++ b/internal/web/templates/dashboard/section_form.html @@ -22,7 +22,7 @@ <option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>{{t "Plain"}}</option> </select></label> </div> - <p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a>) {{t "the announcement moves to the main content instead."}}</p> + <p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/design#columns">{{t "Design"}}</a>) {{t "the announcement moves to the main content instead."}}</p> <label for="ed-body">{{t "Text"}}</label> {{template "editor" (dict "name" "body" "value" $s.BodyMD "format" $s.Format "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> {{t "Shown on the blog"}}</label> diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html index 40ca33f..5482e65 100644 --- a/internal/web/templates/partials/dashnav.html +++ b/internal/web/templates/partials/dashnav.html @@ -21,7 +21,6 @@ <a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>{{t "Posts"}}</a> <a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>{{t "Pages"}}</a> <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>{{t "Announcements"}}</a> - <a href="{{$b}}layout"{{if hasPrefix .Path (print $b "layout")}} class="active"{{end}}>{{t "Layout"}}</a> <a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>{{t "Design"}}</a> <a href="{{$b}}files"{{if hasPrefix .Path (print $b "files")}} class="active"{{end}}>{{t "Files"}}</a> <a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>{{t "Settings"}}</a> diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html index 911b355..47c2146 100644 --- a/internal/web/templates/partials/imagepick.html +++ b/internal/web/templates/partials/imagepick.html @@ -1,9 +1,20 @@ -{{define "imagepick"}}<div class="imagepick"> +{{/* One image field of the design form. The select lists the recent images + (plus the chosen one) so it works without the script; with it the select is + hidden and "Choose from library…" opens the shared panel at the end of + design.html, which fetches the library page by page. */}} +{{define "imagepick"}}{{$cur := .current}}<div class="imagepick" data-name="{{.name}}"> <label>{{.label}}</label> {{if .hint}}<p class="muted small">{{.hint}}</p>{{end}} - <div class="picks"> - <label class="pick none"><input type="radio" name="{{.name}}" value="none"{{if not .current}} checked{{end}}><span class="swatch">×</span><span class="name">{{t "None"}}</span></label> - {{range .images}}<label class="pick"><input type="radio" name="{{$.name}}" value="{{.ID}}"{{if eq .ID.String $.current}} checked{{end}}><img src="/b/{{$.sub}}/media/{{.ID}}" alt=""><span class="name">{{.Filename}}</span></label>{{end}} + <div class="pickrow"> + <span class="preview">{{if $cur}}<img src="/b/{{.sub}}/media/{{$cur}}" alt="">{{else}}<span class="swatch">×</span>{{end}}</span> + <span class="pickctl"> + <select name="{{.name}}" class="nojs"> + <option value="none"{{if not $cur}} selected{{end}}>{{t "None"}}</option> + {{range .images}}<option value="{{.ID}}"{{if eq .ID.String $cur}} selected{{end}}>{{.Filename}}</option>{{end}} + {{if and $cur (not (index .names $cur))}}<option value="{{$cur}}" selected>{{t "Current image"}}</option>{{end}} + </select> + <span class="jsonly"><button type="button" class="secondary small" data-pick="open">{{t "Choose from library…"}}</button> <button type="button" class="secondary small" data-pick="clear">{{t "None"}}</button></span> + <label class="upload">{{t "…or upload a new one"}}<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label> + </span> </div> - <label>{{t "…or upload a new one"}}<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label> </div>{{end}} diff --git a/internal/web/theme.go b/internal/web/theme.go index 2853667..acfe158 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -34,6 +34,7 @@ type Theme struct { HeaderAlign string `json:"header_align"` // left | center HeaderBg string `json:"header_bg"` HeaderText string `json:"header_text"` + TitleSize string `json:"title_size"` // normal | small | large (the site title in the header) NavAlign string `json:"nav_align"` // left | center | right (header menu) NavStyle string `json:"nav_style"` // normal | uppercase @@ -41,11 +42,13 @@ type Theme struct { NavText string `json:"nav_text"` NavHover string `json:"nav_hover"` // "" = same as NavText - ShowDates bool `json:"show_dates"` - DateFormat string `json:"date_format"` // long | short | iso (rendered by i18n.FormatDate) + ShowDates bool `json:"show_dates"` + DateFormat string `json:"date_format"` // long | short | iso (rendered by i18n.FormatDate) + PostsPerPage int `json:"posts_per_page"` // on listings and tag pages; one of postsPerPageChoices FooterBg string `json:"footer_bg"` FooterColor string `json:"footer_color"` + FooterAlign string `json:"footer_align"` // center | left Logo string `json:"logo"` // image uuid or "" LogoSize string `json:"logo_size"` // small | medium | large @@ -73,16 +76,19 @@ const ( maxColumnsSum = 70 // main keeps at least 30% ) +// postsPerPageChoices are the listing sizes the design form offers; the first is the default. +var postsPerPageChoices = []int{10, 5, 15, 20} + func DefaultTheme() Theme { return Theme{ BgColor: "#e9e6df", BgMode: "cover", ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", LinkUnderline: "always", ContentStyle: "card", ContentPadding: "normal", Font: "sans", HeadingFont: "same", FontSize: "normal", - HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", + HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", TitleSize: "normal", NavAlign: "left", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", - ShowDates: true, DateFormat: "long", - FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", + ShowDates: true, DateFormat: "long", PostsPerPage: postsPerPageChoices[0], + FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterAlign: "center", LogoSize: "medium", HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, } @@ -109,6 +115,15 @@ func pick(v string, allowed ...string) string { return allowed[0] } +func pickInt(v int, allowed ...int) int { + for _, a := range allowed { + if v == a { + return v + } + } + return allowed[0] +} + func color(v, def string) string { v = strings.TrimSpace(v) if hexColor.MatchString(v) { @@ -148,14 +163,17 @@ func (t *Theme) normalize() { t.HeaderAlign = pick(t.HeaderAlign, "left", "center") t.HeaderBg = color(t.HeaderBg, d.HeaderBg) t.HeaderText = color(t.HeaderText, d.HeaderText) + t.TitleSize = pick(t.TitleSize, "normal", "small", "large") t.NavAlign = pick(t.NavAlign, "left", "center", "right") t.NavStyle = pick(t.NavStyle, "normal", "uppercase") t.NavBg = color(t.NavBg, d.NavBg) t.NavText = color(t.NavText, d.NavText) t.NavHover = optColor(t.NavHover) t.DateFormat = pick(t.DateFormat, "long", "short", "iso") + t.PostsPerPage = pickInt(t.PostsPerPage, postsPerPageChoices...) t.FooterBg = color(t.FooterBg, d.FooterBg) t.FooterColor = color(t.FooterColor, d.FooterColor) + t.FooterAlign = pick(t.FooterAlign, "center", "left") t.Logo = imageID(t.Logo) t.LogoSize = pick(t.LogoSize, "medium", "small", "large") t.Favicon = imageID(t.Favicon) @@ -176,11 +194,13 @@ func clampInt(v, lo, hi int) int { return v } -// ThemeFromForm reads the design form on top of the current theme. +// ThemeFromForm reads the design form on top of the current theme: colours, +// fonts, images and the layout switches alike. func ThemeFromForm(cur Theme, f url.Values) Theme { t := cur get := func(k string) string { return strings.TrimSpace(f.Get(k)) } on := func(k string) bool { return f.Get(k) == "on" } + num := func(k string) int { n, _ := strconv.Atoi(get(k)); return n } // Hover colours only apply when their checkbox is ticked; otherwise "inherit". hover := func(k string) string { if on(k + "_custom") { @@ -204,6 +224,7 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.HeaderAlign = get("header_align") t.HeaderBg = get("header_bg") t.HeaderText = get("header_text") + t.TitleSize = get("title_size") t.NavAlign = get("nav_align") t.NavStyle = get("nav_style") t.NavBg = get("nav_bg") @@ -211,24 +232,18 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.NavHover = hover("nav_hover") t.ShowDates = on("show_dates") t.DateFormat = get("date_format") + t.PostsPerPage = num("posts_per_page") t.FooterBg = get("footer_bg") t.FooterColor = get("footer_color") + t.FooterAlign = get("footer_align") t.LogoSize = get("logo_size") t.BgImage = pickImage(t.BgImage, get("bg_image")) t.HeaderImage = pickImage(t.HeaderImage, get("header_image")) t.Favicon = pickImage(t.Favicon, get("favicon")) t.Logo = pickImage(t.Logo, get("logo")) - t.normalize() - return t -} - -// LayoutFromForm reads the columns form of the Layout page; the area -// toggles have their own buttons (SetAreaOn). -func LayoutFromForm(cur Theme, f url.Values) Theme { - t := cur - t.LeftWidth, _ = strconv.Atoi(strings.TrimSpace(f.Get("left_width"))) - t.RightWidth, _ = strconv.Atoi(strings.TrimSpace(f.Get("right_width"))) - t.KeepColumns = f.Get("keep_columns") == "on" + t.HeaderOn, t.LeftOn, t.RightOn, t.FooterOn = on("header_on"), on("left_on"), on("right_on"), on("footer_on") + t.LeftWidth, t.RightWidth = num("left_width"), num("right_width") + t.KeepColumns = on("keep_columns") t.normalize() return t } @@ -248,20 +263,6 @@ func (t Theme) AreaOn(area string) bool { return true } -// SetAreaOn flips an area's switch; unknown areas are ignored. -func (t *Theme) SetAreaOn(area string, on bool) { - switch area { - case "header": - t.HeaderOn = on - case "left": - t.LeftOn = on - case "right": - t.RightOn = on - case "footer": - t.FooterOn = on - } -} - // MainWidth is what the main column gets when both side columns are shown. func (t Theme) MainWidth() int { return 100 - t.LeftWidth - t.RightWidth } @@ -309,6 +310,14 @@ var presets = []Preset{ func Presets() []Preset { return presets } +// Colors is the scheme as the design form's colour inputs, name → value, for +// the script that fills them in. +func (p Preset) Colors() string { + b, _ := json.Marshal(map[string]string{"bg_color": p.Bg, "content_bg": p.ContentBg, "text_color": p.Text, "link_color": p.Link, + "header_bg": p.HeaderBg, "header_text": p.HeaderText, "nav_bg": p.NavBg, "nav_text": p.NavText, "footer_bg": p.FooterBg, "footer_color": p.FooterText}) + return string(b) +} + func PresetByKey(key string) (Preset, bool) { for _, p := range presets { if p.Key == key { @@ -381,6 +390,18 @@ func (t Theme) LogoMaxHeight() string { return "80px" } +// TitleSizeCSS is the site title's font size on wide screens; "normal" is +// left to blog.css so its phone rule keeps working. +func (t Theme) TitleSizeCSS() string { + switch t.TitleSize { + case "small": + return "1.5em" + case "large": + return "2.6em" + } + return "" +} + func (t Theme) HeaderImageMaxHeight() string { switch t.HeaderImageHeight { case "short": @@ -422,11 +443,14 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl body.has-left.has-right .col-main { width:{{.MainWidth}}%; } body.has-left:not(.has-right) .col-main { width:{{sub 100 .LeftWidth}}%; } body.has-right:not(.has-left) .col-main { width:{{sub 100 .RightWidth}}%; } +{{- with .TitleSizeCSS}} + .site-title { font-size:{{.}}; } +{{- end}} } {{- with .PaddingPhone}} @media (max-width: 700px) { .content { padding:{{.}}; } } {{- end}} -.site-footer { background:{{.FooterBg}}; color:{{.FooterColor}}; } +.site-footer { background:{{.FooterBg}}; color:{{.FooterColor}};{{if eq .FooterAlign "left"}} text-align:left;{{end}} } .site-footer a { color:{{.FooterColor}}; } `)) diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 48f380e..aa9b78e 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -96,6 +96,9 @@ func TestThemeNormalizeAndCSS(t *testing.T) { if !th.HeaderOn || !th.LeftOn || !th.RightOn || !th.FooterOn || th.LeftWidth != 15 { t.Errorf("old theme layout defaults: %+v", th) } + if th.PostsPerPage != 10 || th.TitleSize != "normal" || th.FooterAlign != "center" { + t.Errorf("old theme content defaults: %+v", th) + } f := url.Values{"bg_color": {"#ABCDEF"}, "bg_image": {"not-a-uuid"}, "logo_size": {"large"}, "link_hover": {"#ff0000"}, "nav_hover": {"#00ff00"}, "nav_hover_custom": {"on"}, "nav_style": {"uppercase"}, "nav_align": {"center"}, "link_underline": {"hover"}, "heading_font": {"serif"}, "content_style": {"flat"}, "content_padding": {"roomy"}, "date_format": {"iso"}} @@ -123,12 +126,20 @@ func TestThemeNormalizeAndCSS(t *testing.T) { } func TestLayoutTheme(t *testing.T) { - th := LayoutFromForm(DefaultTheme(), url.Values{"left_width": {"40"}, "right_width": {"40"}, "keep_columns": {"on"}}) + // The layout switches ride in the design form: the areas are checkboxes, so an unticked one is off. + th := ThemeFromForm(DefaultTheme(), url.Values{"left_width": {"40"}, "right_width": {"40"}, "keep_columns": {"on"}, "header_on": {"on"}, "left_on": {"on"}, "right_on": {"on"}, + "posts_per_page": {"20"}, "title_size": {"large"}, "footer_align": {"left"}}) if th.LeftWidth != 40 || th.RightWidth != 30 || th.MainWidth() != 30 || !th.KeepColumns { t.Errorf("the right column should give way: %+v", th) } - th = LayoutFromForm(th, url.Values{"left_width": {"junk"}, "right_width": {"20"}}) - if th.LeftWidth != 15 || th.RightWidth != 20 || th.KeepColumns { + if th.FooterOn || !th.HeaderOn || th.PostsPerPage != 20 || th.TitleSize != "large" || th.FooterAlign != "left" { + t.Errorf("switches and the content options: %+v", th) + } + if css := th.CSS(); !strings.Contains(css, ".site-title { font-size:2.6em") || !strings.Contains(css, "text-align:left") { + t.Errorf("css: %s", css) + } + th = ThemeFromForm(th, url.Values{"left_width": {"junk"}, "right_width": {"20"}, "posts_per_page": {"7"}}) + if th.LeftWidth != 15 || th.RightWidth != 20 || th.KeepColumns || th.PostsPerPage != 10 { t.Errorf("junk clamps to the minimum: %+v", th) } css := th.CSS() @@ -138,13 +149,74 @@ func TestLayoutTheme(t *testing.T) { t.Errorf("css missing %q:\n%s", want, css) } } - th.SetAreaOn("left", false) - th.SetAreaOn("bogus", false) - if th.AreaOn("left") || !th.AreaOn("right") || !th.AreaOn("above") { + th.LeftOn = false + if th.AreaOn("left") || !th.AreaOn("above") || !th.AreaOn("bogus") { t.Errorf("area switches: %+v", th) } } +// The design form sends every module as indexed fields; pos orders them and +// add.<area> appends. The index only tells rows apart. +func TestParseModulesForm(t *testing.T) { + f := url.Values{ + "mod.0.id": {"1"}, "mod.0.area": {"header"}, "mod.0.kind": {"title"}, "mod.0.pos": {"1"}, + "mod.1.id": {"2"}, "mod.1.area": {"header"}, "mod.1.kind": {"menu"}, "mod.1.pos": {"0"}, + "mod.2.id": {"3"}, "mod.2.area": {"left"}, "mod.2.kind": {"recent"}, "mod.2.pos": {"0"}, "mod.2.title": {" New "}, "mod.2.count": {"99"}, + "mod.3.id": {"4"}, "mod.3.area": {"footer"}, "mod.3.kind": {"rss"}, "mod.3.pos": {"0"}, "mod.3.del": {"on"}, + "mod.7.id": {"0"}, "mod.7.area": {"below"}, "mod.7.kind": {"html"}, "mod.7.body": {"<b>hi</b>\r\n"}, + "add.left": {"tags"}, "add.footer": {""}, + } + mods, err := parseModules(f, "en", "el") + if err != nil { + t.Fatal(err) + } + var got []string + for _, m := range mods { + got = append(got, m.Area+"/"+m.Kind) + } + if want := "header/menu header/title left/recent left/tags below/html"; strings.Join(got, " ") != want { + t.Errorf("order: %v, want %s", got, want) + } + if mods[2].Title != "New" || mods[2].Count != maxRecentCount || mods[2].ID != 3 || mods[3].ID != 0 || mods[3].Title != "Ετικέτες" || mods[3].Count != 10 || mods[4].Body != "<b>hi</b>\n" { + t.Errorf("fields: %+v", mods) + } + if mods, _ := parseModules(url.Values{"mod.0.area": {"header"}, "mod.0.kind": {"html"}, "add.header": {"rss"}}, "en", "en"); len(mods) != 0 { + t.Errorf("kinds an area does not take are dropped: %+v", mods) + } + // Errors still come with every row, so the form can be shown again as sent. + if mods, err := parseModules(url.Values{"mod.0.area": {"above"}, "mod.0.kind": {"html"}, "add.above": {"html"}}, "en", "en"); err == nil || len(mods) != 2 { + t.Errorf("two modules above the posts should be refused: %v %+v", err, mods) + } + if mods, err := parseModules(url.Values{"mod.0.area": {"footer"}, "mod.0.kind": {"text"}, "mod.0.body": {strings.Repeat("x", maxModuleBytes+1)}}, "en", "en"); err == nil || len(mods) != 1 { + t.Errorf("a long body should be refused: %v %+v", err, mods) + } +} + +func TestParseMenuForm(t *testing.T) { + pages := []store.Page{{ID: 1, Title: "Home", Slug: "home", IsHome: true}, {ID: 2, Title: "About", Slug: "about"}} + f := url.Values{ + "menu.0.id": {"10"}, "menu.0.page": {"2"}, "menu.0.pos": {"1"}, + "menu.1.id": {"11"}, "menu.1.label": {"Photos"}, "menu.1.url": {"example.org/pics"}, "menu.1.pos": {"0"}, + "menu.2.id": {"12"}, "menu.2.page": {"9"}, "menu.2.pos": {"2"}, // deleted meanwhile + "menu.3.id": {"13"}, "menu.3.page": {"2"}, "menu.3.pos": {"3"}, // listed twice + "menu_add_page": {"1"}, "menu_add_label": {"Mail"}, "menu_add_url": {"mailto:me@example.org"}, + } + menu, err := parseMenu(f, "en", pages) + if err != nil { + t.Fatal(err) + } + if len(menu) != 4 || menu[0].ID != 11 || menu[0].URL != "https://example.org/pics" || menu[1].ID != 10 || menu[1].PageTitle != "About" || + menu[2].ID != 0 || !menu[2].IsHome || menu[3].URL != "mailto:me@example.org" || menu[3].Label != "Mail" { + t.Errorf("menu: %+v", menu) + } + if menu, err := parseMenu(url.Values{"menu.0.label": {"x"}, "menu.0.url": {"not a url"}}, "en", pages); err == nil || len(menu) != 1 { + t.Errorf("a bad address should be refused, and kept for fixing: %v %+v", err, menu) + } + if menu, err := parseMenu(url.Values{"menu_add_label": {""}, "menu_add_url": {"https://example.org"}}, "en", pages); err == nil || len(menu) != 1 { + t.Errorf("a link without text should be refused: %v %+v", err, menu) + } +} + func TestBuildLayout(t *testing.T) { mods := []store.Module{{ID: 1, Area: "header", Kind: "title"}, {ID: 2, Area: "left", Kind: "recent", Count: 3}, {ID: 3, Area: "right", Kind: "recent", Count: 8}, {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}, {ID: 7, Area: "right", Kind: "tagcloud"}} @@ -326,8 +398,8 @@ func TestMenuItemLinks(t *testing.T) { func TestRedirectOKKeepsAnchor(t *testing.T) { rec := httptest.NewRecorder() - redirectOK(rec, httptest.NewRequest("GET", "/", nil), "/b/a/layout#menu", "hi there") - if got := rec.Header().Get("Location"); got != "/b/a/layout?ok=hi+there#menu" { + redirectOK(rec, httptest.NewRequest("GET", "/", nil), "/b/a/design#menu", "hi there") + if got := rec.Header().Get("Location"); got != "/b/a/design?ok=hi+there#menu" { t.Errorf("location: %q", got) } } @@ -379,7 +451,7 @@ func TestAllTemplatesParse(t *testing.T) { tpl := newTemplates(false) for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/files.html", "dashboard/settings.html", - "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", + "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", "blog/page.html", "blog/post.html", "blog/tag.html", "blog/search.html", "blog/404.html"} { for _, l := range i18n.Languages() { if _, err := tpl.get(l.Code, name); err != nil { |
