aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/theme.go
diff options
context:
space:
mode:
authorgramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
committergramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
commit3eb04b1a2bdf9e53231fe862cfd76327371a9741 (patch)
treeb38b2d82a47233fd8e0bb18c59e4a8f3dd2412d7 /internal/web/theme.go
downloadblogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.gz
blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.bz2
blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.zip
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/theme.go')
-rw-r--r--internal/web/theme.go212
1 files changed, 212 insertions, 0 deletions
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()
+}