From 3eb04b1a2bdf9e53231fe862cfd76327371a9741 Mon Sep 17 00:00:00 2001 From: gramanas Date: Sat, 12 Sep 2026 11:24:17 +0300 Subject: Initial multi-tenant blog host Go + Postgres application serving a management dashboard on the base domain and one public blog per subdomain. Markdown posts organised in pages, form-based theme customisation, image uploads stored in Postgres, JWT cookie sessions with CSRF, superadmin user management, RSS feeds. Docker/compose deployment and a Makefile-driven dev environment with seed data. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/theme.go | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 internal/web/theme.go (limited to 'internal/web/theme.go') diff --git a/internal/web/theme.go b/internal/web/theme.go new file mode 100644 index 0000000..40efc54 --- /dev/null +++ b/internal/web/theme.go @@ -0,0 +1,212 @@ +package web + +import ( + "bytes" + "encoding/json" + "net/url" + "regexp" + "strings" + "text/template" + + "github.com/google/uuid" +) + +// Theme is the structured, form-editable look of a blog. Stored as jsonb. +type Theme struct { + BgColor string `json:"bg_color"` + BgImage string `json:"bg_image"` // image uuid or "" + BgMode string `json:"bg_mode"` // cover | tile | fixed + + ContentBg string `json:"content_bg"` + TextColor string `json:"text_color"` + LinkColor string `json:"link_color"` + ContentWidth string `json:"content_width"` // narrow | medium | wide + Font string `json:"font"` // sans | serif | mono + FontSize string `json:"font_size"` // small | normal | large + + HeaderShowTitle bool `json:"header_show_title"` + HeaderImage string `json:"header_image"` + 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 + NavBg string `json:"nav_bg"` + NavText string `json:"nav_text"` + NavShowHome bool `json:"nav_show_home"` + + FooterText string `json:"footer_text"` + FooterBg string `json:"footer_bg"` + FooterColor string `json:"footer_color"` +} + +func DefaultTheme() Theme { + return Theme{ + BgColor: "#e9e6df", BgMode: "cover", + ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", + ContentWidth: "medium", Font: "sans", FontSize: "normal", + HeaderShowTitle: true, HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", + NavPosition: "below-header", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true, + FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", + } +} + +// ParseTheme decodes stored JSON on top of the defaults so new fields get sane values. +func ParseTheme(raw json.RawMessage) Theme { + t := DefaultTheme() + if len(raw) > 0 { + _ = json.Unmarshal(raw, &t) + } + t.normalize() + return t +} + +var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +func pick(v string, allowed ...string) string { + 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) { + return strings.ToLower(v) + } + return def +} + +func imageID(v string) string { + if _, err := uuid.Parse(v); err != nil { + return "" + } + return v +} + +// normalize clamps every field to an allowed value so the CSS template can trust them. +func (t *Theme) normalize() { + d := DefaultTheme() + t.BgColor = color(t.BgColor, d.BgColor) + t.BgImage = imageID(t.BgImage) + t.BgMode = pick(t.BgMode, "cover", "tile", "fixed") + t.ContentBg = color(t.ContentBg, d.ContentBg) + t.TextColor = color(t.TextColor, d.TextColor) + t.LinkColor = color(t.LinkColor, d.LinkColor) + t.ContentWidth = pick(t.ContentWidth, "medium", "narrow", "wide") + t.Font = pick(t.Font, "sans", "serif", "mono") + t.FontSize = pick(t.FontSize, "normal", "small", "large") + t.HeaderImage = imageID(t.HeaderImage) + 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.NavBg = color(t.NavBg, d.NavBg) + t.NavText = color(t.NavText, d.NavText) + t.FooterBg = color(t.FooterBg, d.FooterBg) + t.FooterColor = color(t.FooterColor, d.FooterColor) + if len(t.FooterText) > 2000 { + t.FooterText = t.FooterText[:2000] + } +} + +// ThemeFromForm reads the design form on top of the current theme. +func ThemeFromForm(cur Theme, f url.Values) Theme { + t := cur + get := func(k string) string { return strings.TrimSpace(f.Get(k)) } + t.BgColor = get("bg_color") + t.BgMode = get("bg_mode") + t.ContentBg = get("content_bg") + t.TextColor = get("text_color") + t.LinkColor = get("link_color") + t.ContentWidth = get("content_width") + t.Font = get("font") + t.FontSize = get("font_size") + t.HeaderShowTitle = f.Get("header_show_title") == "on" + t.HeaderAlign = get("header_align") + t.HeaderBg = get("header_bg") + t.HeaderText = get("header_text") + t.NavPosition = get("nav_position") + t.NavBg = get("nav_bg") + t.NavText = get("nav_text") + t.NavShowHome = f.Get("nav_show_home") == "on" + t.FooterText = get("footer_text") + t.FooterBg = get("footer_bg") + t.FooterColor = get("footer_color") + if f.Get("bg_image_remove") == "on" { + t.BgImage = "" + } else if v := get("bg_image"); v != "" { + t.BgImage = v + } + if f.Get("header_image_remove") == "on" { + t.HeaderImage = "" + } else if v := get("header_image"); v != "" { + t.HeaderImage = v + } + t.normalize() + return t +} + +func (t Theme) JSON() json.RawMessage { + b, _ := json.Marshal(t) + return b +} + +// Helpers used by the CSS template. +func (t Theme) FontFamily() string { + switch t.Font { + case "serif": + return `Georgia, "Times New Roman", Times, serif` + case "mono": + return `"Courier New", Courier, monospace` + } + return `"Helvetica Neue", Helvetica, Arial, sans-serif` +} + +func (t Theme) FontSizePx() string { + switch t.FontSize { + case "small": + return "15px" + case "large": + return "19px" + } + return "17px" +} + +func (t Theme) MaxWidth() string { + switch t.ContentWidth { + case "narrow": + return "640px" + case "wide": + return "1100px" + } + return "840px" +} + +var themeCSS = template.Must(template.New("theme").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; + {{- else if eq .BgMode "fixed"}} background-size:cover; background-position:center; background-attachment:fixed; background-repeat:no-repeat; + {{- else}} background-repeat:repeat;{{end}} +{{- end}} } +a { color:{{.LinkColor}}; } +.wrap { max-width:{{.MaxWidth}}; margin:0 auto; } +.site-header { background:{{.HeaderBg}}; color:{{.HeaderText}}; text-align:{{.HeaderAlign}}; } +.site-header a { color:{{.HeaderText}}; } +.site-nav { background:{{.NavBg}}; } +.site-nav a { color:{{.NavText}}; } +.content { background:{{.ContentBg}}; } +.site-footer { background:{{.FooterBg}}; color:{{.FooterColor}}; } +.site-footer a { color:{{.FooterColor}}; } +`)) + +// CSS renders the per-blog stylesheet; all values were normalized so it is safe to inline. +func (t Theme) CSS() string { + var b bytes.Buffer + _ = themeCSS.Execute(&b, t) + return b.String() +} -- cgit v1.2.3