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:
co-authored by
Claude Sonnet 4.6
parent
7a0a4b9780
commit
2351d909ca
@@ -6,6 +6,8 @@ RUN CGO_ENABLED=0 go build .
|
|||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source=https://github.com/uhlwoogi/modern-glance
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/glance .
|
COPY --from=builder /app/glance .
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+17
-25
@@ -27,6 +27,11 @@ pages:
|
|||||||
widgets:
|
widgets:
|
||||||
- type: calendar
|
- type: calendar
|
||||||
first-day-of-week: monday
|
first-day-of-week: monday
|
||||||
|
- type: group
|
||||||
|
widgets:
|
||||||
|
- type: hacker-news
|
||||||
|
- size: full
|
||||||
|
widgets:
|
||||||
- type: rss
|
- type: rss
|
||||||
limit: 10
|
limit: 10
|
||||||
collapse-after: 3
|
collapse-after: 3
|
||||||
@@ -34,29 +39,12 @@ pages:
|
|||||||
feeds:
|
feeds:
|
||||||
- url: https://www.techmeme.com/feed.xml
|
- url: https://www.techmeme.com/feed.xml
|
||||||
title: TechMeme
|
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
|
- size: small
|
||||||
widgets:
|
widgets:
|
||||||
|
- type: markets
|
||||||
|
markets:
|
||||||
|
- symbol: AMZN
|
||||||
|
name: Amazon.com, Inc.
|
||||||
- type: weather
|
- type: weather
|
||||||
location: Houston, US
|
location: Houston, US
|
||||||
units: imperial
|
units: imperial
|
||||||
@@ -66,10 +54,14 @@ pages:
|
|||||||
|
|
||||||
hide-location: true
|
hide-location: true
|
||||||
show-area-name: false
|
show-area-name: false
|
||||||
- type: markets
|
- type: group
|
||||||
markets:
|
widgets:
|
||||||
- symbol: AMZN
|
- type: reddit
|
||||||
name: Amazon.com, Inc.
|
subreddit: technology
|
||||||
|
show-thumbnails: true
|
||||||
|
- type: reddit
|
||||||
|
subreddit: selfhosted
|
||||||
|
show-thumbnails: true
|
||||||
|
|
||||||
# Add more pages here:
|
# Add more pages here:
|
||||||
# - name: Your page name
|
# - name: Your page name
|
||||||
|
|||||||
@@ -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)
|
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) ----------
|
// ---------- site settings (branding) ----------
|
||||||
|
|
||||||
var brandingFieldKeys = []string{
|
var brandingFieldKeys = []string{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ var (
|
|||||||
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")
|
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.
|
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
||||||
@@ -264,6 +265,7 @@ type adminTemplateData struct {
|
|||||||
|
|
||||||
SiteSettings *adminSiteSettings
|
SiteSettings *adminSiteSettings
|
||||||
ThemeSettings *adminThemeSettings
|
ThemeSettings *adminThemeSettings
|
||||||
|
PresetForm *adminPresetFormData
|
||||||
|
|
||||||
Backups []adminBackupSummary
|
Backups []adminBackupSummary
|
||||||
}
|
}
|
||||||
@@ -278,6 +280,118 @@ type adminThemeSettings struct {
|
|||||||
ContrastMultiplier float32
|
ContrastMultiplier float32
|
||||||
TextSaturationMultiplier float32
|
TextSaturationMultiplier float32
|
||||||
CustomCSSFile string
|
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 {
|
type adminSiteSettings struct {
|
||||||
@@ -462,6 +576,39 @@ func hexOf(c *hslColorField) string {
|
|||||||
return c.ToHex()
|
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) {
|
func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.adminAccessAllowed(w, r) {
|
if !a.adminAccessAllowed(w, r) {
|
||||||
return
|
return
|
||||||
@@ -477,12 +624,71 @@ func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Re
|
|||||||
ContrastMultiplier: t.ContrastMultiplier,
|
ContrastMultiplier: t.ContrastMultiplier,
|
||||||
TextSaturationMultiplier: t.TextSaturationMultiplier,
|
TextSaturationMultiplier: t.TextSaturationMultiplier,
|
||||||
CustomCSSFile: t.CustomCSSFile,
|
CustomCSSFile: t.CustomCSSFile,
|
||||||
|
Presets: a.freshPresetsFromDisk(),
|
||||||
|
CatalogThemes: builtinThemeCatalog,
|
||||||
}
|
}
|
||||||
data := adminTemplateData{App: a, ThemeSettings: settings}
|
data := adminTemplateData{App: a, ThemeSettings: settings}
|
||||||
a.populateTemplateRequestData(&data.Request, r)
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
renderAdminTemplate(w, adminThemeSettingsTemplate, data)
|
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) {
|
func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.adminAccessAllowed(w, r) {
|
if !a.adminAccessAllowed(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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/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/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("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)
|
||||||
@@ -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/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)
|
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 {
|
if a.RequiresAuth {
|
||||||
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
<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/theme-settings">← Theme settings</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1 margin-bottom-15">{{ if .PresetForm.IsNew }}Add preset{{ else }}Edit preset <code>{{ .PresetForm.Key }}</code>{{ end }}</h1>
|
||||||
|
|
||||||
|
{{ if .PresetForm.ErrorMessage }}
|
||||||
|
<p class="color-negative margin-bottom-15">{{ .PresetForm.ErrorMessage }}</p>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .PresetForm.IsNew }}
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-presets"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
{{ else }}
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-presets/{{ .PresetForm.Key }}"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .PresetForm.IsNew }}
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Preset name (key)
|
||||||
|
<input type="text" name="name" class="input" value="{{ .PresetForm.Key }}" placeholder="my-dark-theme" required
|
||||||
|
pattern="[^\s:{}[]|>&*]+" title="No spaces or special characters">
|
||||||
|
<small class="color-subdue size-h5">Used as the key in YAML and in the theme picker. Lowercase kebab-case recommended.</small>
|
||||||
|
</label>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<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 .PresetForm.BackgroundColorHex }}{{ .PresetForm.BackgroundColorHex }}{{ else }}#151823{{ end }}">
|
||||||
|
|
||||||
|
<label for="pc">Primary (accent)</label>
|
||||||
|
<input id="pc" type="color" name="primary-color"
|
||||||
|
value="{{ if .PresetForm.PrimaryColorHex }}{{ .PresetForm.PrimaryColorHex }}{{ else }}#e4cf8d{{ end }}">
|
||||||
|
|
||||||
|
<label for="ps">Positive</label>
|
||||||
|
<input id="ps" type="color" name="positive-color"
|
||||||
|
value="{{ if .PresetForm.PositiveColorHex }}{{ .PresetForm.PositiveColorHex }}{{ else }}#7fbf7f{{ end }}">
|
||||||
|
|
||||||
|
<label for="ng">Negative</label>
|
||||||
|
<input id="ng" type="color" name="negative-color"
|
||||||
|
value="{{ if .PresetForm.NegativeColorHex }}{{ .PresetForm.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 .PresetForm.Light }}checked{{ end }}>
|
||||||
|
<span>Light mode</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 .PresetForm.ContrastMultiplier }}{{ .PresetForm.ContrastMultiplier }}{{ end }}" placeholder="1.0">
|
||||||
|
</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 .PresetForm.TextSaturationMultiplier }}{{ .PresetForm.TextSaturationMultiplier }}{{ end }}" placeholder="1.0">
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">{{ if .PresetForm.IsNew }}Add preset{{ else }}Save{{ end }}</button>
|
||||||
|
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="size-h1 margin-bottom-15">Theme</h1>
|
<h1 class="size-h1 margin-bottom-15">Theme</h1>
|
||||||
|
|
||||||
|
<h2 class="size-h3 margin-bottom-10" style="margin-top:0;">Default theme</h2>
|
||||||
<p class="color-subdue margin-bottom-25">
|
<p class="color-subdue margin-bottom-25">
|
||||||
Colors are stored as HSL in the YAML, but the picker below shows hex
|
Colors are stored as HSL in the YAML, but the picker below shows hex for convenience.
|
||||||
for convenience — Glance converts on save. Theme presets and custom CSS
|
|
||||||
beyond the file path still need YAML.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-settings"
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-settings"
|
||||||
@@ -74,6 +74,61 @@
|
|||||||
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit">Cancel</a>
|
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{{/* ── Presets ── */}}
|
||||||
|
<div style="margin-top:2.5rem;max-width:36rem;">
|
||||||
|
<div class="flex" style="justify-content:space-between;align-items:baseline;margin-bottom:0.75rem;">
|
||||||
|
<h2 class="size-h3" style="margin:0;">Presets</h2>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings/presets/new"
|
||||||
|
style="font-size:0.85rem;">+ Add preset</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .ThemeSettings.Presets }}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.5rem;">
|
||||||
|
{{ range .ThemeSettings.Presets }}
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0.75rem;border:1px solid var(--color-widget-content-border);border-radius:4px;">
|
||||||
|
<div style="flex-shrink:0;pointer-events:none;">{{ .PreviewHTML }}</div>
|
||||||
|
<span style="flex:1;font-weight:500;">{{ .Key }}</span>
|
||||||
|
<a href="{{ $.App.Config.Server.BaseURL }}/edit/theme-settings/presets/{{ .Key }}"
|
||||||
|
style="font-size:0.85rem;">Edit</a>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/theme-presets/{{ .Key }}/delete"
|
||||||
|
style="display:inline;" onsubmit="return confirm('Delete preset {{ .Key }}?')">
|
||||||
|
<button type="submit" class="color-negative"
|
||||||
|
style="background:none;border:none;cursor:pointer;font-size:0.85rem;padding:0;">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<p class="color-subdue size-h5">No custom presets yet. Add one below or import from the built-in catalog.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ── Built-in catalog ── */}}
|
||||||
|
<div style="margin-top:2rem;max-width:36rem;">
|
||||||
|
<details>
|
||||||
|
<summary class="size-h3" style="cursor:pointer;user-select:none;margin-bottom:0.75rem;">
|
||||||
|
Built-in theme catalog
|
||||||
|
</summary>
|
||||||
|
<p class="color-subdue size-h5 margin-bottom-15">
|
||||||
|
Click a theme to open the preset form pre-filled with its colors. You can rename it before saving.
|
||||||
|
</p>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr));gap:0.5rem;">
|
||||||
|
{{ 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 }}
|
||||||
|
<a href="{{ $.App.Config.Server.BaseURL }}/edit/theme-settings/presets/new?name={{ .Key }}&bg={{ .BgHex }}&primary={{ .PrimaryHex }}&positive={{ .PositiveHex }}&negative={{ .NegativeHex }}&light={{ $light }}&cm={{ $cm }}&tsm={{ $tsm }}"
|
||||||
|
style="text-decoration:none;display:flex;flex-direction:column;align-items:center;gap:0.4rem;padding:0.6rem;border:1px solid var(--color-widget-content-border);border-radius:4px;transition:border-color 0.15s;"
|
||||||
|
onmouseover="this.style.borderColor='var(--color-primary)'" onmouseout="this.style.borderColor='var(--color-widget-content-border)'">
|
||||||
|
<div style="pointer-events:none;">{{ .PreviewHTML }}</div>
|
||||||
|
<span class="size-h5" style="text-align:center;color:var(--color-text-base);">{{ .Name }}</span>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
{{ template "footer.html" . }}
|
{{ template "footer.html" . }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user