aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/design_form.go
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-16 19:10:13 +0300
committergrm <grm@eyesin.space>2026-09-16 19:10:13 +0300
commit4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88 (patch)
treecc39ea660046d9c977647e7c9c36349c126be214 /internal/web/design_form.go
parent6b6b5d2e35ff182a0732da245f4eb37c8afa0564 (diff)
downloadblogspace-4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88.tar.gz
blogspace-4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88.tar.bz2
blogspace-4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88.zip
Merge the Layout and Design tabs into one form with a single Save
The look and the layout of a blog were split over two tabs that cross-linked each other, and the Layout tab saved every toggle, move and add straight away through a dozen tiny forms. Now one Design tab holds it all, organised by part of the blog (colours & fonts, header, menu, content, side columns, footer, logo & icon) behind a sticky side index, and one Save stores the theme, every module and the menu atomically (BlogStore.SaveDesign). Module settings are edited inline; Discard changes throws the edits away; a validation error shows the form again exactly as it was sent. The image pickers no longer render the whole library four times over: a select of the newest images, and a script-driven panel that fetches the library page by page (GET /files as JSON) with lazy thumbnails. New options: posts per page, site title size, footer alignment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/design_form.go')
-rw-r--r--internal/web/design_form.go228
1 files changed, 228 insertions, 0 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
+}