diff --git a/.gitignore b/.gitignore index 29c978d..a31a3d1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /.idea /glance*.yml *.yml.bak +*.yml.bak.* diff --git a/internal/glance/admin-edit.go b/internal/glance/admin-edit.go index 8ce1bc3..c27b0c3 100644 --- a/internal/glance/admin-edit.go +++ b/internal/glance/admin-edit.go @@ -47,8 +47,25 @@ func loadConfigEditor(path string) (*configEditor, error) { return e, nil } -// save validates the edited tree against newConfigFromYAML, writes a -// .bak of the previous file, then writes via temp+rename for atomicity. +const maxBackups = 10 + +func backupPath(configPath string, n int) string { + return fmt.Sprintf("%s.bak.%d", configPath, n) +} + +// rotateBackups shifts every .bak.N up by one (oldest gets dropped) so +// .bak.1 becomes free to receive the just-superseded contents. +func rotateBackups(configPath string) { + // Drop the oldest first. + os.Remove(backupPath(configPath, maxBackups)) + for i := maxBackups - 1; i >= 1; i-- { + _ = os.Rename(backupPath(configPath, i), backupPath(configPath, i+1)) + } +} + +// save validates the edited tree against newConfigFromYAML, rotates the +// numbered backup chain (up to maxBackups versions), then writes via +// temp+rename for atomicity. func (e *configEditor) save() error { out, err := yaml.Marshal(&e.root) if err != nil { @@ -60,7 +77,8 @@ func (e *configEditor) save() error { } if existing, err := os.ReadFile(e.path); err == nil { - if werr := os.WriteFile(e.path+".bak", existing, 0644); werr != nil { + rotateBackups(e.path) + if werr := os.WriteFile(backupPath(e.path, 1), existing, 0644); werr != nil { log.Printf("admin: failed to write backup: %v", werr) } } @@ -1015,6 +1033,152 @@ func (a *application) handleAdminWidgetSchemas(w http.ResponseWriter, r *http.Re } } +// ---------- theme settings ---------- + +// hexToHSL converts a #rrggbb (or #rgb) string into HSL components matching +// Glance's hslColorField format (H 0-360, S/L 0-100). +func hexToHSL(hex string) (float64, float64, float64, error) { + s := strings.TrimSpace(strings.TrimPrefix(hex, "#")) + if len(s) == 3 { + s = string(s[0]) + string(s[0]) + string(s[1]) + string(s[1]) + string(s[2]) + string(s[2]) + } + if len(s) != 6 { + return 0, 0, 0, fmt.Errorf("hex color must be #rgb or #rrggbb, got %q", hex) + } + v, err := strconv.ParseUint(s, 16, 32) + if err != nil { + return 0, 0, 0, fmt.Errorf("parsing hex %q: %w", hex, err) + } + r := float64((v>>16)&0xff) / 255.0 + g := float64((v>>8)&0xff) / 255.0 + b := float64(v&0xff) / 255.0 + + maxC, minC := r, r + if g > maxC { + maxC = g + } + if b > maxC { + maxC = b + } + if g < minC { + minC = g + } + if b < minC { + minC = b + } + delta := maxC - minC + l := (maxC + minC) / 2 + + var h, sat float64 + if delta == 0 { + h, sat = 0, 0 + } else { + if l < 0.5 { + sat = delta / (maxC + minC) + } else { + sat = delta / (2 - maxC - minC) + } + switch maxC { + case r: + h = (g - b) / delta + if g < b { + h += 6 + } + case g: + h = (b-r)/delta + 2 + case b: + h = (r-g)/delta + 4 + } + h *= 60 + } + // Round to nearest int for tidy YAML. + return float64(int(h + 0.5)), float64(int(sat*100 + 0.5)), float64(int(l*100 + 0.5)), nil +} + +// hslToYAMLString returns the canonical Glance HSL representation: "H S L". +func hslToYAMLString(h, s, l float64) string { + return fmt.Sprintf("%g %g %g", h, s, l) +} + +var themeBoolKeys = map[string]bool{"light": true, "disable-picker": true} +var themeNumberKeys = map[string]bool{"contrast-multiplier": true, "text-saturation-multiplier": true} +var themeColorKeys = map[string]bool{"background-color": true, "primary-color": true, "positive-color": true, "negative-color": true} + +func (a *application) handleAdminUpdateTheme(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 + } + + 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 + } + + fields := make(map[string]interface{}) + // Colors: form sends hex, we store HSL. + for key := range themeColorKeys { + raw := strings.TrimSpace(r.FormValue(key)) + if raw == "" { + fields[key] = nil + continue + } + h, s, l, err := hexToHSL(raw) + if err != nil { + adminError(w, http.StatusBadRequest, err.Error()) + return + } + fields[key] = hslToYAMLString(h, s, l) + } + // Numbers + for key := range themeNumberKeys { + raw := strings.TrimSpace(r.FormValue(key)) + if raw == "" { + fields[key] = nil + continue + } + f, err := strconv.ParseFloat(raw, 64) + if err != nil { + adminError(w, http.StatusBadRequest, fmt.Sprintf("%s: %v", key, err)) + return + } + fields[key] = f + } + // Booleans (browsers omit unchecked, so default to false). + for key := range themeBoolKeys { + raw := strings.TrimSpace(r.FormValue(key)) + fields[key] = raw == "on" || raw == "true" || raw == "1" + } + // String paths + if r.Form.Has("custom-css-file") { + raw := strings.TrimSpace(r.FormValue("custom-css-file")) + if raw == "" { + fields["custom-css-file"] = nil + } else { + fields["custom-css-file"] = raw + } + } + + if err := applyFieldsToWidget(theme, 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) +} + // ---------- site settings (branding) ---------- var brandingFieldKeys = []string{ @@ -1082,15 +1246,13 @@ func (a *application) handleAdminUpdateSiteSettings(w http.ResponseWriter, r *ht // ---------- restore from .bak ---------- -// handleAdminRestore swaps the contents of glance.yml and glance.yml.bak. -// Each save writes a .bak of the previous version, so this is effectively an -// undo. Calling it again redoes since we put the just-current contents into -// the new .bak. +// handleAdminRestore swaps the current config with .bak.1 (single-step undo). +// Click again to flip back. Older numbered backups stay untouched. func (a *application) handleAdminRestore(w http.ResponseWriter, r *http.Request) { if !a.adminAccessAllowed(w, r) { return } - bakPath := a.ConfigPath + ".bak" + bakPath := backupPath(a.ConfigPath, 1) bakContent, err := os.ReadFile(bakPath) if err != nil { adminError(w, http.StatusBadRequest, "no backup to restore: "+err.Error()) @@ -1100,18 +1262,15 @@ func (a *application) handleAdminRestore(w http.ResponseWriter, r *http.Request) adminError(w, http.StatusBadRequest, "backup is invalid: "+err.Error()) return } - currentContent, err := os.ReadFile(a.ConfigPath) if err != nil { adminError(w, http.StatusInternalServerError, err.Error()) return } - // Move current → .bak (so user can re-restore). if err := os.WriteFile(bakPath, currentContent, 0644); err != nil { adminError(w, http.StatusInternalServerError, "writing new backup: "+err.Error()) return } - // Atomic write of .bak content into main file. tmpPath := a.ConfigPath + ".tmp" if err := os.WriteFile(tmpPath, bakContent, 0644); err != nil { adminError(w, http.StatusInternalServerError, "writing temp file: "+err.Error()) @@ -1122,7 +1281,51 @@ func (a *application) handleAdminRestore(w http.ResponseWriter, r *http.Request) adminError(w, http.StatusInternalServerError, "renaming temp file: "+err.Error()) return } + http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther) +} +// handleAdminRestoreFromBackup restores from a specific numbered backup. The +// just-current contents are saved as a fresh .bak.1 (rotating older ones up) +// so the user can always undo their undo. +func (a *application) handleAdminRestoreFromBackup(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 || n > maxBackups { + http.Error(w, "bad backup number", http.StatusBadRequest) + return + } + + bakContent, err := os.ReadFile(backupPath(a.ConfigPath, n)) + if err != nil { + adminError(w, http.StatusBadRequest, "backup not found: "+err.Error()) + return + } + if _, err := newConfigFromYAML(bakContent); err != nil { + adminError(w, http.StatusBadRequest, "backup is invalid: "+err.Error()) + return + } + currentContent, err := os.ReadFile(a.ConfigPath) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + rotateBackups(a.ConfigPath) + if err := os.WriteFile(backupPath(a.ConfigPath, 1), currentContent, 0644); err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + tmpPath := a.ConfigPath + ".tmp" + if err := os.WriteFile(tmpPath, bakContent, 0644); err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := os.Rename(tmpPath, a.ConfigPath); err != nil { + os.Remove(tmpPath) + adminError(w, http.StatusInternalServerError, err.Error()) + return + } http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther) } diff --git a/internal/glance/admin.go b/internal/glance/admin.go index a2088b3..a664c84 100644 --- a/internal/glance/admin.go +++ b/internal/glance/admin.go @@ -2,9 +2,12 @@ package glance import ( "bytes" + "fmt" "html/template" "net/http" + "os" "strconv" + "time" "gopkg.in/yaml.v3" ) @@ -14,7 +17,8 @@ var ( adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html") adminWidgetTemplate = mustParseTemplate("admin-widget.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") ) // allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted. @@ -214,6 +218,13 @@ type adminPageSummary struct { IsLast bool } +type adminBackupSummary struct { + N int + Time string // human-readable, e.g. "2 minutes ago" + SizeKB int + Present bool +} + type adminPageDetail struct { Title string Slug string @@ -251,7 +262,22 @@ type adminTemplateData struct { IsFirstPage bool IsLastPage bool - SiteSettings *adminSiteSettings + SiteSettings *adminSiteSettings + ThemeSettings *adminThemeSettings + + Backups []adminBackupSummary +} + +type adminThemeSettings struct { + BackgroundColorHex string + PrimaryColorHex string + PositiveColorHex string + NegativeColorHex string + Light bool + DisablePicker bool + ContrastMultiplier float32 + TextSaturationMultiplier float32 + CustomCSSFile string } type adminSiteSettings struct { @@ -288,11 +314,43 @@ func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) { }) } - data := adminTemplateData{App: a, Pages: summaries} + data := adminTemplateData{App: a, Pages: summaries, Backups: a.collectBackups()} a.populateTemplateRequestData(&data.Request, r) renderAdminTemplate(w, adminIndexTemplate, data) } +func (a *application) collectBackups() []adminBackupSummary { + out := make([]adminBackupSummary, 0, maxBackups) + now := time.Now() + for n := 1; n <= maxBackups; n++ { + info, err := os.Stat(backupPath(a.ConfigPath, n)) + if err != nil { + out = append(out, adminBackupSummary{N: n, Present: false}) + continue + } + out = append(out, adminBackupSummary{ + N: n, + Time: humanizeDuration(now.Sub(info.ModTime())) + " ago", + SizeKB: int(info.Size() / 1024), + Present: true, + }) + } + return out +} + +func humanizeDuration(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + func (a *application) handleAdminPage(w http.ResponseWriter, r *http.Request) { if !a.adminAccessAllowed(w, r) { return @@ -397,6 +455,34 @@ func (a *application) handleAdminWidget(w http.ResponseWriter, r *http.Request) }) } +func hexOf(c *hslColorField) string { + if c == nil { + return "" + } + return c.ToHex() +} + +func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + t := a.Config.Theme + settings := &adminThemeSettings{ + BackgroundColorHex: hexOf(t.BackgroundColor), + PrimaryColorHex: hexOf(t.PrimaryColor), + PositiveColorHex: hexOf(t.PositiveColor), + NegativeColorHex: hexOf(t.NegativeColor), + Light: t.Light, + DisablePicker: t.DisablePicker, + ContrastMultiplier: t.ContrastMultiplier, + TextSaturationMultiplier: t.TextSaturationMultiplier, + CustomCSSFile: t.CustomCSSFile, + } + data := adminTemplateData{App: a, ThemeSettings: settings} + a.populateTemplateRequestData(&data.Request, r) + renderAdminTemplate(w, adminThemeSettingsTemplate, data) +} + func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) { if !a.adminAccessAllowed(w, r) { return diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 4102e21..8f08310 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -456,6 +456,7 @@ func (a *application) server() (func() error, func() error) { mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage) 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/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget) mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage) @@ -479,7 +480,9 @@ func (a *application) server() (func() error, func() error) { mux.HandleFunc("POST /edit/api/pages/{page}/fields", a.handleAdminUpdatePageFields) mux.HandleFunc("POST /edit/api/pages/{page}/move", a.handleAdminMovePage) mux.HandleFunc("POST /edit/api/restore", a.handleAdminRestore) + 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) if a.RequiresAuth { mux.HandleFunc("GET /login", a.handleLoginPageRequest) diff --git a/internal/glance/templates/admin-theme-settings.html b/internal/glance/templates/admin-theme-settings.html new file mode 100644 index 0000000..f022753 --- /dev/null +++ b/internal/glance/templates/admin-theme-settings.html @@ -0,0 +1,80 @@ +{{- template "document.html" . }} + +{{- define "document-title" }}Theme settings - {{ .App.Config.Branding.AppName }}{{ end }} + +{{- define "document-body" }} +
+
+
+ ← Edit +
+

Theme

+

+ 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 +
+ + + + + + + + + + + +
+
+ +
+ Tweaks + + + + + + + + +
+ + + +
+ + Cancel +
+
+
+ {{ template "footer.html" . }} +
+{{- end }} diff --git a/internal/glance/templates/admin.html b/internal/glance/templates/admin.html index 1a6b503..617bb4c 100644 --- a/internal/glance/templates/admin.html +++ b/internal/glance/templates/admin.html @@ -7,6 +7,9 @@

Edit

+ + + @@ -50,15 +53,33 @@
-

Undo last save

+

Saved versions

- Restores the previous contents of {{ .App.ConfigPath }} from the - .bak file written on each save. Click again to redo. + Up to {{ len .Backups }} previous versions of {{ .App.ConfigPath }} are kept. + Restoring any one writes it as the current config and saves the just-current state as the new most recent, so you can always step back.

+
- + onsubmit="return confirm('Restore the most recent backup? You can click again to flip back.');" class="margin-bottom-15"> +
+ +
{{ template "footer.html" . }}