aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/theme.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/theme.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/theme.go')
-rw-r--r--internal/web/theme.go88
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}}; }
`))