aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/theme.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web/theme.go')
-rw-r--r--internal/web/theme.go274
1 files changed, 233 insertions, 41 deletions
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}}; }
`))