Files
modern-glance/internal/glance/static/js/edit-mode.js
T
uhlwoogiandClaude Opus 4.7 e108feb74b 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>
2026-04-30 20:43:52 +00:00

1103 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Edit mode for the dashboard.
*
* Toggle: #edit-mode-toggle. Adds drag/edit/delete handles per widget +
* "+ Add widget" buttons per column. Drag-drop saves to /edit/api/.../layout.
* Edit handle opens an inline form dialog driven by widget schema (falls
* back to the YAML editor for unscheaded widget types). Delete handle posts
* to /edit/api/.../delete. Persisted across reloads via localStorage.
*/
const STORAGE_KEY = "glance_edit_mode";
const state = {
active: false,
sortables: [],
schemas: null, // cached schema map, keyed by widget type
};
function pageInfo() {
return {
slug: typeof pageData !== "undefined" ? pageData.slug || "" : "",
baseURL: typeof pageData !== "undefined" ? pageData.baseURL || "" : "",
};
}
function api(path) {
return pageInfo().baseURL + path;
}
function escapeHTML(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
}
function cssEscape(s) {
if (window.CSS && CSS.escape) return CSS.escape(s);
return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
}
function widgetTypeOf(widget) {
const m = String(widget.className || "").match(/widget-type-(\S+)/);
return m ? m[1] : "";
}
/* ---------- 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) => {
w.dataset.origCol = String(colIdx);
w.dataset.origIdx = String(idx);
});
});
}
function addHandles() {
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() {
document.querySelectorAll(".edit-mode-handles").forEach((el) => el.remove());
}
function addColumnAddButtons() {
document.querySelectorAll(".page-column").forEach((col, colIdx) => {
if (col.querySelector(":scope > .edit-add-widget")) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "edit-add-widget";
btn.dataset.col = String(colIdx);
btn.textContent = "+ Add widget";
col.appendChild(btn);
});
}
function removeColumnAddButtons() {
document.querySelectorAll(".edit-add-widget").forEach((b) => b.remove());
}
function inferColumnSize(col) {
const m = String(col.className || "").match(/page-column-(\S+)/);
return m ? m[1] : "full";
}
function addColumnHeaders() {
const cols = document.querySelectorAll(".page-column");
cols.forEach((col, colIdx) => {
if (col.querySelector(":scope > .edit-column-header")) return;
const size = inferColumnSize(col);
const header = document.createElement("div");
header.className = "edit-column-header";
header.dataset.col = String(colIdx);
header.innerHTML = `
<span class="edit-column-label">Column ${colIdx + 1}</span>
<select class="edit-column-size" title="Column size">
<option value="small" ${size === "small" ? "selected" : ""}>small</option>
<option value="full" ${size === "full" ? "selected" : ""}>full</option>
</select>
<div class="edit-column-spacer"></div>
<button type="button" class="edit-column-move" data-dir="up" title="Move left" ${colIdx === 0 ? "disabled" : ""}>←</button>
<button type="button" class="edit-column-move" data-dir="down" title="Move right" ${colIdx === cols.length - 1 ? "disabled" : ""}>→</button>
<button type="button" class="edit-column-delete" title="Delete column" ${cols.length === 1 ? "disabled" : ""}>✕</button>
`;
col.insertBefore(header, col.firstChild);
});
// Add a "+ Add column" button in the page-columns container.
const container = document.querySelector(".page-columns");
if (container && !container.parentElement.querySelector(":scope > .edit-add-column")) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "edit-add-column";
btn.textContent = "+ Add column";
container.parentElement.insertBefore(btn, container.nextSibling);
}
}
function removeColumnHeaders() {
document.querySelectorAll(".edit-column-header").forEach((el) => el.remove());
document.querySelectorAll(".edit-add-column").forEach((el) => el.remove());
}
async function postColumnAction(path, body) {
const { slug, baseURL } = pageInfo();
const init = { method: "POST" };
if (body instanceof URLSearchParams) {
init.headers = { "Content-Type": "application/x-www-form-urlencoded" };
init.body = body.toString();
}
setStatus("Saving…", "saving");
const r = await fetch(
baseURL + "/edit/api/pages/" + encodeURIComponent(slug) + path,
init,
);
if (!r.ok) {
setStatus("Failed: " + (await r.text()), "error");
return false;
}
location.reload();
return true;
}
async function addColumn(size) {
const body = new URLSearchParams();
body.set("size", size || "full");
return postColumnAction("/columns", body);
}
async function deleteColumn(col) {
if (!confirm("Delete this column and everything in it?")) return;
return postColumnAction(`/columns/${col}/delete`, null);
}
async function moveColumn(col, dir) {
return postColumnAction(`/columns/${col}/move?dir=${dir}`, null);
}
async function setColumnSize(col, size) {
const body = new URLSearchParams();
body.set("size", size);
return postColumnAction(`/columns/${col}/size`, body);
}
function setStatus(text, kind) {
let el = document.getElementById("edit-mode-status");
if (!el) {
el = document.createElement("div");
el.id = "edit-mode-status";
document.body.appendChild(el);
}
el.textContent = text;
el.className = 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 = [];
col.querySelectorAll(":scope > .widget").forEach((w) => {
refs.push({
col: parseInt(w.dataset.origCol, 10),
idx: parseInt(w.dataset.origIdx, 10),
});
});
columns.push(refs);
});
setStatus("Saving…", "saving");
try {
const r = await fetch(api(`/edit/api/pages/${encodeURIComponent(slug)}/layout`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ headWidgets, columns }),
});
if (!r.ok) {
setStatus("Save failed: " + (await r.text()), "error");
return;
}
location.reload();
} catch (e) {
setStatus("Save failed: " + e.message, "error");
}
}
async function deleteWidget(widget) {
if (!confirm("Delete this widget?")) return;
const { slug } = pageInfo();
const col = widget.dataset.origCol;
const idx = widget.dataset.origIdx;
setStatus("Deleting…", "saving");
try {
const r = await fetch(
api(`/edit/api/pages/${encodeURIComponent(slug)}/widgets/${col}/${idx}/delete`),
{ method: "POST" },
);
if (!r.ok && !r.redirected) {
setStatus("Delete failed: " + (await r.text()), "error");
return;
}
location.reload();
} catch (e) {
setStatus("Delete failed: " + e.message, "error");
}
}
/* ---------- Schema-driven dialog ---------- */
async function getSchemas() {
if (state.schemas !== null) return state.schemas;
try {
const r = await fetch(api("/edit/api/widget-schemas"));
state.schemas = r.ok ? (await r.json()) || {} : {};
} catch {
state.schemas = {};
}
return state.schemas;
}
async function getWidgetFields(col, idx) {
const { slug } = pageInfo();
const r = await fetch(
api(`/edit/api/pages/${encodeURIComponent(slug)}/widgets/${col}/${idx}/fields`),
);
if (!r.ok) throw new Error(await r.text());
return (await r.json()) || {};
}
function renderField(field, value) {
const required = field.required ? '<span class="edit-required">*</span>' : "";
const help = field.help ? `<small class="edit-help">${escapeHTML(field.help)}</small>` : "";
const wrap = (input) => `
<div class="edit-field" data-field-key="${escapeHTML(field.key)}" data-field-type="${escapeHTML(field.type)}">
<label class="edit-label">${escapeHTML(field.label)}${required}</label>
${help}
${input}
</div>`;
const lookupAttr = field.lookup ? ` data-lookup="${escapeHTML(field.lookup)}"` : "";
const validatorAttr = field.validator ? ` data-validator="${escapeHTML(field.validator)}"` : "";
switch (field.type) {
case "string":
if (field.lookup) {
return wrap(`
<div class="edit-autocomplete">
<input type="text" class="edit-input"${lookupAttr}${validatorAttr} value="${escapeHTML(value)}" ${field.required ? "required" : ""} autocomplete="off">
<div class="edit-autocomplete-list" hidden></div>
<div class="edit-validate-state"></div>
</div>`);
}
return wrap(
`<input type="text" class="edit-input"${validatorAttr} value="${escapeHTML(value)}" ${field.required ? "required" : ""}>` +
(field.validator ? '<div class="edit-validate-state"></div>' : ""),
);
case "multiline":
return wrap(
`<textarea class="edit-input" rows="6" ${field.required ? "required" : ""}>${escapeHTML(value)}</textarea>`,
);
case "number":
return wrap(
`<input type="number" class="edit-input" value="${value ?? ""}" ${field.required ? "required" : ""}>`,
);
case "boolean":
return `
<div class="edit-field" data-field-key="${escapeHTML(field.key)}" data-field-type="boolean">
<label class="edit-checkbox"><input type="checkbox" ${value ? "checked" : ""}><span>${escapeHTML(field.label)}</span></label>
${help}
</div>`;
case "select": {
const opts = (field.options || [])
.map(
(o) =>
`<option value="${escapeHTML(o)}" ${value === o ? "selected" : ""}>${escapeHTML(o)}</option>`,
)
.join("");
return wrap(
`<select class="edit-input" ${field.required ? "required" : ""}><option value=""></option>${opts}</select>`,
);
}
case "list-strings":
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
.map(
(v) => `
<div class="edit-list-item">
<input type="text" class="edit-input" value="${escapeHTML(v)}">
<button type="button" class="edit-list-remove" aria-label="Remove">×</button>
</div>`,
)
.join("");
return `
<div class="edit-list edit-list-strings">
<div class="edit-list-items">${items}</div>
<button type="button" class="edit-list-add">+ Add</button>
</div>`;
}
function renderListObjects(itemsSchema, values) {
values = Array.isArray(values) ? values : [];
const items = values.map((v) => renderListObjectItem(itemsSchema, v)).join("");
const schemaJSON = escapeHTML(JSON.stringify(itemsSchema));
return `
<div class="edit-list edit-list-objects" data-items-schema="${schemaJSON}">
<div class="edit-list-items">${items}</div>
<button type="button" class="edit-list-add">+ Add</button>
</div>`;
}
function renderListObjectItem(itemsSchema, value) {
const fields = itemsSchema.map((f) => renderField(f, value?.[f.key])).join("");
return `
<div class="edit-list-item edit-list-object">
${fields}
<button type="button" class="edit-list-remove">Remove</button>
</div>`;
}
function renderForm(schema, values) {
return schema.map((f) => renderField(f, values?.[f.key])).join("");
}
/* ---------- Collect values ---------- */
function collectValues(container, schema) {
const result = {};
for (const f of schema) {
const fieldEl = container.querySelector(
`:scope > .edit-field[data-field-key="${cssEscape(f.key)}"]`,
);
result[f.key] = fieldEl ? collectFieldValue(fieldEl, f) : null;
}
return result;
}
function collectFieldValue(fieldEl, field) {
switch (field.type) {
case "string":
case "multiline": {
// Inputs may be wrapped in .edit-autocomplete when the schema
// declares a lookup, so we can't rely on a direct-child selector.
const el = fieldEl.querySelector("input, textarea");
const v = (el?.value ?? "").trim();
return v === "" ? null : v;
}
case "number": {
const el = fieldEl.querySelector("input");
const v = (el?.value ?? "").trim();
if (v === "") return null;
const n = Number(v);
return Number.isNaN(n) ? null : n;
}
case "boolean": {
const el = fieldEl.querySelector(":scope > .edit-checkbox > input[type=checkbox]");
return !!el?.checked;
}
case "select": {
const el = fieldEl.querySelector(":scope > select");
return el?.value || null;
}
case "list-strings": {
const inputs = fieldEl.querySelectorAll(
":scope > .edit-list > .edit-list-items > .edit-list-item > input",
);
const arr = [...inputs].map((i) => i.value.trim()).filter(Boolean);
return arr.length ? arr : null;
}
case "list-objects": {
const items = fieldEl.querySelectorAll(
":scope > .edit-list > .edit-list-items > .edit-list-object",
);
const arr = [...items].map((item) => collectValues(item, field.items));
const filtered = arr.filter((o) =>
Object.values(o).some(
(v) => v !== null && v !== "" && !(Array.isArray(v) && v.length === 0),
),
);
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;
}
/* ---------- Autocomplete + validation ---------- */
const lookupCache = new Map(); // key: kind + ":" + query -> suggestions
async function fetchLookup(kind, query) {
const key = kind + ":" + query;
if (lookupCache.has(key)) return lookupCache.get(key);
try {
const r = await fetch(api(`/edit/api/lookup/${encodeURIComponent(kind)}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
if (!r.ok) return [];
const data = await r.json();
const list = data.suggestions || [];
lookupCache.set(key, list);
return list;
} catch {
return [];
}
}
async function fetchValidation(kind, value) {
try {
const r = await fetch(api(`/edit/api/validate/${encodeURIComponent(kind)}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
if (!r.ok) return { valid: false, error: await r.text() };
return await r.json();
} catch (e) {
return { valid: false, error: e.message };
}
}
function debounce(fn, ms) {
let t = null;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
function renderSuggestions(listEl, suggestions) {
if (!suggestions || suggestions.length === 0) {
listEl.hidden = true;
listEl.innerHTML = "";
return;
}
listEl.hidden = false;
listEl.innerHTML = suggestions
.map(
(s, i) => `
<button type="button" class="edit-autocomplete-item" data-index="${i}">
<div class="edit-autocomplete-display">${escapeHTML(s.display || s.value)}</div>
${s.hint ? `<div class="edit-autocomplete-hint">${escapeHTML(s.hint)}</div>` : ""}
</button>`,
)
.join("");
// Stash data on the list element for the click handler.
listEl._suggestions = suggestions;
}
function attachLookup(input) {
if (input._lookupAttached) return;
input._lookupAttached = true;
const kind = input.dataset.lookup;
const wrapper = input.closest(".edit-autocomplete");
const listEl = wrapper?.querySelector(":scope > .edit-autocomplete-list");
if (!wrapper || !listEl) return;
const updateSuggestions = debounce(async () => {
const q = input.value.trim();
if (q.length < 2) {
listEl.hidden = true;
return;
}
const suggestions = await fetchLookup(kind, q);
if (input.value.trim() !== q) return; // user kept typing
renderSuggestions(listEl, suggestions);
}, 300);
input.addEventListener("input", updateSuggestions);
input.addEventListener("focus", () => {
if (listEl.children.length > 0) listEl.hidden = false;
});
input.addEventListener("blur", () => {
// Delay so a click on a suggestion can register first.
setTimeout(() => (listEl.hidden = true), 150);
});
listEl.addEventListener("mousedown", (e) => {
const btn = e.target.closest(".edit-autocomplete-item");
if (!btn) return;
e.preventDefault(); // keep focus on the input
const idx = parseInt(btn.dataset.index, 10);
const s = listEl._suggestions?.[idx];
if (!s) return;
input.value = s.value;
// Auto-fill sibling fields if the suggestion provides extras.
if (s.extra) {
const itemScope =
input.closest(".edit-list-object") || input.closest(".edit-dialog-body");
for (const [k, v] of Object.entries(s.extra)) {
const sibling = itemScope?.querySelector(
`:scope > .edit-field[data-field-key="${cssEscape(k)}"] input, :scope > .edit-field[data-field-key="${cssEscape(k)}"] textarea`,
);
if (sibling && !sibling.value.trim()) {
sibling.value = v;
}
}
}
listEl.hidden = true;
listEl.innerHTML = "";
clearValidationState(input);
});
}
function setValidationState(input, kind, message) {
const stateEl =
input
.closest(".edit-autocomplete, .edit-field")
?.querySelector(":scope > .edit-validate-state, :scope .edit-validate-state");
if (!stateEl) return;
stateEl.className = "edit-validate-state edit-validate-" + kind;
stateEl.textContent = message || "";
input.classList.toggle("edit-input-invalid", kind === "error");
}
function clearValidationState(input) {
const stateEl =
input
.closest(".edit-autocomplete, .edit-field")
?.querySelector(":scope > .edit-validate-state, :scope .edit-validate-state");
if (stateEl) {
stateEl.className = "edit-validate-state";
stateEl.textContent = "";
}
input.classList.remove("edit-input-invalid");
}
function isEmpty(v) {
if (v === null || v === undefined) return true;
if (typeof v === "string") return v.trim() === "";
if (Array.isArray(v)) return v.length === 0;
return false;
}
// Walk the form recursively and return errors for any required field that's
// empty. This duplicates HTML5's `required`, but the dialog's Save button
// isn't an actual form-submit, so we have to enforce it ourselves.
function checkRequired(container, schema) {
const errors = [];
for (const f of schema) {
const fieldEl = container.querySelector(
`:scope > .edit-field[data-field-key="${cssEscape(f.key)}"]`,
);
if (!fieldEl) continue;
const value = collectFieldValue(fieldEl, f);
if (f.required && isEmpty(value)) {
const input = fieldEl.querySelector("input, textarea, select");
errors.push({ input, fieldEl, message: `${f.label} is required.` });
}
if (f.type === "list-objects") {
const items = fieldEl.querySelectorAll(
":scope > .edit-list > .edit-list-items > .edit-list-object",
);
items.forEach((item) => {
const itemValues = collectValues(item, f.items);
// Skip all-empty rows; they'll be filtered out on save.
if (Object.values(itemValues).every(isEmpty)) return;
errors.push(...checkRequired(item, f.items));
});
}
}
return errors;
}
async function preSubmitValidate(dialogBody, schema) {
const errors = [];
// First pass: required-field check.
const requiredErrors = checkRequired(dialogBody, schema);
for (const e of requiredErrors) {
if (e.input) setValidationState(e.input, "error", e.message);
errors.push(e);
}
if (errors.length > 0) return errors;
// Second pass: network validators (only for non-empty inputs).
const inputs = dialogBody.querySelectorAll("input[data-validator]");
for (const input of inputs) {
const value = input.value.trim();
if (value === "") continue;
const kind = input.dataset.validator;
setValidationState(input, "checking", "Checking…");
const r = await fetchValidation(kind, value);
if (r.valid) {
setValidationState(input, "ok", r.hint ? "✓ " + r.hint : "✓");
} else {
setValidationState(input, "error", r.error || "Invalid value");
errors.push({ input, message: r.error });
}
}
return errors;
}
/* ---------- Dialog ---------- */
function openDialog({ title, schema, values, submitLabel, onSubmit }) {
const overlay = document.createElement("div");
overlay.className = "edit-dialog-overlay";
overlay.innerHTML = `
<div class="edit-dialog" role="dialog" aria-modal="true">
<div class="edit-dialog-header">
<h2>${escapeHTML(title)}</h2>
<button type="button" class="edit-dialog-close" aria-label="Close">×</button>
</div>
<div class="edit-dialog-body">${renderForm(schema, values)}</div>
<div class="edit-dialog-error" hidden></div>
<div class="edit-dialog-actions">
<button type="button" class="edit-dialog-cancel">Cancel</button>
<button type="button" class="edit-dialog-save">${escapeHTML(submitLabel || "Save")}</button>
</div>
</div>`;
const close = () => overlay.remove();
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
close();
return;
}
if (e.target.closest(".edit-dialog-close, .edit-dialog-cancel")) {
close();
return;
}
const removeBtn = e.target.closest(".edit-list-remove");
if (removeBtn) {
removeBtn.closest(".edit-list-item").remove();
return;
}
const addBtn = e.target.closest(".edit-list-add");
if (addBtn) {
const list = addBtn.closest(".edit-list");
const items = list.querySelector(":scope > .edit-list-items");
if (list.classList.contains("edit-list-strings")) {
const div = document.createElement("div");
div.className = "edit-list-item";
div.innerHTML =
'<input type="text" class="edit-input"><button type="button" class="edit-list-remove" aria-label="Remove">×</button>';
items.appendChild(div);
div.querySelector("input").focus();
} else if (list.classList.contains("edit-list-objects")) {
const itemsSchema = JSON.parse(list.dataset.itemsSchema);
const tmp = document.createElement("div");
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();
});
overlay.querySelector(".edit-dialog-save").addEventListener("click", async () => {
const errEl = overlay.querySelector(".edit-dialog-error");
const saveBtn = overlay.querySelector(".edit-dialog-save");
errEl.hidden = true;
saveBtn.disabled = true;
try {
const validationErrors = await preSubmitValidate(
overlay.querySelector(".edit-dialog-body"),
schema,
);
if (validationErrors.length > 0) {
errEl.textContent = "Some fields didn't validate — see details next to each one.";
errEl.hidden = false;
validationErrors[0].input?.focus();
return;
}
const newValues = collectValues(overlay.querySelector(".edit-dialog-body"), schema);
await onSubmit(newValues);
} catch (e) {
errEl.textContent = e.message || String(e);
errEl.hidden = false;
errEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
} finally {
saveBtn.disabled = false;
}
});
document.body.appendChild(overlay);
overlay.querySelector("input, textarea, select")?.focus();
// Wire up autocomplete on any pre-rendered or future inputs with a lookup.
overlay.querySelectorAll("input[data-lookup]").forEach(attachLookup);
new MutationObserver((records) => {
for (const r of records) {
r.addedNodes?.forEach((n) => {
if (n.nodeType !== 1) return;
n.querySelectorAll?.("input[data-lookup]").forEach(attachLookup);
if (n.matches?.("input[data-lookup]")) attachLookup(n);
});
}
}).observe(overlay, { childList: true, subtree: true });
// Clear validation hints when user types in a validator-flagged input.
overlay.addEventListener("input", (e) => {
if (e.target.matches?.("input[data-validator]")) {
clearValidationState(e.target);
}
});
}
async function openEditDialog(col, idx, widgetType) {
const { slug, baseURL } = pageInfo();
const schemas = await getSchemas();
const schema = schemas[widgetType];
if (!schema) {
// No schema — fall back to YAML editor for this widget type.
location.href = `${baseURL}/edit/pages/${encodeURIComponent(slug)}/widgets/${col}/${idx}`;
return;
}
let values = {};
try {
values = await getWidgetFields(col, idx);
} catch (e) {
alert("Couldn't load widget fields: " + e.message);
return;
}
openDialog({
title: `Edit ${widgetType}`,
schema,
values,
submitLabel: "Save",
async onSubmit(newValues) {
const r = await fetch(
api(
`/edit/api/pages/${encodeURIComponent(slug)}/widgets/${col}/${idx}/fields`,
),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newValues),
},
);
if (!r.ok) throw new Error(await r.text());
location.reload();
},
});
}
async function openCreateDialog(col, widgetType) {
const { slug, baseURL } = pageInfo();
const schemas = await getSchemas();
const schema = schemas[widgetType];
if (!schema) {
location.href = `${baseURL}/edit/pages/${encodeURIComponent(slug)}/widgets/${col}/new?type=${encodeURIComponent(widgetType)}`;
return;
}
openDialog({
title: `Add ${widgetType}`,
schema,
values: {},
submitLabel: "Create",
async onSubmit(newValues) {
const r = await fetch(
api(`/edit/api/pages/${encodeURIComponent(slug)}/widgets/${col}/create`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: widgetType, fields: newValues }),
},
);
if (!r.ok) throw new Error(await r.text());
location.reload();
},
});
}
async function showAddPicker(col) {
const { slug, baseURL } = pageInfo();
const schemas = await getSchemas();
const types = Object.keys(schemas).sort();
const overlay = document.createElement("div");
overlay.className = "edit-dialog-overlay";
overlay.innerHTML = `
<div class="edit-dialog edit-dialog-narrow" role="dialog" aria-modal="true">
<div class="edit-dialog-header">
<h2>Add widget</h2>
<button type="button" class="edit-dialog-close" aria-label="Close">×</button>
</div>
<div class="edit-dialog-body">
<div class="edit-type-grid">
${types.map((t) => `<button type="button" class="edit-type-option" data-type="${escapeHTML(t)}">${escapeHTML(t)}</button>`).join("")}
</div>
<p class="edit-help" style="margin-top:1rem;">
Need a widget type that isn't listed? Use the
<a href="${baseURL}/edit/pages/${encodeURIComponent(slug)}" target="_blank">advanced editor</a>.
</p>
</div>
</div>`;
const close = () => overlay.remove();
overlay.addEventListener("click", (e) => {
if (e.target === overlay || e.target.closest(".edit-dialog-close")) {
close();
return;
}
const btn = e.target.closest(".edit-type-option");
if (btn) {
close();
openCreateDialog(col, btn.dataset.type);
}
});
overlay.addEventListener("keydown", (e) => {
if (e.key === "Escape") close();
});
document.body.appendChild(overlay);
}
/* ---------- Mode toggle ---------- */
function enterEditMode() {
if (state.active) return;
if (typeof Sortable === "undefined") {
console.error("edit-mode: Sortable library not loaded");
return;
}
state.active = true;
document.body.dataset.editMode = "true";
document.querySelectorAll("#edit-mode-toggle").forEach((b) => b.classList.add("active"));
indexWidgets();
addHandles();
addColumnHeaders();
addColumnAddButtons();
setStatus("Edit mode — drag widgets to rearrange");
document.querySelectorAll(".page-column").forEach((col) => {
state.sortables.push(
Sortable.create(col, {
group: "glance-widgets",
handle: ".edit-handle-drag",
draggable: ".widget",
animation: 150,
ghostClass: "sortable-ghost",
dragClass: "sortable-drag",
onEnd: () => saveLayout(),
}),
);
});
// 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.
getSchemas();
}
function exitEditMode() {
if (!state.active) return;
state.active = false;
document.body.removeAttribute("data-edit-mode");
document.querySelectorAll("#edit-mode-toggle").forEach((b) => b.classList.remove("active"));
state.sortables.forEach((s) => s.destroy());
state.sortables = [];
removeHandles();
removeColumnHeaders();
removeColumnAddButtons();
document.getElementById("edit-mode-status")?.remove();
localStorage.removeItem(STORAGE_KEY);
}
function toggleEditMode() {
state.active ? exitEditMode() : enterEditMode();
}
function waitForContent(callback) {
const target = document.getElementById("page-content");
if (!target) return;
if (target.children.length > 0) {
callback();
return;
}
const obs = new MutationObserver(() => {
if (target.children.length > 0) {
obs.disconnect();
callback();
}
});
obs.observe(target, { childList: true });
}
/* ---------- Click routing ---------- */
document.addEventListener("click", (e) => {
const toggle = e.target.closest("#edit-mode-toggle");
if (toggle) {
e.preventDefault();
toggleEditMode();
return;
}
const editBtn = e.target.closest(".edit-handle-edit");
if (editBtn) {
e.preventDefault();
const widget = editBtn.closest(".widget");
if (widget) {
openEditDialog(
parseInt(widget.dataset.origCol, 10),
parseInt(widget.dataset.origIdx, 10),
widgetTypeOf(widget),
);
}
return;
}
const delBtn = e.target.closest(".edit-handle-delete");
if (delBtn) {
e.preventDefault();
const widget = delBtn.closest(".widget");
if (widget) deleteWidget(widget);
return;
}
const addBtn = e.target.closest(".edit-add-widget");
if (addBtn) {
e.preventDefault();
showAddPicker(parseInt(addBtn.dataset.col, 10));
return;
}
const addColBtn = e.target.closest(".edit-add-column");
if (addColBtn) {
e.preventDefault();
const size = prompt("Column size: 'small' or 'full'", "full");
if (size && (size === "small" || size === "full")) addColumn(size);
return;
}
const moveColBtn = e.target.closest(".edit-column-move");
if (moveColBtn) {
e.preventDefault();
const header = moveColBtn.closest(".edit-column-header");
moveColumn(parseInt(header.dataset.col, 10), moveColBtn.dataset.dir);
return;
}
const delColBtn = e.target.closest(".edit-column-delete");
if (delColBtn) {
e.preventDefault();
const header = delColBtn.closest(".edit-column-header");
deleteColumn(parseInt(header.dataset.col, 10));
return;
}
});
document.addEventListener("change", (e) => {
const sizeSel = e.target.closest(".edit-column-size");
if (sizeSel) {
const header = sizeSel.closest(".edit-column-header");
setColumnSize(parseInt(header.dataset.col, 10), sizeSel.value);
}
});
waitForContent(() => {
if (localStorage.getItem(STORAGE_KEY) === "1") {
enterEditMode();
}
});