Add in-place dashboard edit mode

A toggle on the dashboard flips into edit mode where widgets get
drag/edit/delete handles and a "+ Add widget" button per column. Most
edits no longer require touching YAML.

- Drag-drop reorder via SortableJS, persisted through a bulk-layout
  endpoint that rebuilds each column's widgets sequence in the
  yaml.Node tree.
- Inline form dialog driven by per-widget field schemas
  (admin-schemas.go) covering 25 widget types. Surgical updates apply
  changed fields to the widget node so untouched keys and comments
  survive. Falls back to the existing YAML editor for group and
  split-column (those need a recursive nested-widget UI).
- Per-column "+ Add widget" picker that opens the dialog pre-filled
  with sensible defaults for the chosen type.
- Field validation and autocomplete for weather location (Open-Meteo
  geocoding), market symbol (Yahoo Finance), and RSS feed URL
  (gofeed). Network failures soft-pass for weather/markets so the
  save isn't blocked when external APIs are unreachable.
- Picking a market suggestion auto-fills the company name field via
  per-suggestion `extra` fields.
- Edit mode persists across reloads via localStorage so saves don't
  drop the user back into view mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
uhlwoogi
2026-04-30 16:51:52 +00:00
co-authored by Claude Opus 4.7
parent 2e198a19b4
commit 4b8a34a2ae
10 changed files with 2347 additions and 43 deletions
+479
View File
@@ -0,0 +1,479 @@
/* Edit mode — in-place dashboard editing UI. Hidden unless body[data-edit-mode]. */
#edit-mode-toggle {
background: transparent;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-text-subdue);
}
#edit-mode-toggle.active {
color: var(--color-primary);
}
body[data-edit-mode] .widget {
position: relative;
outline: 1px dashed var(--color-text-subdue);
outline-offset: 4px;
transition: outline-color 0.15s;
}
body[data-edit-mode] .widget:hover {
outline-color: var(--color-primary);
}
body[data-edit-mode] .widget.sortable-ghost {
opacity: 0.4;
}
body[data-edit-mode] .widget.sortable-drag {
cursor: grabbing;
}
.edit-mode-handles {
display: none;
position: absolute;
top: -10px;
right: 8px;
z-index: 10;
gap: 4px;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 4px;
padding: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
body[data-edit-mode] .edit-mode-handles {
display: flex;
}
.edit-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--color-text-base);
cursor: pointer;
border-radius: 3px;
font-size: 14px;
line-height: 1;
text-decoration: none;
}
.edit-handle:hover {
background: var(--color-widget-background-highlight);
color: var(--color-primary);
}
.edit-handle-drag {
cursor: grab;
}
.edit-handle-drag:active {
cursor: grabbing;
}
.edit-handle-delete:hover {
color: var(--color-negative);
}
#edit-mode-status {
display: none;
position: fixed;
bottom: 1rem;
right: 1rem;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 4px;
padding: 0.5rem 1rem;
font-size: var(--font-size-h5);
color: var(--color-text-base);
z-index: 100;
}
body[data-edit-mode] #edit-mode-status {
display: block;
}
#edit-mode-status.saving {
color: var(--color-primary);
}
#edit-mode-status.error {
color: var(--color-negative);
border-color: var(--color-negative);
}
.edit-add-widget {
display: block;
width: 100%;
margin-top: var(--widget-gap);
padding: 0.75rem;
background: transparent;
border: 1px dashed var(--color-text-subdue);
border-radius: var(--border-radius);
color: var(--color-text-subdue);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h4);
}
.edit-add-widget:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* ---------- Dialog ---------- */
.edit-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow-y: auto;
}
.edit-dialog {
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 6px;
width: 100%;
max-width: 640px;
max-height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.edit-dialog-narrow {
max-width: 480px;
}
.edit-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-widget-content-border);
}
.edit-dialog-header h2 {
margin: 0;
font-size: var(--font-size-h2);
color: var(--color-text-highlight);
}
.edit-dialog-close {
background: transparent;
border: none;
color: var(--color-text-subdue);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 3px;
}
.edit-dialog-close:hover {
background: var(--color-widget-background-highlight);
color: var(--color-text-base);
}
.edit-dialog-body {
padding: 1rem 1.25rem;
overflow-y: auto;
flex: 1;
color: var(--color-text-base);
}
.edit-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--color-widget-content-border);
}
.edit-dialog-actions button {
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--color-popover-border);
background: var(--color-widget-background);
color: var(--color-text-base);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h4);
}
.edit-dialog-actions .edit-dialog-save {
background: var(--color-primary);
color: var(--color-background);
border-color: var(--color-primary);
font-weight: bold;
}
.edit-dialog-actions button:hover {
filter: brightness(1.1);
}
.edit-dialog-error {
margin: 0 1.25rem 1rem;
padding: 0.75rem;
background: hsla(0, 70%, 50%, 0.15);
border: 1px solid var(--color-negative);
border-radius: 4px;
color: var(--color-negative);
font-size: var(--font-size-h5);
white-space: pre-wrap;
}
/* ---------- Form fields ---------- */
.edit-field {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.edit-label {
font-size: var(--font-size-h5);
color: var(--color-text-highlight);
font-weight: 600;
}
.edit-required {
color: var(--color-negative);
margin-left: 0.25rem;
}
.edit-help {
font-size: var(--font-size-h6);
color: var(--color-text-subdue);
line-height: 1.4;
}
.edit-input {
background: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border);
border-radius: 3px;
padding: 0.5rem 0.6rem;
color: var(--color-text-base);
font: inherit;
font-size: var(--font-size-h4);
width: 100%;
box-sizing: border-box;
}
.edit-input:focus {
outline: none;
border-color: var(--color-primary);
}
textarea.edit-input {
resize: vertical;
font-family: monospace;
font-size: var(--font-size-h5);
}
.edit-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: var(--font-size-h4);
color: var(--color-text-highlight);
font-weight: 600;
}
.edit-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
/* ---------- List editors ---------- */
.edit-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.edit-list-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.edit-list-strings .edit-list-item {
display: flex;
gap: 0.5rem;
align-items: center;
}
.edit-list-strings .edit-list-item .edit-input {
flex: 1;
}
.edit-list-object {
border: 1px solid var(--color-widget-content-border);
border-radius: 4px;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.1);
position: relative;
}
.edit-list-object .edit-list-remove {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.6rem;
font-size: var(--font-size-h6);
}
.edit-list-remove {
background: transparent;
border: 1px solid var(--color-popover-border);
color: var(--color-text-subdue);
border-radius: 3px;
cursor: pointer;
font: inherit;
padding: 0.25rem 0.5rem;
}
.edit-list-remove:hover {
color: var(--color-negative);
border-color: var(--color-negative);
}
.edit-list-add {
align-self: flex-start;
background: transparent;
border: 1px dashed var(--color-text-subdue);
color: var(--color-text-subdue);
border-radius: 3px;
padding: 0.4rem 0.8rem;
cursor: pointer;
font: inherit;
font-size: var(--font-size-h5);
}
.edit-list-add:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
/* ---------- Type picker ---------- */
.edit-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.edit-type-option {
padding: 0.75rem 0.5rem;
background: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border);
border-radius: 4px;
color: var(--color-text-base);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h5);
text-align: center;
}
.edit-type-option:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-widget-background-highlight);
}
/* ---------- Autocomplete ---------- */
.edit-autocomplete {
position: relative;
}
.edit-autocomplete-list {
position: absolute;
top: calc(100% + 2px);
left: 0;
right: 0;
z-index: 10;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 4px;
max-height: 18rem;
overflow-y: auto;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
.edit-autocomplete-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: none;
border-bottom: 1px solid var(--color-widget-content-border);
padding: 0.5rem 0.6rem;
color: var(--color-text-base);
cursor: pointer;
font: inherit;
}
.edit-autocomplete-item:last-child {
border-bottom: none;
}
.edit-autocomplete-item:hover,
.edit-autocomplete-item:focus {
background: var(--color-widget-background-highlight);
}
.edit-autocomplete-display {
font-size: var(--font-size-h5);
color: var(--color-text-highlight);
}
.edit-autocomplete-hint {
font-size: var(--font-size-h6);
color: var(--color-text-subdue);
margin-top: 2px;
}
/* ---------- Per-field validation state ---------- */
.edit-validate-state {
font-size: var(--font-size-h6);
margin-top: 0.25rem;
min-height: 1em;
}
.edit-validate-checking {
color: var(--color-text-subdue);
font-style: italic;
}
.edit-validate-ok {
color: var(--color-positive);
}
.edit-validate-error {
color: var(--color-negative);
}
.edit-input-invalid {
border-color: var(--color-negative);
}
+1
View File
@@ -64,3 +64,4 @@
@import "popover.css";
@import "utils.css";
@import "mobile.css";
@import "edit-mode.css";
+874
View File
@@ -0,0 +1,874 @@
/* 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(".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").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 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 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({ 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));
default:
return wrap(`<em>Unsupported type: ${escapeHTML(field.type)}</em>`);
}
}
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;
}
}
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();
}
}
});
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();
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(),
}),
);
});
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();
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));
}
});
waitForContent(() => {
if (localStorage.getItem(STORAGE_KEY) === "1") {
enterEditMode();
}
});
File diff suppressed because one or more lines are too long