diff options
| author | grm <grm@eyesin.space> | 2026-09-16 19:10:13 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-16 19:10:13 +0300 |
| commit | 4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88 (patch) | |
| tree | cc39ea660046d9c977647e7c9c36349c126be214 /internal/web/theme.go | |
| parent | 6b6b5d2e35ff182a0732da245f4eb37c8afa0564 (diff) | |
| download | blogspace-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/theme.go')
| -rw-r--r-- | internal/web/theme.go | 88 |
1 files changed, 56 insertions, 32 deletions
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}}; } `)) |
