Add theme color editor and multi-step undo
- New /edit/theme-settings page with native color pickers for the background/primary/positive/negative theme colors plus light mode, contrast and saturation multipliers, custom-css-file path, and picker-disable. Pickers use hex; the backend converts to Glance's HSL "H S L" form before writing. Theme presets and full custom CSS intentionally still YAML. - Numbered backup chain: save() rotates up to 10 prior versions as glance.yml.bak.1 .. .10 instead of a single .bak. The /edit page lists them with humanized timestamps and a per-row Restore button. Single-step "Restore previous (toggle)" is preserved. - Restoring any backup saves the just-current state as a fresh .bak.1, so undo-the-undo always works. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e108feb74b
commit
7a0a4b9780
@@ -4,3 +4,4 @@
|
|||||||
/.idea
|
/.idea
|
||||||
/glance*.yml
|
/glance*.yml
|
||||||
*.yml.bak
|
*.yml.bak
|
||||||
|
*.yml.bak.*
|
||||||
|
|||||||
+214
-11
@@ -47,8 +47,25 @@ func loadConfigEditor(path string) (*configEditor, error) {
|
|||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// save validates the edited tree against newConfigFromYAML, writes a
|
const maxBackups = 10
|
||||||
// .bak of the previous file, then writes via temp+rename for atomicity.
|
|
||||||
|
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 {
|
func (e *configEditor) save() error {
|
||||||
out, err := yaml.Marshal(&e.root)
|
out, err := yaml.Marshal(&e.root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,7 +77,8 @@ func (e *configEditor) save() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existing, err := os.ReadFile(e.path); err == nil {
|
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)
|
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) ----------
|
// ---------- site settings (branding) ----------
|
||||||
|
|
||||||
var brandingFieldKeys = []string{
|
var brandingFieldKeys = []string{
|
||||||
@@ -1082,15 +1246,13 @@ func (a *application) handleAdminUpdateSiteSettings(w http.ResponseWriter, r *ht
|
|||||||
|
|
||||||
// ---------- restore from .bak ----------
|
// ---------- restore from .bak ----------
|
||||||
|
|
||||||
// handleAdminRestore swaps the contents of glance.yml and glance.yml.bak.
|
// handleAdminRestore swaps the current config with .bak.1 (single-step undo).
|
||||||
// Each save writes a .bak of the previous version, so this is effectively an
|
// Click again to flip back. Older numbered backups stay untouched.
|
||||||
// undo. Calling it again redoes since we put the just-current contents into
|
|
||||||
// the new .bak.
|
|
||||||
func (a *application) handleAdminRestore(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handleAdminRestore(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.adminAccessAllowed(w, r) {
|
if !a.adminAccessAllowed(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bakPath := a.ConfigPath + ".bak"
|
bakPath := backupPath(a.ConfigPath, 1)
|
||||||
bakContent, err := os.ReadFile(bakPath)
|
bakContent, err := os.ReadFile(bakPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
adminError(w, http.StatusBadRequest, "no backup to restore: "+err.Error())
|
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())
|
adminError(w, http.StatusBadRequest, "backup is invalid: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currentContent, err := os.ReadFile(a.ConfigPath)
|
currentContent, err := os.ReadFile(a.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
adminError(w, http.StatusInternalServerError, err.Error())
|
adminError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Move current → .bak (so user can re-restore).
|
|
||||||
if err := os.WriteFile(bakPath, currentContent, 0644); err != nil {
|
if err := os.WriteFile(bakPath, currentContent, 0644); err != nil {
|
||||||
adminError(w, http.StatusInternalServerError, "writing new backup: "+err.Error())
|
adminError(w, http.StatusInternalServerError, "writing new backup: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Atomic write of .bak content into main file.
|
|
||||||
tmpPath := a.ConfigPath + ".tmp"
|
tmpPath := a.ConfigPath + ".tmp"
|
||||||
if err := os.WriteFile(tmpPath, bakContent, 0644); err != nil {
|
if err := os.WriteFile(tmpPath, bakContent, 0644); err != nil {
|
||||||
adminError(w, http.StatusInternalServerError, "writing temp file: "+err.Error())
|
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())
|
adminError(w, http.StatusInternalServerError, "renaming temp file: "+err.Error())
|
||||||
return
|
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)
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package glance
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -14,7 +17,8 @@ var (
|
|||||||
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
||||||
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
||||||
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")
|
||||||
)
|
)
|
||||||
|
|
||||||
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
||||||
@@ -214,6 +218,13 @@ type adminPageSummary struct {
|
|||||||
IsLast bool
|
IsLast bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminBackupSummary struct {
|
||||||
|
N int
|
||||||
|
Time string // human-readable, e.g. "2 minutes ago"
|
||||||
|
SizeKB int
|
||||||
|
Present bool
|
||||||
|
}
|
||||||
|
|
||||||
type adminPageDetail struct {
|
type adminPageDetail struct {
|
||||||
Title string
|
Title string
|
||||||
Slug string
|
Slug string
|
||||||
@@ -251,7 +262,22 @@ type adminTemplateData struct {
|
|||||||
IsFirstPage bool
|
IsFirstPage bool
|
||||||
IsLastPage 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 {
|
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)
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
renderAdminTemplate(w, adminIndexTemplate, data)
|
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) {
|
func (a *application) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.adminAccessAllowed(w, r) {
|
if !a.adminAccessAllowed(w, r) {
|
||||||
return
|
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) {
|
func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.adminAccessAllowed(w, r) {
|
if !a.adminAccessAllowed(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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}", a.handleAdminPage)
|
||||||
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/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)
|
||||||
@@ -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}/fields", a.handleAdminUpdatePageFields)
|
||||||
mux.HandleFunc("POST /edit/api/pages/{page}/move", a.handleAdminMovePage)
|
mux.HandleFunc("POST /edit/api/pages/{page}/move", a.handleAdminMovePage)
|
||||||
mux.HandleFunc("POST /edit/api/restore", a.handleAdminRestore)
|
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/site-settings", a.handleAdminUpdateSiteSettings)
|
||||||
|
mux.HandleFunc("POST /edit/api/theme-settings", a.handleAdminUpdateTheme)
|
||||||
|
|
||||||
if a.RequiresAuth {
|
if a.RequiresAuth {
|
||||||
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Theme settings - {{ .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">← Edit</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1 margin-bottom-15">Theme</h1>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-settings"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
|
||||||
|
<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 .ThemeSettings.BackgroundColorHex }}{{ .ThemeSettings.BackgroundColorHex }}{{ else }}#151823{{ end }}">
|
||||||
|
|
||||||
|
<label for="pc">Primary (accent)</label>
|
||||||
|
<input id="pc" type="color" name="primary-color" value="{{ if .ThemeSettings.PrimaryColorHex }}{{ .ThemeSettings.PrimaryColorHex }}{{ else }}#e4cf8d{{ end }}">
|
||||||
|
|
||||||
|
<label for="ps">Positive</label>
|
||||||
|
<input id="ps" type="color" name="positive-color" value="{{ if .ThemeSettings.PositiveColorHex }}{{ .ThemeSettings.PositiveColorHex }}{{ else }}#7fbf7f{{ end }}">
|
||||||
|
|
||||||
|
<label for="ng">Negative</label>
|
||||||
|
<input id="ng" type="color" name="negative-color" value="{{ if .ThemeSettings.NegativeColorHex }}{{ .ThemeSettings.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 .ThemeSettings.Light }}checked{{ end }}>
|
||||||
|
<span>Light mode (use light text/background scheme)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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="disable-picker" {{ if .ThemeSettings.DisablePicker }}checked{{ end }}>
|
||||||
|
<span>Hide the theme picker in the header</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 .ThemeSettings.ContrastMultiplier }}{{ .ThemeSettings.ContrastMultiplier }}{{ end }}" placeholder="1.0">
|
||||||
|
<small class="color-subdue size-h5">Higher = stronger contrast for body text. Default 1.0.</small>
|
||||||
|
</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 .ThemeSettings.TextSaturationMultiplier }}{{ .ThemeSettings.TextSaturationMultiplier }}{{ end }}" placeholder="1.0">
|
||||||
|
<small class="color-subdue size-h5">0 = monochrome text. Default 1.0.</small>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Custom CSS file
|
||||||
|
<input type="text" name="custom-css-file" class="input" value="{{ .ThemeSettings.CustomCSSFile }}" placeholder="/assets/custom.css">
|
||||||
|
<small class="color-subdue size-h5">Path to a .css file under your assets-path, loaded after the bundled styles.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
<main class="content-bounds" style="padding-block: 2rem;">
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
<div class="flex items-center gap-10">
|
<div class="flex items-center gap-10">
|
||||||
<h1 class="size-h1 grow">Edit</h1>
|
<h1 class="size-h1 grow">Edit</h1>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings">
|
||||||
|
<button type="button">Theme</button>
|
||||||
|
</a>
|
||||||
<a href="{{ .App.Config.Server.BaseURL }}/edit/site-settings">
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/site-settings">
|
||||||
<button type="button">Site settings</button>
|
<button type="button">Site settings</button>
|
||||||
</a>
|
</a>
|
||||||
@@ -50,15 +53,33 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr style="margin-block: 2rem; border: none; border-top: 1px solid var(--color-widget-content-border);">
|
<hr style="margin-block: 2rem; border: none; border-top: 1px solid var(--color-widget-content-border);">
|
||||||
<h3 class="size-h3 margin-bottom-10">Undo last save</h3>
|
<h3 class="size-h3 margin-bottom-10">Saved versions</h3>
|
||||||
<p class="color-subdue margin-bottom-10">
|
<p class="color-subdue margin-bottom-10">
|
||||||
Restores the previous contents of <code>{{ .App.ConfigPath }}</code> from the
|
Up to {{ len .Backups }} previous versions of <code>{{ .App.ConfigPath }}</code> are kept.
|
||||||
<code>.bak</code> file written on each save. Click again to redo.
|
Restoring any one writes it as the current config and saves the just-current state as the new <em>most recent</em>, so you can always step back.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/restore"
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/restore"
|
||||||
onsubmit="return confirm('Restore the previous version of your config?');">
|
onsubmit="return confirm('Restore the most recent backup? You can click again to flip back.');" class="margin-bottom-15">
|
||||||
<button type="submit">Restore previous version</button>
|
<button type="submit">Restore previous version (toggle)</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ul class="list list-gap-10">
|
||||||
|
{{- range .Backups }}
|
||||||
|
{{- if .Present }}
|
||||||
|
<li class="flex items-center gap-10">
|
||||||
|
<div class="grow">
|
||||||
|
<span class="color-text-base">Version {{ .N }}</span>
|
||||||
|
<span class="color-subdue size-h5"> — {{ .Time }} · {{ .SizeKB }} KB</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/restore/{{ .N }}"
|
||||||
|
onsubmit="return confirm('Restore this version? Current state will be saved as the new most recent backup.');">
|
||||||
|
<button type="submit">Restore this</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
{{ template "footer.html" . }}
|
{{ template "footer.html" . }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user