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
+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.