diff --git a/Dockerfile b/Dockerfile
index 9d4ae67..32353c8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,6 +6,8 @@ RUN CGO_ENABLED=0 go build .
FROM alpine:3.21
+LABEL org.opencontainers.image.source=https://github.com/uhlwoogi/modern-glance
+
WORKDIR /app
COPY --from=builder /app/glance .
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1ba1a23
--- /dev/null
+++ b/docker-compose.yml
@@ -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
diff --git a/docs/glance.yml b/docs/glance.yml
index e8df2db..35aa39b 100644
--- a/docs/glance.yml
+++ b/docs/glance.yml
@@ -27,6 +27,11 @@ pages:
widgets:
- type: calendar
first-day-of-week: monday
+ - type: group
+ widgets:
+ - type: hacker-news
+ - size: full
+ widgets:
- type: rss
limit: 10
collapse-after: 3
@@ -34,29 +39,12 @@ pages:
feeds:
- url: https://www.techmeme.com/feed.xml
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
widgets:
+ - type: markets
+ markets:
+ - symbol: AMZN
+ name: Amazon.com, Inc.
- type: weather
location: Houston, US
units: imperial
@@ -66,10 +54,14 @@ pages:
hide-location: true
show-area-name: false
- - type: markets
- markets:
- - symbol: AMZN
- name: Amazon.com, Inc.
+ - type: group
+ widgets:
+ - type: reddit
+ subreddit: technology
+ show-thumbnails: true
+ - type: reddit
+ subreddit: selfhosted
+ show-thumbnails: true
# Add more pages here:
# - name: Your page name
diff --git a/internal/glance/admin-edit.go b/internal/glance/admin-edit.go
index c27b0c3..d1e42ca 100644
--- a/internal/glance/admin-edit.go
+++ b/internal/glance/admin-edit.go
@@ -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{
diff --git a/internal/glance/admin.go b/internal/glance/admin.go
index a664c84..e277dc9 100644
--- a/internal/glance/admin.go
+++ b/internal/glance/admin.go
@@ -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
diff --git a/internal/glance/glance.go b/internal/glance/glance.go
index 8f08310..4f64900 100644
--- a/internal/glance/glance.go
+++ b/internal/glance/glance.go
@@ -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)
diff --git a/internal/glance/templates/admin-theme-preset.html b/internal/glance/templates/admin-theme-preset.html
new file mode 100644
index 0000000..c4c85cd
--- /dev/null
+++ b/internal/glance/templates/admin-theme-preset.html
@@ -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" }}
+
+
+
+ {{ if .PresetForm.IsNew }}Add preset{{ else }}Edit preset {{ .PresetForm.Key }}{{ end }}
+
+ {{ if .PresetForm.ErrorMessage }}
+ {{ .PresetForm.ErrorMessage }}
+ {{ end }}
+
+ {{ if .PresetForm.IsNew }}
+
+
+ {{ template "footer.html" . }}
+
+{{- end }}
diff --git a/internal/glance/templates/admin-theme-settings.html b/internal/glance/templates/admin-theme-settings.html
index f022753..fd5e25c 100644
--- a/internal/glance/templates/admin-theme-settings.html
+++ b/internal/glance/templates/admin-theme-settings.html
@@ -9,10 +9,10 @@
← Edit
Theme
+
+ Default 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 are stored as HSL in the YAML, but the picker below shows hex for convenience.
+
+ {{/* ── Presets ── */}}
+
+
+
+ {{ if .ThemeSettings.Presets }}
+
+ {{ range .ThemeSettings.Presets }}
+
+
{{ .PreviewHTML }}
+
{{ .Key }}
+
Edit
+
+
+ {{ end }}
+
+ {{ else }}
+
No custom presets yet. Add one below or import from the built-in catalog.
+ {{ end }}
+
+
+ {{/* ── Built-in catalog ── */}}
+
+
+
+ Built-in theme catalog
+
+
+ Click a theme to open the preset form pre-filled with its colors. You can rename it before saving.
+
+
+ {{ 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 }}
+
+ {{ .PreviewHTML }}
+ {{ .Name }}
+
+ {{ end }}
+
+
+
+
{{ template "footer.html" . }}