Round out the editor: head widgets, undo, group/split-column, site settings

- Head widgets are now first-class in dashboard edit mode: they get
  drag/edit/delete handles, their own SortableJS group (so they can't
  cross into columns), and the layout endpoint accepts a
  headWidgets[] sequence alongside columns[].
- New POST /edit/api/restore swaps glance.yml ↔ glance.yml.bak —
  effectively undo (and click again to redo). Surfaced as a "Restore
  previous version" button on /edit.
- group and split-column are now editable inline via a new
  list-of-widgets schema type. Each item picks a widget type from a
  dropdown (excluding group/split-column to honor the backend's
  no-nesting rule) and renders that type's schema-driven sub-form.
  Switching the type re-renders the body.
- New /edit/site-settings page with a form for branding (app name,
  logo, favicon, footer, app icon/background). POST applies via the
  same surgical yaml.Node update used for widgets. Theme/server
  intentionally still YAML for now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
uhlwoogi
2026-04-30 20:43:52 +00:00
co-authored by Claude Opus 4.7
parent 7f36f30dad
commit e108feb74b
8 changed files with 463 additions and 27 deletions
+172 -12
View File
@@ -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.
+11 -3
View File
@@ -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},
},
}
+34
View File
@@ -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
+3
View File
@@ -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)
+31
View File
@@ -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;
}
+123 -11
View File
@@ -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 =
'<button type="button" class="edit-handle edit-handle-drag" title="Drag to reorder" aria-label="Drag">⋮⋮</button>' +
'<button type="button" class="edit-handle edit-handle-edit" title="Edit" aria-label="Edit">✎</button>' +
'<button type="button" class="edit-handle edit-handle-delete" title="Delete" aria-label="Delete">✕</button>';
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 =
'<button type="button" class="edit-handle edit-handle-drag" title="Drag to reorder" aria-label="Drag">⋮⋮</button>' +
'<button type="button" class="edit-handle edit-handle-edit" title="Edit" aria-label="Edit">✎</button>' +
'<button type="button" class="edit-handle edit-handle-delete" title="Delete" aria-label="Delete">✕</button>';
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(`<em>Unsupported type: ${escapeHTML(field.type)}</em>`);
}
}
// 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 `
<div class="edit-list edit-list-widgets">
<div class="edit-list-items">${items}</div>
<button type="button" class="edit-list-add" data-add-widget="1">+ Add widget</button>
</div>`;
}
function renderNestedWidget(widgetType, values) {
const schema = state.schemas?.[widgetType] || [];
const typeOptions = nestedWidgetTypes()
.map(
(t) =>
`<option value="${escapeHTML(t)}" ${t === widgetType ? "selected" : ""}>${escapeHTML(t)}</option>`,
)
.join("");
return `
<div class="edit-list-item edit-nested-widget" data-type="${escapeHTML(widgetType)}">
<div class="edit-nested-header">
<select class="edit-nested-type" title="Widget type">${typeOptions}</select>
<button type="button" class="edit-list-remove">Remove</button>
</div>
<div class="edit-nested-body">${renderForm(schema, values)}</div>
</div>`;
}
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.
@@ -0,0 +1,72 @@
{{- template "document.html" . }}
{{- define "document-title" }}Site 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">Site settings</h1>
<p class="color-subdue margin-bottom-25">
Branding shown in the header and footer. Theme colors, server settings,
and authentication still need YAML — edit <code>{{ .App.ConfigPath }}</code>
directly for those.
</p>
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/site-settings"
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
<label class="form-label widget-header">
App name
<input type="text" name="app-name" class="input" value="{{ .SiteSettings.AppName }}" placeholder="Glance">
</label>
<label class="form-label widget-header">
Logo text (header)
<input type="text" name="logo-text" class="input" value="{{ .SiteSettings.LogoText }}">
<small class="color-subdue size-h5">Shown if no logo URL is set.</small>
</label>
<label class="form-label widget-header">
Logo URL
<input type="text" name="logo-url" class="input" value="{{ .SiteSettings.LogoURL }}" placeholder="/assets/logo.svg">
</label>
<label class="form-label widget-header">
Favicon URL
<input type="text" name="favicon-url" class="input" value="{{ .SiteSettings.FaviconURL }}">
<small class="color-subdue size-h5">Defaults to the bundled glance favicon.</small>
</label>
<label class="form-label widget-header">
App icon URL (PWA)
<input type="text" name="app-icon-url" class="input" value="{{ .SiteSettings.AppIconURL }}">
</label>
<label class="form-label widget-header">
App background color (PWA)
<input type="text" name="app-background-color" class="input" value="{{ .SiteSettings.AppBackgroundColor }}" placeholder="#000000">
</label>
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;">
<input type="checkbox" name="hide-footer" {{ if .SiteSettings.HideFooter }}checked{{ end }}>
<span>Hide footer</span>
</label>
<label class="form-label widget-header">
Custom footer (HTML)
<textarea name="custom-footer" class="input" rows="4">{{ .SiteSettings.CustomFooter }}</textarea>
<small class="color-subdue size-h5">Replaces the default Glance footer if set.</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 }}
+17 -1
View File
@@ -5,7 +5,12 @@
{{- define "document-body" }}
<div class="flex flex-column body-content">
<main class="content-bounds" style="padding-block: 2rem;">
<h1 class="size-h1 margin-bottom-15">Edit</h1>
<div class="flex items-center gap-10">
<h1 class="size-h1 grow">Edit</h1>
<a href="{{ .App.Config.Server.BaseURL }}/edit/site-settings">
<button type="button">Site settings</button>
</a>
</div>
<p class="color-subdue margin-bottom-25">
Editing <code>{{ .App.ConfigPath }}</code>. Changes take effect via hot-reload.
</p>
@@ -43,6 +48,17 @@
<input type="text" name="title" required placeholder="Page title" class="input grow">
<button type="submit">Add page</button>
</form>
<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>
<p class="color-subdue margin-bottom-10">
Restores the previous contents of <code>{{ .App.ConfigPath }}</code> from the
<code>.bak</code> file written on each save. Click again to redo.
</p>
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/restore"
onsubmit="return confirm('Restore the previous version of your config?');">
<button type="submit">Restore previous version</button>
</form>
</main>
{{ template "footer.html" . }}
</div>