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
+2
View File
@@ -6,6 +6,8 @@ RUN CGO_ENABLED=0 go build .
FROM alpine:3.21 FROM alpine:3.21
LABEL org.opencontainers.image.source=https://github.com/uhlwoogi/modern-glance
WORKDIR /app WORKDIR /app
COPY --from=builder /app/glance . COPY --from=builder /app/glance .
+17
View File
@@ -0,0 +1,17 @@
services:
glance:
image: ghcr.io/uhlwoogi/modern-glance:latest
ports:
- "8080:8080"
volumes:
# Config directory: glance.yml lives here and is written by the /edit UI.
# Copy docs/glance.yml → ./config/glance.yml to get started.
- ./config:/app/config
# Assets directory: custom CSS, images, icons referenced by server.assets-path.
# Set `server: { assets-path: /app/assets }` in glance.yml to enable.
- ./assets:/app/assets
restart: unless-stopped
# Uncomment to inject secrets as environment variables.
# Reference them in glance.yml as ${env:MY_SECRET_TOKEN}.
# environment:
# MY_SECRET_TOKEN: abc123
+17 -25
View File
@@ -27,6 +27,11 @@ pages:
widgets: widgets:
- type: calendar - type: calendar
first-day-of-week: monday first-day-of-week: monday
- type: group
widgets:
- type: hacker-news
- size: full
widgets:
- type: rss - type: rss
limit: 10 limit: 10
collapse-after: 3 collapse-after: 3
@@ -34,29 +39,12 @@ pages:
feeds: feeds:
- url: https://www.techmeme.com/feed.xml - url: https://www.techmeme.com/feed.xml
title: TechMeme title: TechMeme
- type: server-stats
- size: full
widgets:
- type: group
widgets:
- type: hacker-news
- type: videos
channels:
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
- UCsBjURrPoezykLs9EqgamOA # Fireship
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
- type: group
widgets:
- type: reddit
subreddit: technology
show-thumbnails: true
- type: reddit
subreddit: selfhosted
show-thumbnails: true
- size: small - size: small
widgets: widgets:
- type: markets
markets:
- symbol: AMZN
name: Amazon.com, Inc.
- type: weather - type: weather
location: Houston, US location: Houston, US
units: imperial units: imperial
@@ -66,10 +54,14 @@ pages:
hide-location: true hide-location: true
show-area-name: false show-area-name: false
- type: markets - type: group
markets: widgets:
- symbol: AMZN - type: reddit
name: Amazon.com, Inc. subreddit: technology
show-thumbnails: true
- type: reddit
subreddit: selfhosted
show-thumbnails: true
# Add more pages here: # Add more pages here:
# - name: Your page name # - name: Your page name
+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) 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) ---------- // ---------- site settings (branding) ----------
var brandingFieldKeys = []string{ var brandingFieldKeys = []string{
+206
View File
@@ -19,6 +19,7 @@ var (
adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html") adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html")
adminSiteSettingsTemplate = mustParseTemplate("admin-site-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") 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. // allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
@@ -264,6 +265,7 @@ type adminTemplateData struct {
SiteSettings *adminSiteSettings SiteSettings *adminSiteSettings
ThemeSettings *adminThemeSettings ThemeSettings *adminThemeSettings
PresetForm *adminPresetFormData
Backups []adminBackupSummary Backups []adminBackupSummary
} }
@@ -278,6 +280,118 @@ type adminThemeSettings struct {
ContrastMultiplier float32 ContrastMultiplier float32
TextSaturationMultiplier float32 TextSaturationMultiplier float32
CustomCSSFile string 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 { type adminSiteSettings struct {
@@ -462,6 +576,39 @@ func hexOf(c *hslColorField) string {
return c.ToHex() 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) { func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Request) {
if !a.adminAccessAllowed(w, r) { if !a.adminAccessAllowed(w, r) {
return return
@@ -477,12 +624,71 @@ func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Re
ContrastMultiplier: t.ContrastMultiplier, ContrastMultiplier: t.ContrastMultiplier,
TextSaturationMultiplier: t.TextSaturationMultiplier, TextSaturationMultiplier: t.TextSaturationMultiplier,
CustomCSSFile: t.CustomCSSFile, CustomCSSFile: t.CustomCSSFile,
Presets: a.freshPresetsFromDisk(),
CatalogThemes: builtinThemeCatalog,
} }
data := adminTemplateData{App: a, ThemeSettings: settings} data := adminTemplateData{App: a, ThemeSettings: settings}
a.populateTemplateRequestData(&data.Request, r) a.populateTemplateRequestData(&data.Request, r)
renderAdminTemplate(w, adminThemeSettingsTemplate, data) 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) { func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) {
if !a.adminAccessAllowed(w, r) { if !a.adminAccessAllowed(w, r) {
return 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/pages/{page}/settings", a.handleAdminPageSettings)
mux.HandleFunc("GET /edit/site-settings", a.handleAdminSiteSettings) mux.HandleFunc("GET /edit/site-settings", a.handleAdminSiteSettings)
mux.HandleFunc("GET /edit/theme-settings", a.handleAdminThemeSettings) 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("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget)
mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage) 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/restore/{n}", a.handleAdminRestoreFromBackup)
mux.HandleFunc("POST /edit/api/site-settings", a.handleAdminUpdateSiteSettings) mux.HandleFunc("POST /edit/api/site-settings", a.handleAdminUpdateSiteSettings)
mux.HandleFunc("POST /edit/api/theme-settings", a.handleAdminUpdateTheme) 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 { if a.RequiresAuth {
mux.HandleFunc("GET /login", a.handleLoginPageRequest) 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> <a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
</div> </div>
<h1 class="size-h1 margin-bottom-15">Theme</h1> <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"> <p class="color-subdue margin-bottom-25">
Colors are stored as HSL in the YAML, but the picker below shows hex Colors are stored as HSL in the YAML, but the picker below shows hex for convenience.
for convenience — Glance converts on save. Theme presets and custom CSS
beyond the file path still need YAML.
</p> </p>
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-settings" <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> <a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit">Cancel</a>
</div> </div>
</form> </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> </main>
{{ template "footer.html" . }} {{ template "footer.html" . }}
</div> </div>