From 4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88 Mon Sep 17 00:00:00 2001 From: grm Date: Wed, 16 Sep 2026 19:10:13 +0300 Subject: 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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/design_form.go | 228 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 internal/web/design_form.go (limited to 'internal/web/design_form.go') 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..{id,area,kind,title,body,count,pos,del} and every menu +// entry menu..{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., 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..), 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. 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 +} -- cgit v1.2.3