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{