Add theme preset UI and Docker compose

- /edit/theme-settings now lists user-defined presets with preview
  swatches, edit and delete actions
- Add/edit preset form at /edit/theme-settings/presets/new and /{key}
- Built-in catalog (14 themes from docs/themes.md) with one-click import
- docker-compose.yml using ghcr.io/uhlwoogi/modern-glance:latest
- Dockerfile gains OCI source label for GHCR repo linking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
uhlwoogi
2026-05-01 13:53:46 +00:00
co-authored by Claude Sonnet 4.6
parent 7a0a4b9780
commit 2351d909ca
8 changed files with 516 additions and 28 deletions
+127
View File
@@ -1179,6 +1179,133 @@ func (a *application) handleAdminUpdateTheme(w http.ResponseWriter, r *http.Requ
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/theme-settings", http.StatusSeeOther)
}
// ---------- theme presets ----------
func removeKeyFromMapping(m *yaml.Node, key string) {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
m.Content = append(m.Content[:i], m.Content[i+2:]...)
return
}
}
}
func (a *application) handleAdminCreateOrUpdatePreset(w http.ResponseWriter, r *http.Request) {
if !a.adminAccessAllowed(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
key := r.PathValue("key")
if key == "" {
key = strings.TrimSpace(r.FormValue("name"))
}
if key == "" {
adminError(w, http.StatusBadRequest, "preset name is required")
return
}
for _, ch := range key {
if ch == ' ' || ch == '\t' || ch == '\n' || ch == ':' || ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == '|' || ch == '>' || ch == '&' || ch == '*' {
adminError(w, http.StatusBadRequest, "preset name must not contain spaces or special characters")
return
}
}
editor, err := loadConfigEditor(a.ConfigPath)
if err != nil {
adminError(w, http.StatusInternalServerError, err.Error())
return
}
theme, err := findOrCreateKey(editor.topMapping(), "theme", true, yaml.MappingNode)
if err != nil {
adminError(w, http.StatusInternalServerError, err.Error())
return
}
presets, err := findOrCreateKey(theme, "presets", true, yaml.MappingNode)
if err != nil {
adminError(w, http.StatusInternalServerError, err.Error())
return
}
preset, err := findOrCreateKey(presets, key, true, yaml.MappingNode)
if err != nil {
adminError(w, http.StatusInternalServerError, err.Error())
return
}
fields := make(map[string]interface{})
for fkey := range themeColorKeys {
raw := strings.TrimSpace(r.FormValue(fkey))
if raw == "" {
fields[fkey] = nil
continue
}
h, s, l, err := hexToHSL(raw)
if err != nil {
adminError(w, http.StatusBadRequest, err.Error())
return
}
fields[fkey] = hslToYAMLString(h, s, l)
}
for fkey := range themeNumberKeys {
raw := strings.TrimSpace(r.FormValue(fkey))
if raw == "" {
fields[fkey] = nil
continue
}
f, err := strconv.ParseFloat(raw, 64)
if err != nil {
adminError(w, http.StatusBadRequest, fmt.Sprintf("%s: %v", fkey, err))
return
}
fields[fkey] = f
}
raw := strings.TrimSpace(r.FormValue("light"))
fields["light"] = raw == "on" || raw == "true" || raw == "1"
if err := applyFieldsToWidget(preset, fields); err != nil {
adminError(w, http.StatusBadRequest, err.Error())
return
}
if err := editor.save(); err != nil {
adminError(w, http.StatusBadRequest, err.Error())
return
}
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/theme-settings", http.StatusSeeOther)
}
func (a *application) handleAdminDeletePreset(w http.ResponseWriter, r *http.Request) {
if !a.adminAccessAllowed(w, r) {
return
}
key := r.PathValue("key")
editor, err := loadConfigEditor(a.ConfigPath)
if err != nil {
adminError(w, http.StatusInternalServerError, err.Error())
return
}
theme, err := findOrCreateKey(editor.topMapping(), "theme", false, yaml.MappingNode)
if err != nil {
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/theme-settings", http.StatusSeeOther)
return
}
presetsNode, err := findOrCreateKey(theme, "presets", false, yaml.MappingNode)
if err != nil {
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/theme-settings", http.StatusSeeOther)
return
}
removeKeyFromMapping(presetsNode, key)
if err := editor.save(); err != nil {
adminError(w, http.StatusBadRequest, err.Error())
return
}
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/theme-settings", http.StatusSeeOther)
}
// ---------- site settings (branding) ----------
var brandingFieldKeys = []string{
+206
View File
@@ -19,6 +19,7 @@ var (
adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html")
adminSiteSettingsTemplate = mustParseTemplate("admin-site-settings.html", "document.html", "footer.html")
adminThemeSettingsTemplate = mustParseTemplate("admin-theme-settings.html", "document.html", "footer.html")
adminThemePresetTemplate = mustParseTemplate("admin-theme-preset.html", "document.html", "footer.html")
)
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
@@ -264,6 +265,7 @@ type adminTemplateData struct {
SiteSettings *adminSiteSettings
ThemeSettings *adminThemeSettings
PresetForm *adminPresetFormData
Backups []adminBackupSummary
}
@@ -278,6 +280,118 @@ type adminThemeSettings struct {
ContrastMultiplier float32
TextSaturationMultiplier float32
CustomCSSFile string
Presets []adminPresetItem
CatalogThemes []catalogTheme
}
type adminPresetItem struct {
Key string
BackgroundColorHex string
PrimaryColorHex string
PositiveColorHex string
NegativeColorHex string
Light bool
ContrastMultiplier float32
TextSaturationMultiplier float32
PreviewHTML template.HTML
}
type adminPresetFormData struct {
Key string
BackgroundColorHex string
PrimaryColorHex string
PositiveColorHex string
NegativeColorHex string
Light bool
ContrastMultiplier float32
TextSaturationMultiplier float32
IsNew bool
ErrorMessage string
}
type catalogTheme struct {
Name string
Key string
PreviewHTML template.HTML
BgHex string
PrimaryHex string
PositiveHex string
NegativeHex string
Light bool
CM float32
TSM float32
}
var builtinThemeCatalog []catalogTheme
func parseHSLStr(s string) *hslColorField {
if s == "" {
return nil
}
matches := hslColorFieldPattern.FindStringSubmatch(s)
if len(matches) != 4 {
return nil
}
h, _ := strconv.ParseFloat(matches[1], 64)
sat, _ := strconv.ParseFloat(matches[2], 64)
l, _ := strconv.ParseFloat(matches[3], 64)
return &hslColorField{H: h, S: sat, L: l}
}
func safeHex(c *hslColorField) string {
if c == nil {
return ""
}
return c.ToHex()
}
func init() {
type rawEntry struct {
name, key, bg, primary, positive, negative string
cm, tsm float32
light bool
}
entries := []rawEntry{
{"Teal City", "teal-city", "225 14 15", "157 47 65", "", "", 1.1, 0, false},
{"Catppuccin Frappe", "catppuccin-frappe", "229 19 23", "222 74 74", "96 44 68", "359 68 71", 1.2, 0, false},
{"Catppuccin Macchiato", "catppuccin-macchiato", "232 23 18", "220 83 75", "105 48 72", "351 74 73", 1.2, 0, false},
{"Catppuccin Mocha", "catppuccin-mocha", "240 21 15", "217 92 83", "115 54 76", "347 70 65", 1.2, 0, false},
{"Camouflage", "camouflage", "186 21 20", "97 13 80", "", "", 1.2, 0, false},
{"Gruvbox Dark", "gruvbox-dark", "0 0 16", "43 59 81", "61 66 44", "6 96 59", 0, 0, false},
{"Kanagawa Dark", "kanagawa-dark", "240 13 14", "51 33 68", "", "358 100 68", 1.2, 0, false},
{"Tucan", "tucan", "50 1 6", "24 97 58", "", "209 88 54", 0, 0, false},
{"Dracula", "dracula", "231 15 21", "265 89 79", "135 94 66", "0 100 67", 1.2, 0, false},
{"Shades of Purple", "shades-of-purple", "243 33 25", "50 100 49", "98 82 71", "12 77 52", 1.2, 0, false},
{"Neon Pink", "neon-pink", "240 27 11", "321 100 71", "165 78 51", "360 100 71", 1.5, 0, false},
{"Catppuccin Latte", "catppuccin-latte", "220 23 95", "220 91 54", "109 58 40", "347 87 44", 1.0, 0, true},
{"Peachy", "peachy", "28 40 77", "155 100 20", "", "0 100 60", 1.1, 0.5, true},
{"Zebra", "zebra", "0 0 95", "0 0 10", "", "0 90 50", 0, 0, true},
}
for _, e := range entries {
p := themeProperties{
BackgroundColor: parseHSLStr(e.bg),
PrimaryColor: parseHSLStr(e.primary),
PositiveColor: parseHSLStr(e.positive),
NegativeColor: parseHSLStr(e.negative),
ContrastMultiplier: e.cm,
TextSaturationMultiplier: e.tsm,
Light: e.light,
Key: e.key,
}
_ = p.init()
builtinThemeCatalog = append(builtinThemeCatalog, catalogTheme{
Name: e.name,
Key: e.key,
PreviewHTML: p.PreviewHTML,
BgHex: safeHex(p.BackgroundColor),
PrimaryHex: safeHex(p.PrimaryColor),
PositiveHex: safeHex(p.PositiveColor),
NegativeHex: safeHex(p.NegativeColor),
Light: e.light,
CM: e.cm,
TSM: e.tsm,
})
}
}
type adminSiteSettings struct {
@@ -462,6 +576,39 @@ func hexOf(c *hslColorField) string {
return c.ToHex()
}
func (a *application) freshPresetsFromDisk() []adminPresetItem {
contents, _, err := parseYAMLIncludes(a.ConfigPath)
if err != nil {
return nil
}
cfg, err := newConfigFromYAML(contents)
if err != nil {
return nil
}
var keys []string
for k := range cfg.Theme.Presets.Items() {
keys = append(keys, k)
}
items := make([]adminPresetItem, 0, len(keys))
for _, key := range keys {
p, _ := cfg.Theme.Presets.Get(key)
p.Key = key
_ = p.init()
items = append(items, adminPresetItem{
Key: key,
BackgroundColorHex: safeHex(p.BackgroundColor),
PrimaryColorHex: safeHex(p.PrimaryColor),
PositiveColorHex: safeHex(p.PositiveColor),
NegativeColorHex: safeHex(p.NegativeColor),
Light: p.Light,
ContrastMultiplier: p.ContrastMultiplier,
TextSaturationMultiplier: p.TextSaturationMultiplier,
PreviewHTML: p.PreviewHTML,
})
}
return items
}
func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Request) {
if !a.adminAccessAllowed(w, r) {
return
@@ -477,12 +624,71 @@ func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Re
ContrastMultiplier: t.ContrastMultiplier,
TextSaturationMultiplier: t.TextSaturationMultiplier,
CustomCSSFile: t.CustomCSSFile,
Presets: a.freshPresetsFromDisk(),
CatalogThemes: builtinThemeCatalog,
}
data := adminTemplateData{App: a, ThemeSettings: settings}
a.populateTemplateRequestData(&data.Request, r)
renderAdminTemplate(w, adminThemeSettingsTemplate, data)
}
func (a *application) handleAdminThemePreset(w http.ResponseWriter, r *http.Request) {
if !a.adminAccessAllowed(w, r) {
return
}
key := r.PathValue("key")
var form adminPresetFormData
if key == "" || key == "new" {
form.IsNew = true
q := r.URL.Query()
form.Key = q.Get("name")
form.BackgroundColorHex = q.Get("bg")
form.PrimaryColorHex = q.Get("primary")
form.PositiveColorHex = q.Get("positive")
form.NegativeColorHex = q.Get("negative")
form.Light = q.Get("light") == "true"
if cm := q.Get("cm"); cm != "" {
if v, err := strconv.ParseFloat(cm, 32); err == nil {
form.ContrastMultiplier = float32(v)
}
}
if tsm := q.Get("tsm"); tsm != "" {
if v, err := strconv.ParseFloat(tsm, 32); err == nil {
form.TextSaturationMultiplier = float32(v)
}
}
} else {
presets := a.freshPresetsFromDisk()
found := false
for _, p := range presets {
if p.Key == key {
form = adminPresetFormData{
Key: p.Key,
BackgroundColorHex: p.BackgroundColorHex,
PrimaryColorHex: p.PrimaryColorHex,
PositiveColorHex: p.PositiveColorHex,
NegativeColorHex: p.NegativeColorHex,
Light: p.Light,
ContrastMultiplier: p.ContrastMultiplier,
TextSaturationMultiplier: p.TextSaturationMultiplier,
IsNew: false,
}
found = true
break
}
}
if !found {
a.handleNotFound(w, r)
return
}
}
data := adminTemplateData{App: a, PresetForm: &form}
a.populateTemplateRequestData(&data.Request, r)
renderAdminTemplate(w, adminThemePresetTemplate, data)
}
func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) {
if !a.adminAccessAllowed(w, r) {
return
+5
View File
@@ -457,6 +457,8 @@ func (a *application) server() (func() error, func() error) {
mux.HandleFunc("GET /edit/pages/{page}/settings", a.handleAdminPageSettings)
mux.HandleFunc("GET /edit/site-settings", a.handleAdminSiteSettings)
mux.HandleFunc("GET /edit/theme-settings", a.handleAdminThemeSettings)
mux.HandleFunc("GET /edit/theme-settings/presets/new", a.handleAdminThemePreset)
mux.HandleFunc("GET /edit/theme-settings/presets/{key}", a.handleAdminThemePreset)
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget)
mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage)
@@ -483,6 +485,9 @@ func (a *application) server() (func() error, func() error) {
mux.HandleFunc("POST /edit/api/restore/{n}", a.handleAdminRestoreFromBackup)
mux.HandleFunc("POST /edit/api/site-settings", a.handleAdminUpdateSiteSettings)
mux.HandleFunc("POST /edit/api/theme-settings", a.handleAdminUpdateTheme)
mux.HandleFunc("POST /edit/api/theme-presets", a.handleAdminCreateOrUpdatePreset)
mux.HandleFunc("POST /edit/api/theme-presets/{key}", a.handleAdminCreateOrUpdatePreset)
mux.HandleFunc("POST /edit/api/theme-presets/{key}/delete", a.handleAdminDeletePreset)
if a.RequiresAuth {
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
@@ -0,0 +1,84 @@
{{- template "document.html" . }}
{{- define "document-title" }}{{ if .PresetForm.IsNew }}Add preset{{ else }}Edit preset — {{ .PresetForm.Key }}{{ end }} - {{ .App.Config.Branding.AppName }}{{ end }}
{{- define "document-body" }}
<div class="flex flex-column body-content">
<main class="content-bounds" style="padding-block: 2rem;">
<div class="margin-bottom-15">
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings">← Theme settings</a>
</div>
<h1 class="size-h1 margin-bottom-15">{{ if .PresetForm.IsNew }}Add preset{{ else }}Edit preset <code>{{ .PresetForm.Key }}</code>{{ end }}</h1>
{{ if .PresetForm.ErrorMessage }}
<p class="color-negative margin-bottom-15">{{ .PresetForm.ErrorMessage }}</p>
{{ end }}
{{ if .PresetForm.IsNew }}
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-presets"
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
{{ else }}
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-presets/{{ .PresetForm.Key }}"
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
{{ end }}
{{ if .PresetForm.IsNew }}
<label class="form-label widget-header">
Preset name (key)
<input type="text" name="name" class="input" value="{{ .PresetForm.Key }}" placeholder="my-dark-theme" required
pattern="[^\s:{}&#91;&#93;|>&amp;*]+" title="No spaces or special characters">
<small class="color-subdue size-h5">Used as the key in YAML and in the theme picker. Lowercase kebab-case recommended.</small>
</label>
{{ end }}
<fieldset style="border:1px solid var(--color-widget-content-border);border-radius:4px;padding:1rem;">
<legend class="color-subdue size-h5">Colors</legend>
<div style="display:grid;grid-template-columns:1fr auto;gap:0.75rem;align-items:center;">
<label for="bg">Background</label>
<input id="bg" type="color" name="background-color"
value="{{ if .PresetForm.BackgroundColorHex }}{{ .PresetForm.BackgroundColorHex }}{{ else }}#151823{{ end }}">
<label for="pc">Primary (accent)</label>
<input id="pc" type="color" name="primary-color"
value="{{ if .PresetForm.PrimaryColorHex }}{{ .PresetForm.PrimaryColorHex }}{{ else }}#e4cf8d{{ end }}">
<label for="ps">Positive</label>
<input id="ps" type="color" name="positive-color"
value="{{ if .PresetForm.PositiveColorHex }}{{ .PresetForm.PositiveColorHex }}{{ else }}#7fbf7f{{ end }}">
<label for="ng">Negative</label>
<input id="ng" type="color" name="negative-color"
value="{{ if .PresetForm.NegativeColorHex }}{{ .PresetForm.NegativeColorHex }}{{ else }}#e07f7f{{ end }}">
</div>
</fieldset>
<fieldset style="border:1px solid var(--color-widget-content-border);border-radius:4px;padding:1rem;">
<legend class="color-subdue size-h5">Tweaks</legend>
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
<input type="checkbox" name="light" {{ if .PresetForm.Light }}checked{{ end }}>
<span>Light mode</span>
</label>
<label class="form-label widget-header">
Contrast multiplier
<input type="number" step="0.05" min="0.5" max="2.0" name="contrast-multiplier" class="input"
value="{{ if .PresetForm.ContrastMultiplier }}{{ .PresetForm.ContrastMultiplier }}{{ end }}" placeholder="1.0">
</label>
<label class="form-label widget-header" style="margin-top:0.75rem;">
Text saturation multiplier
<input type="number" step="0.05" min="0" max="2.0" name="text-saturation-multiplier" class="input"
value="{{ if .PresetForm.TextSaturationMultiplier }}{{ .PresetForm.TextSaturationMultiplier }}{{ end }}" placeholder="1.0">
</label>
</fieldset>
<div class="flex gap-10 margin-top-10">
<button type="submit">{{ if .PresetForm.IsNew }}Add preset{{ else }}Save{{ end }}</button>
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings">Cancel</a>
</div>
</form>
</main>
{{ template "footer.html" . }}
</div>
{{- end }}
@@ -9,10 +9,10 @@
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
</div>
<h1 class="size-h1 margin-bottom-15">Theme</h1>
<h2 class="size-h3 margin-bottom-10" style="margin-top:0;">Default theme</h2>
<p class="color-subdue margin-bottom-25">
Colors are stored as HSL in the YAML, but the picker below shows hex
for convenience — Glance converts on save. Theme presets and custom CSS
beyond the file path still need YAML.
Colors are stored as HSL in the YAML, but the picker below shows hex for convenience.
</p>
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-settings"
@@ -74,6 +74,61 @@
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit">Cancel</a>
</div>
</form>
{{/* ── Presets ── */}}
<div style="margin-top:2.5rem;max-width:36rem;">
<div class="flex" style="justify-content:space-between;align-items:baseline;margin-bottom:0.75rem;">
<h2 class="size-h3" style="margin:0;">Presets</h2>
<a href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings/presets/new"
style="font-size:0.85rem;">+ Add preset</a>
</div>
{{ if .ThemeSettings.Presets }}
<div style="display:flex;flex-direction:column;gap:0.5rem;">
{{ range .ThemeSettings.Presets }}
<div style="display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0.75rem;border:1px solid var(--color-widget-content-border);border-radius:4px;">
<div style="flex-shrink:0;pointer-events:none;">{{ .PreviewHTML }}</div>
<span style="flex:1;font-weight:500;">{{ .Key }}</span>
<a href="{{ $.App.Config.Server.BaseURL }}/edit/theme-settings/presets/{{ .Key }}"
style="font-size:0.85rem;">Edit</a>
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/theme-presets/{{ .Key }}/delete"
style="display:inline;" onsubmit="return confirm('Delete preset {{ .Key }}?')">
<button type="submit" class="color-negative"
style="background:none;border:none;cursor:pointer;font-size:0.85rem;padding:0;">Delete</button>
</form>
</div>
{{ end }}
</div>
{{ else }}
<p class="color-subdue size-h5">No custom presets yet. Add one below or import from the built-in catalog.</p>
{{ end }}
</div>
{{/* ── Built-in catalog ── */}}
<div style="margin-top:2rem;max-width:36rem;">
<details>
<summary class="size-h3" style="cursor:pointer;user-select:none;margin-bottom:0.75rem;">
Built-in theme catalog
</summary>
<p class="color-subdue size-h5 margin-bottom-15">
Click a theme to open the preset form pre-filled with its colors. You can rename it before saving.
</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr));gap:0.5rem;">
{{ range .ThemeSettings.CatalogThemes }}
{{- $cm := "" }}{{- if .CM }}{{- $cm = printf "%g" .CM }}{{ end }}
{{- $tsm := "" }}{{- if .TSM }}{{- $tsm = printf "%g" .TSM }}{{ end }}
{{- $light := "" }}{{- if .Light }}{{- $light = "true" }}{{ end }}
<a href="{{ $.App.Config.Server.BaseURL }}/edit/theme-settings/presets/new?name={{ .Key }}&bg={{ .BgHex }}&primary={{ .PrimaryHex }}&positive={{ .PositiveHex }}&negative={{ .NegativeHex }}&light={{ $light }}&cm={{ $cm }}&tsm={{ $tsm }}"
style="text-decoration:none;display:flex;flex-direction:column;align-items:center;gap:0.4rem;padding:0.6rem;border:1px solid var(--color-widget-content-border);border-radius:4px;transition:border-color 0.15s;"
onmouseover="this.style.borderColor='var(--color-primary)'" onmouseout="this.style.borderColor='var(--color-widget-content-border)'">
<div style="pointer-events:none;">{{ .PreviewHTML }}</div>
<span class="size-h5" style="text-align:center;color:var(--color-text-base);">{{ .Name }}</span>
</a>
{{ end }}
</div>
</details>
</div>
</main>
{{ template "footer.html" . }}
</div>