Site settings
+
+ Branding shown in the header and footer. Theme colors, server settings,
+ and authentication still need YAML — edit {{ .App.ConfigPath }}
+ directly for those.
+
diff --git a/internal/glance/admin-edit.go b/internal/glance/admin-edit.go index 4a0e77a..8ce1bc3 100644 --- a/internal/glance/admin-edit.go +++ b/internal/glance/admin-edit.go @@ -601,11 +601,13 @@ func (a *application) handleAdminLayout(w http.ResponseWriter, r *http.Request) return } + type widgetRef struct { + Col int `json:"col"` + Idx int `json:"idx"` + } var req struct { - Columns [][]struct { - Col int `json:"col"` - Idx int `json:"idx"` - } `json:"columns"` + HeadWidgets []widgetRef `json:"headWidgets"` + Columns [][]widgetRef `json:"columns"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -632,6 +634,19 @@ func (a *application) handleAdminLayout(w http.ResponseWriter, r *http.Request) return } + // Snapshot head widgets (col = -1). Optional — pages may not have any. + var headWidgetsNode *yaml.Node + var headSnapshot []*yaml.Node + if hw, err := findOrCreateKey(pageNode, "head-widgets", false, yaml.SequenceNode); err == nil { + headWidgetsNode = hw + headSnapshot = make([]*yaml.Node, len(hw.Content)) + copy(headSnapshot, hw.Content) + } + if len(req.HeadWidgets) != len(headSnapshot) { + http.Error(w, fmt.Sprintf("layout has %d head widgets, page has %d", len(req.HeadWidgets), len(headSnapshot)), http.StatusBadRequest) + return + } + // Snapshot the existing widget nodes by [col][idx] so we can splice them // into the new layout without mutating during traversal. snapshot := make([][]*yaml.Node, len(columns.Content)) @@ -648,25 +663,51 @@ func (a *application) handleAdminLayout(w http.ResponseWriter, r *http.Request) } // Validate: each widget referenced exactly once, all references in range. - seen := make(map[[2]int]bool, totalCount) + // Column widgets use col >= 0 keys; head widgets use col = -1. + seen := make(map[[2]int]bool, totalCount+len(headSnapshot)) + checkRef := func(ref widgetRef) error { + if ref.Col == -1 { + if ref.Idx < 0 || ref.Idx >= len(headSnapshot) { + return fmt.Errorf("invalid head widget reference idx=%d", ref.Idx) + } + } else { + if ref.Col < 0 || ref.Col >= len(snapshot) || ref.Idx < 0 || ref.Idx >= len(snapshot[ref.Col]) { + return fmt.Errorf("invalid widget reference col=%d idx=%d", ref.Col, ref.Idx) + } + } + key := [2]int{ref.Col, ref.Idx} + if seen[key] { + return fmt.Errorf("widget col=%d idx=%d referenced more than once", ref.Col, ref.Idx) + } + seen[key] = true + return nil + } + for _, ref := range req.HeadWidgets { + if ref.Col != -1 { + http.Error(w, "head widgets must use col=-1", http.StatusBadRequest) + return + } + if err := checkRef(ref); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } refCount := 0 for _, newCol := range req.Columns { for _, ref := range newCol { - if ref.Col < 0 || ref.Col >= len(snapshot) || ref.Idx < 0 || ref.Idx >= len(snapshot[ref.Col]) { - http.Error(w, fmt.Sprintf("invalid widget reference col=%d idx=%d", ref.Col, ref.Idx), http.StatusBadRequest) + if ref.Col == -1 { + http.Error(w, "head widgets cannot move into columns", http.StatusBadRequest) return } - key := [2]int{ref.Col, ref.Idx} - if seen[key] { - http.Error(w, fmt.Sprintf("widget col=%d idx=%d referenced more than once", ref.Col, ref.Idx), http.StatusBadRequest) + if err := checkRef(ref); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } - seen[key] = true refCount++ } } if refCount != totalCount { - http.Error(w, fmt.Sprintf("layout references %d widgets but page has %d", refCount, totalCount), http.StatusBadRequest) + http.Error(w, fmt.Sprintf("layout references %d column widgets but page has %d", refCount, totalCount), http.StatusBadRequest) return } @@ -679,6 +720,14 @@ func (a *application) handleAdminLayout(w http.ResponseWriter, r *http.Request) } widgets.Content = newContent } + // And rebuild head widgets sequence. + if headWidgetsNode != nil { + newHead := make([]*yaml.Node, 0, len(req.HeadWidgets)) + for _, ref := range req.HeadWidgets { + newHead = append(newHead, headSnapshot[ref.Idx]) + } + headWidgetsNode.Content = newHead + } if err := editor.save(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -966,6 +1015,117 @@ func (a *application) handleAdminWidgetSchemas(w http.ResponseWriter, r *http.Re } } +// ---------- site settings (branding) ---------- + +var brandingFieldKeys = []string{ + "hide-footer", + "custom-footer", + "logo-text", + "logo-url", + "favicon-url", + "app-name", + "app-icon-url", + "app-background-color", +} + +func (a *application) handleAdminUpdateSiteSettings(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 + } + branding, err := findOrCreateKey(editor.topMapping(), "branding", true, yaml.MappingNode) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + + fields := make(map[string]interface{}) + for _, key := range brandingFieldKeys { + if !r.Form.Has(key) { + continue + } + raw := strings.TrimSpace(r.FormValue(key)) + if key == "hide-footer" { + fields[key] = raw == "on" || raw == "true" || raw == "1" + continue + } + if raw == "" { + fields[key] = nil + } else { + fields[key] = raw + } + } + // Force unchecked checkboxes to false (browsers omit them). + if _, ok := fields["hide-footer"]; !ok { + fields["hide-footer"] = false + } + + if err := applyFieldsToWidget(branding, 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/site-settings", http.StatusSeeOther) +} + +// ---------- restore from .bak ---------- + +// handleAdminRestore swaps the contents of glance.yml and glance.yml.bak. +// Each save writes a .bak of the previous version, so this is effectively an +// undo. Calling it again redoes since we put the just-current contents into +// the new .bak. +func (a *application) handleAdminRestore(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + bakPath := a.ConfigPath + ".bak" + bakContent, err := os.ReadFile(bakPath) + if err != nil { + adminError(w, http.StatusBadRequest, "no backup to restore: "+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 + } + // Move current → .bak (so user can re-restore). + if err := os.WriteFile(bakPath, currentContent, 0644); err != nil { + adminError(w, http.StatusInternalServerError, "writing new backup: "+err.Error()) + return + } + // Atomic write of .bak content into main file. + tmpPath := a.ConfigPath + ".tmp" + if err := os.WriteFile(tmpPath, bakContent, 0644); err != nil { + adminError(w, http.StatusInternalServerError, "writing temp file: "+err.Error()) + return + } + if err := os.Rename(tmpPath, a.ConfigPath); err != nil { + os.Remove(tmpPath) + adminError(w, http.StatusInternalServerError, "renaming temp file: "+err.Error()) + return + } + + http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther) +} + // ---------- page settings + reorder ---------- // pageMetadataKeys is the set of page-level keys the settings form handles. diff --git a/internal/glance/admin-schemas.go b/internal/glance/admin-schemas.go index 7e4b26b..ecf324e 100644 --- a/internal/glance/admin-schemas.go +++ b/internal/glance/admin-schemas.go @@ -236,7 +236,15 @@ var widgetSchemas = map[string][]widgetFieldSchema{ {Key: "frameless", Label: "Hide widget frame", Type: "boolean"}, }, - // group + split-column intentionally skipped: their `widgets:` field is a - // recursive list of widget objects, which the inline form generator can't - // render usefully yet. They fall through to the YAML editor. + "group": { + {Key: "title", Label: "Custom title", Type: "string"}, + {Key: "widgets", Label: "Widgets in group", Type: "list-of-widgets", Required: true, + Help: "Each tab inside the group. Nested groups and split-columns aren't allowed."}, + }, + + "split-column": { + {Key: "title", Label: "Custom title", Type: "string"}, + {Key: "max-columns", Label: "Maximum columns (≥2)", Type: "number"}, + {Key: "widgets", Label: "Widgets shown in the split", Type: "list-of-widgets", Required: true}, + }, } diff --git a/internal/glance/admin.go b/internal/glance/admin.go index 3a0d010..a2088b3 100644 --- a/internal/glance/admin.go +++ b/internal/glance/admin.go @@ -14,6 +14,7 @@ var ( adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html") adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html") adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html") + adminSiteSettingsTemplate = mustParseTemplate("admin-site-settings.html", "document.html", "footer.html") ) // allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted. @@ -249,6 +250,19 @@ type adminTemplateData struct { PageSettings *adminPageSettings IsFirstPage bool IsLastPage bool + + SiteSettings *adminSiteSettings +} + +type adminSiteSettings struct { + AppName string + LogoText string + LogoURL string + FaviconURL string + AppIconURL string + AppBackgroundColor string + HideFooter bool + CustomFooter string } func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) { @@ -383,6 +397,26 @@ func (a *application) handleAdminWidget(w http.ResponseWriter, r *http.Request) }) } +func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + b := a.Config.Branding + settings := &adminSiteSettings{ + AppName: b.AppName, + LogoText: b.LogoText, + LogoURL: b.LogoURL, + FaviconURL: b.FaviconURL, + AppIconURL: b.AppIconURL, + AppBackgroundColor: b.AppBackgroundColor, + HideFooter: b.HideFooter, + CustomFooter: string(b.CustomFooter), + } + data := adminTemplateData{App: a, SiteSettings: settings} + a.populateTemplateRequestData(&data.Request, r) + renderAdminTemplate(w, adminSiteSettingsTemplate, data) +} + func (a *application) handleAdminPageSettings(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 5620d5a..4102e21 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -455,6 +455,7 @@ func (a *application) server() (func() error, func() error) { mux.HandleFunc("GET /edit", a.handleAdminIndex) mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage) mux.HandleFunc("GET /edit/pages/{page}/settings", a.handleAdminPageSettings) + mux.HandleFunc("GET /edit/site-settings", a.handleAdminSiteSettings) mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget) mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage) @@ -477,6 +478,8 @@ func (a *application) server() (func() error, func() error) { mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/size", a.handleAdminColumnSize) mux.HandleFunc("POST /edit/api/pages/{page}/fields", a.handleAdminUpdatePageFields) mux.HandleFunc("POST /edit/api/pages/{page}/move", a.handleAdminMovePage) + mux.HandleFunc("POST /edit/api/restore", a.handleAdminRestore) + mux.HandleFunc("POST /edit/api/site-settings", a.handleAdminUpdateSiteSettings) if a.RequiresAuth { mux.HandleFunc("GET /login", a.handleLoginPageRequest) diff --git a/internal/glance/static/css/edit-mode.css b/internal/glance/static/css/edit-mode.css index 90cc1fb..244a31c 100644 --- a/internal/glance/static/css/edit-mode.css +++ b/internal/glance/static/css/edit-mode.css @@ -557,3 +557,34 @@ textarea.edit-input { border-color: var(--color-primary); color: var(--color-primary); } + +/* ---------- Nested widget editor (group / split-column) ---------- */ + +.edit-nested-widget { + border: 1px solid var(--color-widget-content-border); + border-radius: 4px; + padding: 0.6rem; + background: rgba(0, 0, 0, 0.1); +} + +.edit-nested-header { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.6rem; +} + +.edit-nested-type { + background: var(--color-widget-background); + border: 1px solid var(--color-widget-content-border); + border-radius: 3px; + color: var(--color-text-base); + font: inherit; + font-size: var(--font-size-h5); + padding: 0.2rem 0.4rem; + flex: 1; +} + +.edit-nested-body > .edit-field:last-child { + margin-bottom: 0; +} diff --git a/internal/glance/static/js/edit-mode.js b/internal/glance/static/js/edit-mode.js index e31c0a2..d10e0a3 100644 --- a/internal/glance/static/js/edit-mode.js +++ b/internal/glance/static/js/edit-mode.js @@ -45,6 +45,10 @@ function widgetTypeOf(widget) { /* ---------- Layout (drag-drop) ---------- */ function indexWidgets() { + document.querySelectorAll(".head-widgets > .widget").forEach((w, idx) => { + w.dataset.origCol = "-1"; + w.dataset.origIdx = String(idx); + }); document.querySelectorAll(".page-column").forEach((col, colIdx) => { col.dataset.colIdx = String(colIdx); col.querySelectorAll(":scope > .widget").forEach((w, idx) => { @@ -55,16 +59,18 @@ function indexWidgets() { } function addHandles() { - document.querySelectorAll(".page-column > .widget").forEach((w) => { - if (w.querySelector(":scope > .edit-mode-handles")) return; - const handles = document.createElement("div"); - handles.className = "edit-mode-handles"; - handles.innerHTML = - '' + - '' + - ''; - w.appendChild(handles); - }); + document + .querySelectorAll(".page-column > .widget, .head-widgets > .widget") + .forEach((w) => { + if (w.querySelector(":scope > .edit-mode-handles")) return; + const handles = document.createElement("div"); + handles.className = "edit-mode-handles"; + handles.innerHTML = + '' + + '' + + ''; + w.appendChild(handles); + }); } function removeHandles() { @@ -184,6 +190,15 @@ function setStatus(text, kind) { async function saveLayout() { const { slug } = pageInfo(); + const headWidgets = []; + document + .querySelectorAll(".head-widgets > .widget") + .forEach((w) => + headWidgets.push({ + col: parseInt(w.dataset.origCol, 10), + idx: parseInt(w.dataset.origIdx, 10), + }), + ); const columns = []; document.querySelectorAll(".page-column").forEach((col) => { const refs = []; @@ -201,7 +216,7 @@ async function saveLayout() { const r = await fetch(api(`/edit/api/pages/${encodeURIComponent(slug)}/layout`), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ columns }), + body: JSON.stringify({ headWidgets, columns }), }); if (!r.ok) { setStatus("Save failed: " + (await r.text()), "error"); @@ -312,11 +327,52 @@ function renderField(field, value) { return wrap(renderListStrings(value)); case "list-objects": return wrap(renderListObjects(field.items, value)); + case "list-of-widgets": + return wrap(renderListOfWidgets(value)); default: return wrap(`Unsupported type: ${escapeHTML(field.type)}`); } } +// Widget types allowed inside a group/split-column. Nested groups and +// split-columns are rejected by the backend, so we hide them in the picker. +function nestedWidgetTypes() { + const all = Object.keys(state.schemas || {}); + return all + .filter((t) => t !== "group" && t !== "split-column") + .sort(); +} + +function renderListOfWidgets(values) { + values = Array.isArray(values) ? values : []; + const items = values + .map((v) => renderNestedWidget(v?.type || "clock", v || {})) + .join(""); + return ` +
`; +} + +function renderNestedWidget(widgetType, values) { + const schema = state.schemas?.[widgetType] || []; + const typeOptions = nestedWidgetTypes() + .map( + (t) => + ``, + ) + .join(""); + return ` + `; +} + function renderListStrings(values) { values = Array.isArray(values) ? values : []; const items = values @@ -416,6 +472,26 @@ function collectFieldValue(fieldEl, field) { ); return filtered.length ? filtered : null; } + case "list-of-widgets": { + const items = fieldEl.querySelectorAll( + ":scope > .edit-list > .edit-list-items > .edit-nested-widget", + ); + const arr = [...items].map((item) => { + const type = item.dataset.type; + const body = item.querySelector(":scope > .edit-nested-body"); + const schema = state.schemas?.[type] || []; + const values = collectValues(body, schema); + // Drop null/empty fields; emit type plus whatever was filled in. + const out = { type }; + for (const [k, v] of Object.entries(values)) { + if (v !== null && !(Array.isArray(v) && v.length === 0)) { + out[k] = v; + } + } + return out; + }); + return arr.length ? arr : null; + } } return null; } @@ -682,10 +758,31 @@ function openDialog({ title, schema, values, submitLabel, onSubmit }) { tmp.innerHTML = renderListObjectItem(itemsSchema, {}); items.appendChild(tmp.firstElementChild); items.lastElementChild.querySelector("input, textarea, select")?.focus(); + } else if (list.classList.contains("edit-list-widgets")) { + const defaultType = nestedWidgetTypes()[0] || "clock"; + const tmp = document.createElement("div"); + tmp.innerHTML = renderNestedWidget(defaultType, {}); + items.appendChild(tmp.firstElementChild); + items.lastElementChild + .querySelector(".edit-nested-body input, .edit-nested-body textarea, .edit-nested-body select") + ?.focus(); } } }); + // Type-switching for nested widgets: re-render the item's body with the + // schema for the newly chosen type. + overlay.addEventListener("change", (e) => { + const sel = e.target.closest(".edit-nested-type"); + if (!sel) return; + const item = sel.closest(".edit-nested-widget"); + const newType = sel.value; + item.dataset.type = newType; + const body = item.querySelector(":scope > .edit-nested-body"); + const schema = state.schemas?.[newType] || []; + body.innerHTML = renderForm(schema, {}); + }); + overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }); @@ -877,6 +974,21 @@ function enterEditMode() { }), ); }); + // Head widgets get their own group so they can't be dragged into columns + // (they live in a separate yaml sequence and render differently). + document.querySelectorAll(".head-widgets").forEach((hw) => { + state.sortables.push( + Sortable.create(hw, { + group: "glance-head-widgets", + handle: ".edit-handle-drag", + draggable: ".widget", + animation: 150, + ghostClass: "sortable-ghost", + dragClass: "sortable-drag", + onEnd: () => saveLayout(), + }), + ); + }); localStorage.setItem(STORAGE_KEY, "1"); // Pre-warm schema cache so first dialog open is instant. diff --git a/internal/glance/templates/admin-site-settings.html b/internal/glance/templates/admin-site-settings.html new file mode 100644 index 0000000..5040c2c --- /dev/null +++ b/internal/glance/templates/admin-site-settings.html @@ -0,0 +1,72 @@ +{{- template "document.html" . }} + +{{- define "document-title" }}Site settings - {{ .App.Config.Branding.AppName }}{{ end }} + +{{- define "document-body" }} +
+ Branding shown in the header and footer. Theme colors, server settings,
+ and authentication still need YAML — edit {{ .App.ConfigPath }}
+ directly for those.
+
Editing {{ .App.ConfigPath }}. Changes take effect via hot-reload.
+ Restores the previous contents of {{ .App.ConfigPath }} from the
+ .bak file written on each save. Click again to redo.
+