aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/dashboard/dashboard.html217
1 files changed, 192 insertions, 25 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index c99d773..6df3300 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -241,14 +241,17 @@
.legend .node.unreachable::before { width: 6px; height: 6px; background: #000;
border: 4px solid var(--muted); }
/* A node's menu (right-click): subscribe, its dashboard. */
- #node-menu, #user-menu { position: fixed; z-index: 10; background: var(--panel);
+ #node-menu, #user-menu, #entry-menu { position: fixed; z-index: 10; background: var(--panel);
border: 1px solid var(--line); border-radius: 6px; padding: 4px 0;
box-shadow: 0 4px 16px rgb(0 0 0 / .15); font-size: 12px; }
- #node-menu[hidden], #user-menu[hidden] { display: none; }
- #node-menu button, #user-menu button { display: block; width: 100%; text-align: left;
+ #node-menu[hidden], #user-menu[hidden], #entry-menu[hidden] { display: none; }
+ /* A finger held on an entry opens its menu (`withMenu`), not the browser's callout. */
+ #document-box summary, #document-box .leaf { -webkit-touch-callout: none; }
+ #node-menu button, #user-menu button, #entry-menu button { display: block; width: 100%; text-align: left;
font: inherit; padding: 4px 12px; background: none; border: none; color: var(--fg);
cursor: pointer; }
- #node-menu button:hover:not(:disabled), #user-menu button:hover { background: var(--bg); }
+ #node-menu button:hover:not(:disabled), #user-menu button:hover,
+ #entry-menu button:hover { background: var(--bg); }
#node-menu button:disabled, #user-menu button[aria-disabled="true"] { color: var(--muted); }
#node-menu button:disabled { cursor: default; }
#who { font: inherit; background: none; border: none; color: var(--muted); cursor: pointer; }
@@ -331,6 +334,7 @@
<button id="who" type="button" hidden aria-haspopup="menu"></button>
</header>
<div id="user-menu" role="menu" hidden></div>
+<div id="entry-menu" role="menu" hidden></div>
<dialog id="dialog"></dialog>
<main>
<section id="map">
@@ -1745,13 +1749,21 @@ const editing = element => element.querySelector(".editor") !== null;
// end), without writing it whole: a "+" on the row, the field below it. `known()`: a
// map's keys as last read, so an existing one isn't overwritten.
function addButton(kind, path, known, place) {
- const map = kind === "map";
const button = el("button", { type: "button", class: "add",
- title: map ? "Add a key" : "Insert an item" }, "+");
+ title: kind === "map" ? "Add a key" : "Insert an item" }, "+");
button.addEventListener("click", event => {
// Not a click on the row: it would open or close it.
event.preventDefault();
event.stopPropagation();
+ adding(kind, path, known, place, "-1");
+ });
+ return button;
+}
+// The field for adding to `path` (`addButton`), below its row: a list's index as
+// `index` has it at first ("-1": at the end; "": asked for).
+function adding(kind, path, known, place, index) {
+ const map = kind === "map";
+ {
const area = place();
if (editing(area)) return;
const form = editor(path, "", async (saved, response) => {
@@ -1767,7 +1779,7 @@ function addButton(kind, path, known, place) {
}, {
extra: map
? { label: "key", initial: "" }
- : { label: "index", initial: "-1", hint: "where: 0 is the first; -1 the end" },
+ : { label: "index", initial: index, hint: "where: 0 is the first; -1 the end" },
check: extra => {
form.dataset.extra = extra;
return map
@@ -1781,8 +1793,140 @@ function addButton(kind, path, known, place) {
withToken({ method: map ? "PUT" : "POST", body, headers: { "Content-Type": type } })),
});
area.prepend(form);
+ }
+}
+
+// --- An entry's menu (right-click, or a finger held on it): edit its value, rename its
+// key (a map's), add to it (a map: a key; a list: an item at the end, or at an index).
+// Each as the double-click and the "+" do. ---
+function entryMenu(x, y, items) {
+ const menu = document.getElementById("entry-menu");
+ menu.replaceChildren(...items.map(([label, act]) => {
+ const button = el("button", { type: "button", role: "menuitem" }, label);
+ button.addEventListener("click", () => { menu.hidden = true; act(); });
+ return button;
+ }));
+ if (items.length === 0) return;
+ menu.hidden = false;
+ menu.style.left = Math.max(8, Math.min(x, innerWidth - menu.offsetWidth - 8)) + "px";
+ menu.style.top = Math.max(8, Math.min(y, innerHeight - menu.offsetHeight - 8)) + "px";
+}
+// `row` opens a menu of `items()` on a right-click, or a finger held on it half a second
+// (the click that ends the touch then neither opens nor closes the row).
+const HOLD_MS = 500;
+function withMenu(row, items) {
+ row.addEventListener("contextmenu", event => {
+ if (event.target.closest(".editor, .renamer")) return;
+ // Nothing to offer: the browser's own menu.
+ const offered = items();
+ if (offered.length === 0) return;
+ event.preventDefault();
+ event.stopPropagation();
+ entryMenu(event.clientX, event.clientY, offered);
});
- return button;
+ let held = null, swallow = false;
+ row.addEventListener("pointerdown", event => {
+ if (event.pointerType !== "touch" || event.target.closest(".editor, .renamer")) return;
+ const { clientX: x, clientY: y } = event;
+ held = setTimeout(() => {
+ held = null;
+ const offered = items();
+ if (offered.length === 0) return;
+ swallow = true;
+ entryMenu(x, y, offered);
+ }, HOLD_MS);
+ const cancel = moved => {
+ if (moved && Math.hypot(moved.clientX - x, moved.clientY - y) < 10) return;
+ clearTimeout(held);
+ held = null;
+ row.removeEventListener("pointermove", cancel);
+ };
+ row.addEventListener("pointermove", cancel);
+ row.addEventListener("pointerup", () => cancel(), { once: true });
+ row.addEventListener("pointercancel", () => cancel(), { once: true });
+ });
+ row.addEventListener("click", event => {
+ if (!swallow) return;
+ swallow = false;
+ event.preventDefault();
+ event.stopPropagation();
+ }, true);
+}
+// Renaming a map's key: a field below its row, the key in it. Its value is read, written
+// under the new key (PUT), and the old key deleted (DELETE): two writes, the value as
+// JSON. `siblings()`: the map's keys as last read, so none is overwritten.
+function renaming(path, siblings, row) {
+ if (row.parentNode.querySelector(":scope > .renamer")) return;
+ const old = path[path.length - 1], parent = path.slice(0, -1);
+ const field = el("input", { type: "text", spellcheck: "false", class: "extra" });
+ field.value = old;
+ const message = el("span", { class: "note" });
+ const fail = text => { message.className = "error"; message.textContent = text; };
+ const go = el("button", { type: "button" }, "Rename");
+ const cancel = el("button", { type: "button" }, "Cancel");
+ const form = el("div", { class: "editor renamer" }, field,
+ el("div", {}, go, " ", cancel, " ",
+ el("span", { class: "note" }, "Enter renames, Esc cancels"), " ", message));
+ const close = renamed => {
+ form.remove();
+ edited(parent, renamed, renamed ? [...parent, field.value] : parent);
+ };
+ const submit = async () => {
+ const to = field.value;
+ const problem = to === "" ? "A key, please."
+ : to.includes("/") ? "A key with \"/\" can't be named in a path."
+ : to === old ? "The same key."
+ : siblings().includes(to) ? "\"" + to + "\" exists already."
+ : null;
+ if (problem) { fail(problem); return; }
+ go.disabled = true;
+ message.className = "note";
+ message.textContent = "Renaming…";
+ const why = async response => {
+ let why = response.status === 403 ? "not allowed" : "";
+ try { why = (await response.json()).error || why; } catch { /* no body */ }
+ return "HTTP " + response.status + (why ? ": " + why : "");
+ };
+ try {
+ const value = await readStore(path, false);
+ const put = await fetch(storeUrl([...parent, to], false), withToken({ method: "PUT",
+ body: JSON.stringify(value), headers: { "Content-Type": "application/json" } }));
+ if (!put.ok) throw new Error("Not renamed: " + await why(put));
+ const removed = await fetch(storeUrl(path, false), withToken({ method: "DELETE" }));
+ if (!removed.ok) {
+ throw new Error("Written under \"" + to + "\", but \"" + old + "\" not deleted: " +
+ await why(removed));
+ }
+ close(true);
+ } catch (error) {
+ go.disabled = false;
+ fail(error.message);
+ }
+ };
+ go.addEventListener("click", submit);
+ cancel.addEventListener("click", () => close(false));
+ field.addEventListener("keydown", event => {
+ if (event.key === "Escape") { event.preventDefault(); close(false); }
+ else if (event.key === "Enter") { event.preventDefault(); submit(); }
+ });
+ row.after(form);
+ setTimeout(() => { field.focus(); field.select(); });
+}
+// The items of an entry's menu: `edit`, if it can be edited; renaming, if it is a map's
+// key (`parentKind`); adding to it, if it is a map or list (`kind`) that can be written
+// in (`place`: where the field goes; `known`: its keys).
+function entryItems({ path, kind, parentKind, siblings, row, edit, known, place }) {
+ const items = [];
+ if (edit) items.push(["Edit value", edit]);
+ if (parentKind === "map" && editable(path)) {
+ items.push(["Rename key", () => renaming(path, siblings, row)]);
+ }
+ if (place && kind === "map") items.push(["Add a key", () => adding("map", path, known, place, "")]);
+ if (place && kind === "list") {
+ items.push(["Append an item", () => adding("list", path, known, place, "-1")],
+ ["Insert an item at…", () => adding("list", path, known, place, "")]);
+ }
+ return items;
}
// In the protocol's data (`_continuum`): node IDs, times and sizes glossed (`gloss`).
@@ -1794,16 +1938,19 @@ function editableLeaf(key, shown, value, path) {
...(glosses ? glossed(aliasOf(key)) : []), ": "), shown,
...(glosses ? glossed(gloss(key, value)) : []));
if (!editable(path)) return leaf;
- leaf.title = "Double-click to edit";
- leaf.addEventListener("dblclick", event => {
+ leaf.title = "Double-click to edit, right-click for more";
+ leaf.edit = () => {
if (editing(leaf)) return;
- event.preventDefault();
window.getSelection().removeAllRanges();
const before = [...leaf.childNodes];
leaf.replaceChildren(before[0], editor(path, value, saved => {
leaf.replaceChildren(...before);
edited(path, saved);
}));
+ };
+ leaf.addEventListener("dblclick", event => {
+ event.preventDefault();
+ leaf.edit();
});
return leaf;
}
@@ -1824,19 +1971,29 @@ function glow(entry) {
// One entry: a short scalar as it is; anything else a branch read when opened. A
// branch's entries are kept across reads, so what's open inside stays open.
-function documentEntry(key, summary, path) {
+// `parentKind`: what it is in (`map`, `list`); `siblings()`: the keys there.
+function documentEntry(key, summary, path, parentKind = null, siblings = () => []) {
if (summary.size === 0 && summary.type in EMPTY) {
const leaf = editableLeaf(key, EMPTY[summary.type], EMPTY_VALUE[summary.type](), path);
// An empty map or list takes its first entry here: "+" on its row, the field below.
if ((summary.type === "map" || summary.type === "list") && editable(path)) {
const below = el("div", { class: "nested" });
leaf.append(" ", addButton(summary.type, path, () => [], () => below));
+ withMenu(leaf, () => entryItems({ path, kind: summary.type, parentKind, siblings,
+ row: leaf, edit: leaf.edit, known: () => [], place: () => below }));
return el("div", {}, leaf, below);
}
+ if (leaf.edit) {
+ withMenu(leaf, () => entryItems({ path, parentKind, siblings, row: leaf, edit: leaf.edit }));
+ }
return leaf;
}
if ("value" in summary) {
- return editableLeaf(key, JSON.stringify(summary.value), summary.value, path);
+ const leaf = editableLeaf(key, JSON.stringify(summary.value), summary.value, path);
+ if (leaf.edit) {
+ withMenu(leaf, () => entryItems({ path, parentKind, siblings, row: leaf, edit: leaf.edit }));
+ }
+ return leaf;
}
const details = el("details", {});
const described = document.createTextNode(describe(summary));
@@ -1844,12 +2001,16 @@ function documentEntry(key, summary, path) {
...(glossedPath(path) ? glossed(aliasOf(key)) : []), " ", described);
details.append(head);
// A double click edits the whole of it: read, then shown in a field.
+ let edit = null;
if (editable(path)) {
- head.title = "Double-click to edit";
+ head.title = "Double-click to edit, right-click for more";
head.addEventListener("mousedown", event => { if (event.detail > 1) event.preventDefault(); });
- head.addEventListener("dblclick", async event => {
+ head.addEventListener("dblclick", event => {
event.preventDefault();
- if (details.querySelector(":scope > .editor")) return;
+ edit();
+ });
+ edit = async () => {
+ if (details.querySelector(":scope > .editor:not(.renamer)")) return;
let value;
try { value = await readStore(path, false); } catch (error) {
described.textContent = describe(summary) + " (not read: " + error.message + ")";
@@ -1858,11 +2019,11 @@ function documentEntry(key, summary, path) {
details.open = true;
details.classList.add("editing");
head.after(editor(path, value, saved => {
- details.querySelector(":scope > .editor").remove();
+ details.querySelector(":scope > .editor:not(.renamer)").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 => {
@@ -1878,12 +2039,16 @@ function documentEntry(key, summary, path) {
const children = new Map();
const browsed = summary.type === "map" || summary.type === "list";
// Adding: on the row, the field just below it (the branch opened for it).
- if (browsed && (editable(path) || path.length === 0)) {
- head.append(" ", addButton(summary.type, path, () => [...children.keys()], () => {
- details.open = true;
- return { prepend: form => head.after(form), querySelector: q => details.querySelector(":scope > " + q) };
- }));
+ const writable = browsed && (editable(path) || path.length === 0);
+ const place = () => {
+ details.open = true;
+ return { prepend: form => head.after(form), querySelector: q => details.querySelector(":scope > " + q) };
+ };
+ if (writable) {
+ head.append(" ", addButton(summary.type, path, () => [...children.keys()], place));
}
+ withMenu(head, () => entryItems({ path, kind: summary.type, parentKind, siblings, row: head,
+ edit, known: () => [...children.keys()], place: writable ? place : null }));
let read = false;
// Read once at least: what's new or changed from then on breathes (`glow`).
let filled = false;
@@ -1916,7 +2081,8 @@ function documentEntry(key, summary, path) {
glow(known.element);
continue;
}
- const element = documentEntry(k, newer, [...path, k]);
+ const element = documentEntry(k, newer, [...path, k], summary.type,
+ () => [...children.keys()]);
if (known) known.element.replaceWith(element);
children.set(k, { element, summary: newer });
if (filled) glow(element);
@@ -2629,7 +2795,7 @@ function nodeMenu(id, x, y) {
menu.style.top = Math.min(y, innerHeight - menu.offsetHeight - 8) + "px";
}
addEventListener("pointerdown", event => {
- for (const id of ["node-menu", "user-menu"]) {
+ for (const id of ["node-menu", "user-menu", "entry-menu"]) {
const menu = document.getElementById(id);
if (!menu.hidden && !menu.contains(event.target) && event.target.id !== "who") {
menu.hidden = true;
@@ -2640,6 +2806,7 @@ addEventListener("keydown", event => {
if (event.key !== "Escape") return;
document.getElementById("node-menu").hidden = true;
document.getElementById("user-menu").hidden = true;
+ document.getElementById("entry-menu").hidden = true;
});
// A node's dashboard, where it publishes one (`--dashboard-url`, in the member list),