/* 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 = '' + '' + ''; 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 ? '*' : ""; const help = field.help ? `${escapeHTML(field.help)}` : ""; const wrap = (input) => `
${help} ${input}
`; 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(`
`); } return wrap( `` + (field.validator ? '
' : ""), ); case "multiline": return wrap( ``, ); case "number": return wrap( ``, ); case "boolean": return `
${help}
`; case "select": { const opts = (field.options || []) .map( (o) => ``, ) .join(""); return wrap( ``, ); } case "list-strings": return wrap(renderListStrings(value)); case "list-objects": return wrap(renderListObjects(field.items, value)); default: return wrap(`Unsupported type: ${escapeHTML(field.type)}`); } } function renderListStrings(values) { values = Array.isArray(values) ? values : []; const items = values .map( (v) => `
`, ) .join(""); return `
${items}
`; } 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 `
${items}
`; } function renderListObjectItem(itemsSchema, value) { const fields = itemsSchema.map((f) => renderField(f, value?.[f.key])).join(""); return `
${fields}
`; } 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) => ` `, ) .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 = ` `; 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 = ''; 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 = ` `; 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(); } });