aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web')
-rw-r--r--internal/web/design_form.go228
-rw-r--r--internal/web/handlers_blog.go24
-rw-r--r--internal/web/handlers_design.go188
-rw-r--r--internal/web/handlers_files.go8
-rw-r--r--internal/web/handlers_layout.go315
-rw-r--r--internal/web/routes.go15
-rw-r--r--internal/web/static/dashboard.css106
-rw-r--r--internal/web/templates/dashboard/design.html435
-rw-r--r--internal/web/templates/dashboard/layout.html113
-rw-r--r--internal/web/templates/dashboard/module_form.html41
-rw-r--r--internal/web/templates/dashboard/overview.html2
-rw-r--r--internal/web/templates/dashboard/pages.html2
-rw-r--r--internal/web/templates/dashboard/section_form.html2
-rw-r--r--internal/web/templates/partials/dashnav.html1
-rw-r--r--internal/web/templates/partials/imagepick.html21
-rw-r--r--internal/web/theme.go88
-rw-r--r--internal/web/web_test.go90
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"}} &#8599;</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"}} &#8599;</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"}} &#8599;</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"}} &#8599;</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">&larr; {{t "Newer"}}</button> <span class="pnum"></span> <button type="button" class="mini" data-pick="next">{{t "Older"}} &rarr;</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">&times;</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"}}">&#9650;</button><button type="button" class="mini jsonly" data-act="down" title="{{t "Move down"}}">&#9660;</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"}}">&#10005;</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="&lt;p&gt;{{t "Hello!"}}&lt;/p&gt;" 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"}}">&#9650;</button><button type="button" class="mini jsonly" data-act="down" title="{{t "Move down"}}">&#9660;</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"}}">&#10005;</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"}} &#8599;</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"}}">&#9650;</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"}}">&#9660;</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"}}">&#9650;</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"}}">&#9660;</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="&lt;p&gt;{{t "Hello!"}}&lt;/p&gt;">{{$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"}} &#8599;</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> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/files">{{t "Upload files"}}</a></p>
+ <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> &middot; <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">&times;</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">&times;</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 {