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 }} +
+ {{ else }} + + {{ end }} + + {{ if .PresetForm.IsNew }} + + {{ end }} + +
+ Colors +
+ + + + + + + + + + + +
+
+ +
+ Tweaks + + + + + + +
+ +
+ + Cancel +
+
+
+ {{ 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.

Cancel
+ + {{/* ── Presets ── */}} +
+
+

Presets

+ + Add preset +
+ + {{ 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" . }}