diff options
| author | grm <grm@eyesin.space> | 2026-09-17 21:29:44 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-17 21:29:44 +0300 |
| commit | e96bbec551c0388e972894b349413fa6ef607352 (patch) | |
| tree | c63b50d515fc0a0f754f970b8d316dd37f73ac80 /internal/web/theme.go | |
| parent | 69912c4ac04ada3286c09400ce523d5d3ab4cfb4 (diff) | |
| download | blogspace-e96bbec551c0388e972894b349413fa6ef607352.tar.gz blogspace-e96bbec551c0388e972894b349413fa6ef607352.tar.bz2 blogspace-e96bbec551c0388e972894b349413fa6ef607352.zip | |
Add custom fonts: upload WOFF2/WOFF/TTF/OTF files and pick them on the Design tab
Fonts become a file kind of their own (sniffed from the bytes like images,
served inline from /media), and the theme gets custom_font and
custom_heading_font: a chosen file is declared as @font-face and put first
in the stack, the built-in Font / Heading font choice behind it as the
fallback, so there is no "custom" enum value and no invalid state. The
pickers are plain selects with an upload input, no script. Fonts uploaded
before this are reclassified by the migration.
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 | 68 |
1 files changed, 52 insertions, 16 deletions
diff --git a/internal/web/theme.go b/internal/web/theme.go index c0c0729..26181c2 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -28,6 +28,11 @@ type Theme struct { Font string `json:"font"` // sans | serif | mono HeadingFont string `json:"heading_font"` // same | sans | serif | mono FontSize string `json:"font_size"` // small | normal | large + // Uploaded fonts (file uuids or ""): when set, the blog is typeset in the + // file and Font/HeadingFont are the stack readers see until it loads, or + // instead of it when their browser cannot use it. + CustomFont string `json:"custom_font"` + CustomHeadingFont string `json:"custom_heading_font"` HeaderImage string `json:"header_image"` HeaderImageHeight string `json:"header_image_height"` // short | medium | tall @@ -142,7 +147,7 @@ func color(v, def string) string { // optColor is for colours that may be unset ("" = inherit). func optColor(v string) string { return color(v, "") } -func imageID(v string) string { +func fileID(v string) string { if _, err := uuid.Parse(v); err != nil { return "" } @@ -153,7 +158,7 @@ func imageID(v string) string { func (t *Theme) normalize() { d := DefaultTheme() t.BgColor = color(t.BgColor, d.BgColor) - t.BgImage = imageID(t.BgImage) + t.BgImage = fileID(t.BgImage) t.BgMode = pick(t.BgMode, "cover", "tile", "fixed") t.ContentBg = color(t.ContentBg, d.ContentBg) t.TextColor = color(t.TextColor, d.TextColor) @@ -165,7 +170,9 @@ func (t *Theme) normalize() { 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.CustomFont = fileID(t.CustomFont) + t.CustomHeadingFont = fileID(t.CustomHeadingFont) + t.HeaderImage = fileID(t.HeaderImage) t.HeaderImageHeight = pick(t.HeaderImageHeight, "medium", "short", "tall") t.HeaderAlign = pick(t.HeaderAlign, "left", "center") t.HeaderBg = color(t.HeaderBg, d.HeaderBg) @@ -184,9 +191,9 @@ func (t *Theme) normalize() { t.FooterBg = color(t.FooterBg, d.FooterBg) t.FooterColor = color(t.FooterColor, d.FooterColor) t.FooterAlign = pick(t.FooterAlign, "center", "left") - t.Logo = imageID(t.Logo) + t.Logo = fileID(t.Logo) t.LogoSize = pick(t.LogoSize, "medium", "small", "large") - t.Favicon = imageID(t.Favicon) + t.Favicon = fileID(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 @@ -250,10 +257,12 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.FooterColor = get("footer_color") t.FooterAlign = get("footer_align") 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.BgImage = pickFile(t.BgImage, get("bg_image")) + t.HeaderImage = pickFile(t.HeaderImage, get("header_image")) + t.Favicon = pickFile(t.Favicon, get("favicon")) + t.Logo = pickFile(t.Logo, get("logo")) + t.CustomFont = pickFile(t.CustomFont, get("custom_font")) + t.CustomHeadingFont = pickFile(t.CustomHeadingFont, get("custom_heading_font")) t.HeaderOn, t.LeftOn, t.RightOn, t.FooterOn = on("header_on"), on("left_on"), on("right_on"), on("footer_on") t.LeftWidth, t.RightWidth = num("left_width"), num("right_width") t.KeepColumns = on("keep_columns") @@ -280,9 +289,9 @@ func (t Theme) AreaOn(area string) bool { // 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 { +// pickFile applies an imagepick or fontpick select: "none" clears, an id +// selects, and an empty value (no select in the form) keeps what was there. +func pickFile(cur, v string) string { switch v { case "": return cur @@ -364,10 +373,31 @@ func fontStack(name string) string { return `"Helvetica Neue", Helvetica, Arial, sans-serif` } -// Helpers used by the CSS template. -func (t Theme) FontFamily() string { return fontStack(t.Font) } +// Helpers used by the CSS template. The @font-face families are named for +// their role, so a stack is the uploaded font first and the built-in choice as +// its fallback. +func (t Theme) FontFamily() string { + if t.CustomFont != "" { + return `"BlogFont", ` + fontStack(t.Font) + } + return fontStack(t.Font) +} + +// HeadingFontFamily builds on the text's stack when the heading font is +// "same", so a custom heading font falls back to the custom text font. +func (t Theme) HeadingFontFamily() string { + stack := t.FontFamily() + if t.HeadingFont != "same" { + stack = fontStack(t.HeadingFont) + } + if t.CustomHeadingFont != "" { + return `"BlogHeadingFont", ` + stack + } + return stack +} -func (t Theme) HeadingFontFamily() string { return fontStack(t.HeadingFont) } +// HeadingFontSet says whether headings need a font-family rule of their own. +func (t Theme) HeadingFontSet() bool { return t.HeadingFont != "same" || t.CustomHeadingFont != "" } func (t Theme) FontSizePx() string { switch t.FontSize { @@ -436,13 +466,19 @@ func (t Theme) HeaderImageMaxHeight() string { } var themeCSS = template.Must(template.New("theme").Funcs(template.FuncMap{"sub": func(a, b int) int { return a - b }}).Parse(` +{{- if .CustomFont}} +@font-face { font-family:"BlogFont"; src:url(/media/{{.CustomFont}}); font-display:swap; } +{{- end}} +{{- if .CustomHeadingFont}} +@font-face { font-family:"BlogHeadingFont"; src:url(/media/{{.CustomHeadingFont}}); font-display:swap; } +{{- end}} 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}} } -{{- if ne .HeadingFont "same"}} +{{- if .HeadingFontSet}} 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}} } |
