aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/dashboard/dashboard.html138
1 files changed, 129 insertions, 9 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index d5642a9..3f5d862 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -83,6 +83,13 @@
.tree pre { margin: 2px 0 4px 1.1em; white-space: pre-wrap; word-break: break-all;
max-height: 20em; overflow: auto; }
.note { color: var(--muted); font-style: italic; }
+ .editor { margin: 2px 0 4px; }
+ .tree details > .editor { margin-left: 1.1em; }
+ .editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg);
+ background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; }
+ .editor button { font: inherit; }
+ .tree details.editing > :not(summary):not(.editor) { display: none; }
+ .tree .leaf[title] { cursor: text; }
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: 3px 8px 3px 0; border-bottom: 1px solid var(--line);
white-space: nowrap; }
@@ -580,18 +587,128 @@ async function readStore(path, shallow) {
// branch's entries are kept across reads, so what's open inside stays open.
// An empty map, list or text: shown as it is, nothing to open.
const EMPTY = { map: "{}", list: "[]", text: "\"\"" };
+const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" };
+
+// --- Editing: a double click on an entry turns it into a field holding its contents;
+// saved with PUT /store/<path> (the whole value, objects too) ---
+// A string is edited as text (sent as text/plain), anything else as JSON; "JSON"
+// switches. `done(saved)` when saved or cancelled.
+function editor(path, value, done) {
+ const message = el("span", { class: "note" });
+ const asText = typeof value === "string";
+ const text = asText ? value : JSON.stringify(value, null, 2);
+ const long = text.includes("\n") || text.length > 60 || (value !== null && typeof value === "object");
+ const field = long
+ ? el("textarea", { rows: String(Math.min(20, text.split("\n").length + 1)), spellcheck: "false" })
+ : el("input", { type: "text", spellcheck: "false" });
+ field.value = text;
+ const json = el("input", { type: "checkbox" });
+ json.checked = !asText;
+ json.addEventListener("change", () => {
+ if (json.checked) { field.value = JSON.stringify(field.value); return; }
+ try {
+ const parsed = JSON.parse(field.value);
+ if (typeof parsed !== "string") throw new Error("not a string");
+ field.value = parsed;
+ } catch {
+ json.checked = true;
+ message.textContent = "Only a JSON string can be edited as text.";
+ }
+ });
+ const save = el("button", { type: "button" }, "Save");
+ const cancel = el("button", { type: "button" }, "Cancel");
+ const submit = async () => {
+ const body = field.value;
+ if (json.checked) {
+ try { JSON.parse(body); } catch (error) {
+ message.textContent = "Not JSON: " + error.message; return;
+ }
+ }
+ save.disabled = true;
+ message.textContent = "Saving…";
+ try {
+ const response = await fetch(storeUrl(path, false), { method: "PUT", body,
+ headers: { "Content-Type": json.checked ? "application/json" : "text/plain" } });
+ if (!response.ok) {
+ throw new Error("HTTP " + response.status + (response.status === 403 ? ", not allowed" : ""));
+ }
+ done(true);
+ } catch (error) {
+ save.disabled = false;
+ message.textContent = "Not saved: " + error.message;
+ }
+ };
+ save.addEventListener("click", submit);
+ cancel.addEventListener("click", () => done(false));
+ field.addEventListener("keydown", event => {
+ if (event.key === "Escape") { event.preventDefault(); done(false); }
+ // Enter saves a line; Ctrl or ⌘ and Enter saves an area.
+ else if (event.key === "Enter" && (!long || event.ctrlKey || event.metaKey)) {
+ event.preventDefault(); submit();
+ }
+ });
+ setTimeout(() => field.focus());
+ return el("div", { class: "editor" }, field,
+ el("div", {}, save, " ", cancel, " ", el("label", {}, json, " JSON"), " ",
+ el("span", { class: "note" }, long ? "Ctrl+Enter saves, Esc cancels" : "Enter saves, Esc cancels"),
+ " ", message));
+}
+// Store paths name keys below the root; the root itself isn't written whole.
+const editable = path => path.length > 0 && addressable(path);
+// After an edit: what changed is read again (the node's `document` event follows).
+function edited(path, saved) {
+ release();
+ if (saved) documentChanged({ paths: [path] });
+}
+function editableLeaf(key, shown, value, path) {
+ const leaf = el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "), shown);
+ if (!editable(path)) return leaf;
+ leaf.title = "Double-click to edit";
+ leaf.addEventListener("dblclick", event => {
+ if (leaf.querySelector(".editor")) return;
+ event.preventDefault();
+ window.getSelection().removeAllRanges();
+ const before = [...leaf.childNodes];
+ leaf.replaceChildren(before[0], editor(path, value, saved => {
+ leaf.replaceChildren(...before);
+ edited(path, saved);
+ }));
+ });
+ return leaf;
+}
+
function documentEntry(key, summary, path) {
if (summary.size === 0 && summary.type in EMPTY) {
- return el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "),
- EMPTY[summary.type]);
+ return editableLeaf(key, EMPTY[summary.type], EMPTY_VALUE[summary.type](), path);
}
if ("value" in summary) {
- return el("div", { class: "leaf" }, el("span", { class: "key" }, key + ": "),
- JSON.stringify(summary.value));
+ return editableLeaf(key, JSON.stringify(summary.value), summary.value, path);
}
const details = el("details", {});
const described = document.createTextNode(describe(summary));
- details.append(el("summary", {}, el("span", { class: "key" }, key), " ", described));
+ const head = el("summary", {}, el("span", { class: "key" }, key), " ", described);
+ details.append(head);
+ // A double click edits the whole of it: read, then shown in a field.
+ if (editable(path)) {
+ head.title = "Double-click to edit";
+ head.addEventListener("mousedown", event => { if (event.detail > 1) event.preventDefault(); });
+ head.addEventListener("dblclick", async event => {
+ event.preventDefault();
+ if (details.querySelector(":scope > .editor")) return;
+ let value;
+ try { value = await readStore(path, false); } catch (error) {
+ described.textContent = describe(summary) + " (not read: " + error.message + ")";
+ return;
+ }
+ details.open = true;
+ details.classList.add("editing");
+ head.after(editor(path, value, saved => {
+ details.querySelector(":scope > .editor").remove();
+ details.classList.remove("editing");
+ edited(path, saved);
+ }));
+ });
+ }
// A newer summary from the parent: a branch of the same kind stays as it is.
details.updated = newer => {
summary = newer;
@@ -671,9 +788,10 @@ function documentChanged(change) {
document.getElementById("document").replaceChildren(
documentEntry("store", { type: "map", size: "…" }, []));
-// A box is busy while text in it is selected: drawing it anew would lose the selection,
-// so it waits (`held`) until the selection is gone.
+// A box is busy while text in it is selected, or something in it is being edited:
+// drawing it anew would lose that, so it waits (`held`) until it's done.
function busy(box) {
+ if (box.querySelector(".editor")) return true;
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.rangeCount) return false;
return box.contains(selection.getRangeAt(0).commonAncestorContainer);
@@ -689,14 +807,16 @@ function render() {
if (busy(document.getElementById(box))) held = true; else draw();
}
}
-document.addEventListener("selectionchange", () => {
+// What waited for a box to be free: drawn, or applied, now.
+function release() {
if (held) render();
if (documentPending !== null && !busy(document.getElementById("document-box"))) {
const change = documentPending;
documentPending = null;
documentChanged(change);
}
-});
+}
+document.addEventListener("selectionchange", release);
// A click beside the nodes: back to this node.
const graph = document.getElementById("graph");