From e974eb6f3feb06c8951f85c1d34161938905f807 Mon Sep 17 00:00:00 2001 From: grm Date: Sat, 12 Sep 2026 19:47:28 +0300 Subject: Add a favicon and rework the Design page Blogs had no icon at all, and browsers probing /favicon.ico ended up rendering the 404 page. Ship a default paper & ink icon (svg + png) and let each blog replace it from the Design page; /favicon.ico now serves the default or redirects to the blog's own image. ICO uploads accepted. The Design page gains colour schemes, reset to defaults, thumbnail image pickers, jump links and a sticky save bar, plus new options: heading font, link underline/hover, content box style and padding, header image height, post dates and format, footer RSS toggle, menu alignment/style/hover and sidebar width. The Menu card picks the position with illustrated tiles and shows only the options that apply to it, using CSS :checked rules rather than JavaScript. All new defaults reproduce the previous look, so existing blogs are unchanged until edited. Theme is jsonb, so no migration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/theme.go | 274 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 233 insertions(+), 41 deletions(-) (limited to 'internal/web/theme.go') diff --git a/internal/web/theme.go b/internal/web/theme.go index 40efc54..5ee2398 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" "text/template" + "time" "github.com/google/uuid" ) @@ -17,37 +18,55 @@ type Theme struct { 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 + ContentBg string `json:"content_bg"` + TextColor string `json:"text_color"` + 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 + 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"` - HeaderAlign string `json:"header_align"` // left | center - HeaderBg string `json:"header_bg"` - HeaderText string `json:"header_text"` + 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 - NavBg string `json:"nav_bg"` - NavText string `json:"nav_text"` - NavShowHome bool `json:"nav_show_home"` + 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"` - FooterText string `json:"footer_text"` - FooterBg string `json:"footer_bg"` - FooterColor string `json:"footer_color"` + 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 } 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", + 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, + ShowDates: true, DateFormat: "long", + FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterShowRSS: true, } } @@ -80,6 +99,9 @@ func color(v, def string) string { return def } +// optColor is for colours that may be unset ("" = inherit). +func optColor(v string) string { return color(v, "") } + func imageID(v string) string { if _, err := uuid.Parse(v); err != nil { return "" @@ -96,68 +118,165 @@ func (t *Theme) normalize() { t.ContentBg = color(t.ContentBg, d.ContentBg) t.TextColor = color(t.TextColor, d.TextColor) t.LinkColor = color(t.LinkColor, d.LinkColor) + t.LinkHover = optColor(t.LinkHover) + t.LinkUnderline = pick(t.LinkUnderline, "always", "hover", "never") t.ContentWidth = pick(t.ContentWidth, "medium", "narrow", "wide") + t.ContentStyle = pick(t.ContentStyle, "card", "flat") + t.ContentPadding = pick(t.ContentPadding, "normal", "compact", "roomy") t.Font = pick(t.Font, "sans", "serif", "mono") + t.HeadingFont = pick(t.HeadingFont, "same", "sans", "serif", "mono") t.FontSize = pick(t.FontSize, "normal", "small", "large") t.HeaderImage = imageID(t.HeaderImage) + t.HeaderImageHeight = pick(t.HeaderImageHeight, "medium", "short", "tall") 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) + t.NavHover = optColor(t.NavHover) + 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.Favicon = imageID(t.Favicon) } // 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)) } + on := func(k string) bool { return f.Get(k) == "on" } + // Hover colours only apply when their checkbox is ticked; otherwise "inherit". + hover := func(k string) string { + if on(k + "_custom") { + return get(k) + } + return "" + } 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.LinkHover = hover("link_hover") + t.LinkUnderline = get("link_underline") t.ContentWidth = get("content_width") + t.ContentStyle = get("content_style") + t.ContentPadding = get("content_padding") t.Font = get("font") + t.HeadingFont = get("heading_font") t.FontSize = get("font_size") - t.HeaderShowTitle = f.Get("header_show_title") == "on" + 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.NavShowHome = f.Get("nav_show_home") == "on" + 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") - 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.FooterShowRSS = on("footer_show_rss") + t.BgImage = pickImage(t.BgImage, get("bg_image")) + t.HeaderImage = pickImage(t.HeaderImage, get("header_image")) + t.Favicon = pickImage(t.Favicon, get("favicon")) t.normalize() return t } +// 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 { + switch v { + case "": + return cur + case "none": + return "" + } + return v +} + 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 { +// FormatDate renders post dates in the blogger's chosen style. +func (t Theme) FormatDate(at time.Time) string { + switch t.DateFormat { + case "short": + return at.Format("2 Jan 2006") + case "iso": + return at.Format("2006-01-02") + } + return at.Format("2 January 2006") +} + +// ---- presets ---------------------------------------------------------------- + +// Preset is a named colour scheme; applying one touches only colours. +type Preset struct { + Key, Name string + Bg, ContentBg, Text, Link string + HeaderBg, HeaderText, NavBg, NavText string + FooterBg, FooterText string +} + +var presets = []Preset{ + {Key: "paper", Name: "Paper", Bg: "#e9e6df", ContentBg: "#ffffff", Text: "#222222", Link: "#1a5fb4", + HeaderBg: "#2f3a4a", HeaderText: "#ffffff", NavBg: "#ffffff", NavText: "#222222", FooterBg: "#2f3a4a", FooterText: "#d0d5dc"}, + {Key: "night", Name: "Night", Bg: "#15171c", ContentBg: "#1f2229", Text: "#e6e6e6", Link: "#8ab4f8", + HeaderBg: "#0d0f13", HeaderText: "#f2f2f2", NavBg: "#1a1d23", NavText: "#dcdcdc", FooterBg: "#0d0f13", FooterText: "#9a9a9a"}, + {Key: "ocean", Name: "Ocean", Bg: "#dfeef4", ContentBg: "#ffffff", Text: "#1b2b34", Link: "#0e7490", + HeaderBg: "#0f4c5c", HeaderText: "#e6f7fb", NavBg: "#e6f4f8", NavText: "#0f4c5c", FooterBg: "#0f4c5c", FooterText: "#bfe3ec"}, + {Key: "forest", Name: "Forest", Bg: "#e4e9df", ContentBg: "#fbfcf8", Text: "#1f2a1f", Link: "#2f6b3a", + HeaderBg: "#243d2a", HeaderText: "#eef5ea", NavBg: "#eef3e8", NavText: "#243d2a", FooterBg: "#243d2a", FooterText: "#bccfb8"}, + {Key: "slate", Name: "Slate", Bg: "#f0f0f0", ContentBg: "#ffffff", Text: "#2a2a2a", Link: "#4a4a4a", + HeaderBg: "#ffffff", HeaderText: "#111111", NavBg: "#f7f7f7", NavText: "#333333", FooterBg: "#e5e5e5", FooterText: "#555555"}, + {Key: "rose", Name: "Rose", Bg: "#f7e9e6", ContentBg: "#fffaf8", Text: "#3a2426", Link: "#b03a48", + HeaderBg: "#7a2e3a", HeaderText: "#fdf1f2", NavBg: "#fbe4e4", NavText: "#7a2e3a", FooterBg: "#7a2e3a", FooterText: "#e9c3c8"}, +} + +func Presets() []Preset { return presets } + +func PresetByKey(key string) (Preset, bool) { + for _, p := range presets { + if p.Key == key { + return p, true + } + } + return Preset{}, false +} + +// WithPreset swaps in a scheme's colours and drops custom hover colours; images +// and layout choices are untouched. +func (t Theme) WithPreset(p Preset) Theme { + t.BgColor, t.ContentBg, t.TextColor, t.LinkColor = p.Bg, p.ContentBg, p.Text, p.Link + t.HeaderBg, t.HeaderText, t.NavBg, t.NavText = p.HeaderBg, p.HeaderText, p.NavBg, p.NavText + t.FooterBg, t.FooterColor = p.FooterBg, p.FooterText + t.LinkHover, t.NavHover = "", "" + t.normalize() + return t +} + +// ---- css -------------------------------------------------------------------- + +func fontStack(name string) string { + switch name { case "serif": return `Georgia, "Times New Roman", Times, serif` case "mono": @@ -166,6 +285,11 @@ func (t Theme) FontFamily() string { return `"Helvetica Neue", Helvetica, Arial, sans-serif` } +// Helpers used by the CSS template. +func (t Theme) FontFamily() string { return fontStack(t.Font) } + +func (t Theme) HeadingFontFamily() string { return fontStack(t.HeadingFont) } + func (t Theme) FontSizePx() string { switch t.FontSize { case "small": @@ -186,6 +310,55 @@ func (t Theme) MaxWidth() string { return "840px" } +// ContentPaddingCSS returns desktop and phone paddings; "normal" is left to blog.css. +func (t Theme) ContentPaddingCSS() (desktop, phone string) { + switch t.ContentPadding { + case "compact": + return "1em 1.2em", "0.7em" + case "roomy": + return "2.2em 3em", "1.4em" + } + return "", "" +} + +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": + 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 "" +} + +func (t Theme) HeaderImageMaxHeight() string { + switch t.HeaderImageHeight { + case "short": + return "180px" + case "tall": + return "520px" + } + return "320px" +} + 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}}); @@ -193,13 +366,32 @@ body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-famil {{- 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}}; } +{{- if ne .HeadingFont "same"}} +h1, h2, h3, h4, h5, h6 { font-family:{{.HeadingFontFamily}}; } +{{- end}} +a { color:{{.LinkColor}};{{if eq .LinkUnderline "always"}} text-decoration:underline;{{else}} text-decoration:none;{{end}} } +{{- if or .LinkHover (eq .LinkUnderline "hover")}} +a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderline "hover"}} text-decoration:underline;{{end}} } +{{- end}} .wrap { max-width:{{.MaxWidth}}; margin:0 auto; } .site-header { background:{{.HeaderBg}}; color:{{.HeaderText}}; text-align:{{.HeaderAlign}}; } .site-header a { color:{{.HeaderText}}; } +.header-image img { max-height:{{.HeaderImageMaxHeight}}; } .site-nav { background:{{.NavBg}}; } -.site-nav a { color:{{.NavText}}; } -.content { background:{{.ContentBg}}; } +.nav-inner { text-align:{{.NavAlign}}; } +.site-nav a { color:{{.NavText}};{{if eq .NavStyle "uppercase"}} text-transform:uppercase; letter-spacing:0.05em; font-size:0.9em;{{end}} } +{{- 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}} } +{{- with .PaddingPhone}} +@media (max-width: 700px) { .content { padding:{{.}}; } } +{{- end}} .site-footer { background:{{.FooterBg}}; color:{{.FooterColor}}; } .site-footer a { color:{{.FooterColor}}; } `)) -- cgit v1.2.3