diff options
| author | grm <grm@eyesin.space> | 2026-09-13 00:38:24 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-13 00:38:24 +0300 |
| commit | d11e4c9544f544b3a024c7ac705a674b4096cf50 (patch) | |
| tree | a60be98a37b764dfcb655b2738c8a991a248103b /internal/web/theme.go | |
| parent | e974eb6f3feb06c8951f85c1d34161938905f807 (diff) | |
| download | blogspace-d11e4c9544f544b3a024c7ac705a674b4096cf50.tar.gz blogspace-d11e4c9544f544b3a024c7ac705a674b4096cf50.tar.bz2 blogspace-d11e4c9544f544b3a024c7ac705a674b4096cf50.zip | |
Add the Layout tab: five areas with modules, columns and a menu editor
A blog page is now header, left column, main content, right column and
footer, each holding an ordered list of modules (blog title, logo, menu,
archive by year/month, recent posts, custom HTML, footer text, RSS link,
site map). Side columns have percentage widths, can be hidden, and can
keep their space when empty; on phones they stack under the posts. The
menu becomes its own ordered list mixing pages and outside links.
Announcements are placed per column at its top or bottom.
Modules and menu items get tables; the migration derives them from each
blog's old theme (nav position, footer text, show-in-nav pages) so
existing blogs look the same. Custom HTML is deliberately stored and
served as-is (owner's decision; see AGENTS.md for why the blast radius
is the blogger's own origin).
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 | 176 |
1 files changed, 118 insertions, 58 deletions
diff --git a/internal/web/theme.go b/internal/web/theme.go index 5ee2398..46067ba 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/url" "regexp" + "strconv" "strings" "text/template" "time" @@ -23,50 +24,69 @@ type Theme struct { LinkColor string `json:"link_color"` LinkHover string `json:"link_hover"` // "" = same as LinkColor LinkUnderline string `json:"link_underline"` // always | hover | never - ContentWidth string `json:"content_width"` // narrow | medium | wide + ContentWidth string `json:"content_width"` // narrow | medium | wide (no side columns) ContentStyle string `json:"content_style"` // card | flat ContentPadding string `json:"content_padding"` // normal | compact | roomy Font string `json:"font"` // sans | serif | mono HeadingFont string `json:"heading_font"` // same | sans | serif | mono FontSize string `json:"font_size"` // small | normal | large - HeaderShowTitle bool `json:"header_show_title"` HeaderImage string `json:"header_image"` HeaderImageHeight string `json:"header_image_height"` // short | medium | tall HeaderAlign string `json:"header_align"` // left | center HeaderBg string `json:"header_bg"` HeaderText string `json:"header_text"` - NavPosition string `json:"nav_position"` // below-header | top-bar | left-sidebar - NavAlign string `json:"nav_align"` // left | center | right (horizontal menus) - NavSidebarWidth string `json:"nav_sidebar_width"` // narrow | normal | wide (sidebar only) - NavStyle string `json:"nav_style"` // normal | uppercase - NavBg string `json:"nav_bg"` - NavText string `json:"nav_text"` - NavHover string `json:"nav_hover"` // "" = same as NavText - NavShowHome bool `json:"nav_show_home"` + NavAlign string `json:"nav_align"` // left | center | right (header menu) + NavStyle string `json:"nav_style"` // normal | uppercase + NavBg string `json:"nav_bg"` + 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 - FooterText string `json:"footer_text"` - FooterBg string `json:"footer_bg"` - FooterColor string `json:"footer_color"` - FooterShowRSS bool `json:"footer_show_rss"` - - Favicon string `json:"favicon"` // image uuid or "" for the Blogspace default + FooterBg string `json:"footer_bg"` + FooterColor string `json:"footer_color"` + + Logo string `json:"logo"` // image uuid or "" + LogoSize string `json:"logo_size"` // small | medium | large + Favicon string `json:"favicon"` // image uuid or "" for the Blogspace default + + // Layout: which areas are shown and how wide the side columns are. What + // each area contains lives in the modules table (store.Module). + HeaderOn bool `json:"header_on"` + LeftOn bool `json:"left_on"` + RightOn bool `json:"right_on"` + FooterOn bool `json:"footer_on"` + LeftWidth int `json:"left_width"` // percent of the page + RightWidth int `json:"right_width"` // percent of the page + // KeepColumns lays the side columns out even when empty or hidden, so + // the main content keeps the same width on every page. + KeepColumns bool `json:"keep_columns"` } +// Areas of the blog page, in page order. above/below sit inside the main column. +var Areas = []string{"header", "left", "above", "below", "right", "footer"} + +const ( + minColumnWidth = 15 + maxColumnWidth = 40 + maxColumnsSum = 70 // main keeps at least 30% +) + func DefaultTheme() Theme { return Theme{ BgColor: "#e9e6df", BgMode: "cover", ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", LinkUnderline: "always", ContentWidth: "medium", ContentStyle: "card", ContentPadding: "normal", Font: "sans", HeadingFont: "same", FontSize: "normal", - HeaderShowTitle: true, HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", - NavPosition: "below-header", NavAlign: "left", NavSidebarWidth: "normal", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true, + HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", + NavAlign: "left", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", ShowDates: true, DateFormat: "long", - FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterShowRSS: true, + FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", + LogoSize: "medium", + HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, } } @@ -131,9 +151,7 @@ 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.NavPosition = pick(t.NavPosition, "below-header", "top-bar", "left-sidebar") t.NavAlign = pick(t.NavAlign, "left", "center", "right") - t.NavSidebarWidth = pick(t.NavSidebarWidth, "normal", "narrow", "wide") t.NavStyle = pick(t.NavStyle, "normal", "uppercase") t.NavBg = color(t.NavBg, d.NavBg) t.NavText = color(t.NavText, d.NavText) @@ -141,10 +159,24 @@ func (t *Theme) normalize() { t.DateFormat = pick(t.DateFormat, "long", "short", "iso") t.FooterBg = color(t.FooterBg, d.FooterBg) t.FooterColor = color(t.FooterColor, d.FooterColor) - if len(t.FooterText) > 2000 { - t.FooterText = t.FooterText[:2000] - } + t.Logo = imageID(t.Logo) + t.LogoSize = pick(t.LogoSize, "medium", "small", "large") t.Favicon = imageID(t.Favicon) + t.LeftWidth = clampInt(t.LeftWidth, minColumnWidth, maxColumnWidth) + t.RightWidth = clampInt(t.RightWidth, minColumnWidth, maxColumnWidth) + if t.LeftWidth+t.RightWidth > maxColumnsSum { // the right column gives way + t.RightWidth = maxColumnsSum - t.LeftWidth + } +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v } // ThemeFromForm reads the design form on top of the current theme. @@ -172,32 +204,71 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.Font = get("font") t.HeadingFont = get("heading_font") t.FontSize = get("font_size") - t.HeaderShowTitle = on("header_show_title") t.HeaderImageHeight = get("header_image_height") t.HeaderAlign = get("header_align") t.HeaderBg = get("header_bg") t.HeaderText = get("header_text") - t.NavPosition = get("nav_position") t.NavAlign = get("nav_align") - t.NavSidebarWidth = get("nav_sidebar_width") t.NavStyle = get("nav_style") t.NavBg = get("nav_bg") t.NavText = get("nav_text") t.NavHover = hover("nav_hover") - t.NavShowHome = on("nav_show_home") t.ShowDates = on("show_dates") t.DateFormat = get("date_format") - t.FooterText = get("footer_text") t.FooterBg = get("footer_bg") t.FooterColor = get("footer_color") - t.FooterShowRSS = on("footer_show_rss") + 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.normalize() + return t +} + +// AreaOn reports whether an area is switched on; above/below are part of the main column and always on. +func (t Theme) AreaOn(area string) bool { + switch area { + case "header": + return t.HeaderOn + case "left": + return t.LeftOn + case "right": + return t.RightOn + case "footer": + return t.FooterOn + } + 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 } + // pickImage applies an imagepick radio: "none" clears, an id selects, and an // empty value (no radio in the form) keeps what was there. func pickImage(cur, v string) string { @@ -324,29 +395,15 @@ func (t Theme) ContentPaddingCSS() (desktop, phone string) { func (t Theme) PaddingDesktop() string { d, _ := t.ContentPaddingCSS(); return d } func (t Theme) PaddingPhone() string { _, p := t.ContentPaddingCSS(); return p } -// SidebarWidthPx is the sidebar width when it differs from blog.css's 180px, else "". -func (t Theme) SidebarWidthPx() string { - if t.NavPosition != "left-sidebar" { - return "" - } - switch t.NavSidebarWidth { - case "narrow": +// LogoMaxHeight sizes the logo image wherever the logo module appears. +func (t Theme) LogoMaxHeight() string { + switch t.LogoSize { + case "small": + return "48px" + case "large": return "140px" - case "wide": - return "240px" } - return "" -} - -// SidebarContentMargin leaves the same 20px gutter blog.css uses. -func (t Theme) SidebarContentMargin() string { - switch t.NavSidebarWidth { - case "narrow": - return "160px" - case "wide": - return "260px" - } - return "" + return "80px" } func (t Theme) HeaderImageMaxHeight() string { @@ -359,7 +416,7 @@ func (t Theme) HeaderImageMaxHeight() string { return "320px" } -var themeCSS = template.Must(template.New("theme").Parse(` +var themeCSS = template.Must(template.New("theme").Funcs(template.FuncMap{"sub": func(a, b int) int { return a - b }}).Parse(` body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.55; {{- if .BgImage}} background-image:url(/media/{{.BgImage}}); {{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat; @@ -383,12 +440,15 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl {{- if .NavHover}} .site-nav a:hover { color:{{.NavHover}}; } {{- end}} -{{- with .SidebarWidthPx}} -.nav-left-sidebar .body-wrap .site-nav, .nav-left-sidebar .body-wrap .sidebar-notices { width:{{.}}; } -.nav-left-sidebar .body-wrap .content { margin-left:{{$.SidebarContentMargin}}; } -@media (max-width: 700px) { .nav-left-sidebar .body-wrap .site-nav, .nav-left-sidebar .body-wrap .sidebar-notices { width:auto; } .nav-left-sidebar .body-wrap .content { margin-left:0; } } -{{- end}} -.content { background:{{.ContentBg}};{{with .PaddingDesktop}} padding:{{.}};{{end}}{{if eq .ContentStyle "flat"}} border-radius:0; box-shadow:none;{{end}} } +.content, .mod { background:{{.ContentBg}};{{if eq .ContentStyle "flat"}} border-radius:0; box-shadow:none;{{end}} } +.content {{"{"}}{{with .PaddingDesktop}} padding:{{.}};{{end}} } +.logo { max-height:{{.LogoMaxHeight}}; } +@media (min-width: 701px) { + .col-left { width:{{.LeftWidth}}%; } .col-right { width:{{.RightWidth}}%; } + 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 .PaddingPhone}} @media (max-width: 700px) { .content { padding:{{.}}; } } {{- end}} |
