diff --git a/settings/ui/app.js b/settings/ui/app.js
index d0d949a..2a6eda8 100644
--- a/settings/ui/app.js
+++ b/settings/ui/app.js
@@ -101,17 +101,22 @@
function saveDraft() {
badge();
clearTimeout(state.saveTimer);
- state.saveTimer = setTimeout(async () => {
- try {
- const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
- state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
- } catch (e) {
- if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
- else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
- else alertBox("Could not save the draft: " + e.message);
- }
- }, 400);
+ state.saving = new Promise((done) => {
+ state.saveTimer = setTimeout(async () => {
+ try {
+ const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
+ state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
+ } catch (e) {
+ if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
+ else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
+ else alertBox("Could not save the draft: " + e.message);
+ }
+ done();
+ }, 400);
+ });
+ return state.saving;
}
+ const flush = () => state.saving || Promise.resolve();
// set a value at a dotted path in the draft (creating objects on the way)
function setPath(path, value) {
if (!ensureDraft()) return;
@@ -261,42 +266,138 @@
});
}
- function deckHtml(deck, c, which, selected) {
+ function deckHtml(deck, c, which, selected, draggable) {
const cells = {};
const rp = c.restart_page;
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : c.page), "2,0": "DOWN" };
+ const drag = (what) => editing() && draggable ? ` draggable="true" data-drag="${esc(what)}"` : "";
let out = `
`;
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) {
const k = `${r},${col}`, cell = cells[k];
- if (nav[k]) out += `
${esc(nav[k])}
`;
- else if (cell && cell.nav) out += `
${esc(cell.nav)}
`;
+ if (nav[k]) out += `
${esc(nav[k])}
`;
+ else if (cell && cell.nav) out += `
${esc(cell.nav)}
`;
else if (cell) {
const s = deck && deck[cell.id];
const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd";
const blink = s && s.b !== s.b2 ? " blink" : "";
- out += `
${esc(s ? s.x : cell.label)}
`;
- } else out += `
`;
+ out += `
${esc(s ? s.x : cell.label)}
`;
+ } else out += `
`;
}
return out + `
`;
}
// ---- Keys ------------------------------------------------------------------
async function keys(rest) {
+ await flush();
const o = await api("/api/overview").catch(() => ({ deck: null }));
const c = cfg();
const restart = rest[0] === "restart";
const sel = restart ? rest[1] : rest[0];
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
- const tabs = `Status page (page ${c.page}) ${c.restart_page ? `Restart page (page ${c.restart_page.page}) ` : ""}
`;
- let panel = `Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here." : ""}
`;
+ const tabs = `Status page (page ${c.page}) ${c.restart_page ? `Restart page (page ${c.restart_page.page}) ` : ""}
+ ${editing() ? `In Companion the Status page is page ${c.restart_page ? ` and the Restart page is page ` : ""} this campus has a Restart page
` : ""}`;
+ let panel = `Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here, drag a key to move it, drag it onto the tray to hide it." : ""}
`;
if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); }
- const tray = c.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => `${esc(t.label)} `).join(" ");
- main.innerHTML = `Keys The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does (moving keys arrives with step 3).
${tabs}
- ${deckHtml(o.deck, c, restart ? "restart" : "status", sel)}${!restart && tray ? `
Shown inside their group, no key of their own: ${tray}
` : ""}
${panel}
`;
+ const trayKeys = c.tiles.filter((t) => !t.pos && t.kind !== "master");
+ const tray = trayKeys.map((t) => `${esc(t.label)} `).join(" ");
+ let note = "";
+ if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `Layout changed: Apply will re-import the ${v.plan.pages.filter((p) => p === "status" || p === "restart").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.
`; } catch (e) {} }
+ const adders = editing() ? (restart ? `+ New restart key ` : `+ New key `) : "";
+ main.innerHTML = `Keys The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does, drag it to move it.
${tabs}${note}
+ ${restart && !c.restart_page ? "
This campus has no Restart page.
" : deckHtml(o.deck, c, restart ? "restart" : "status", sel, true)}
+ ${!restart ? `
Shown inside their group, no key of their own: ${tray || "(none) "}${editing() ? " · drop a key here to hide it " : ""}
` : ""}
+
${adders}
${panel}
`;
wireKeyPanel(sel, restart);
+ wireLayout(restart);
+ }
+ function takenCells(c, which) {
+ const taken = new Set(["0,0", "1,0", "2,0"]);
+ if (which === "restart") { for (const k of c.restart_page.keys) taken.add(String(k.pos)); taken.add(String(c.restart_page.back_pos)); }
+ else { for (const t of c.tiles) if (t.pos) taken.add(String(t.pos)); if (c.restart_page) taken.add(String(c.restart_page.jump_pos)); }
+ return taken;
+ }
+ function firstEmpty(c, which) {
+ const taken = takenCells(c, which);
+ for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) if (!taken.has(`${r},${col}`)) return [r, col];
+ return null;
+ }
+ function parsePos(text, c, which, allowBlank) {
+ const m = text.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/);
+ if (!m) { if (allowBlank && !text.trim()) return null; alertBox("Row, column: two numbers, like 2, 5."); return false; }
+ const pos = [+m[1], +m[2]];
+ if (pos[0] > 3 || pos[1] > 7) { alertBox("The deck has rows 0-3 and columns 0-7."); return false; }
+ if (takenCells(c, which).has(String(pos))) { alertBox(`Row ${pos[0]} column ${pos[1]} is taken. Hide or move a key first` + (allowBlank ? ", or leave the position blank." : ".")); return false; }
+ return pos;
+ }
+ function wireLayout(restart) {
+ const move = (what, pos) => {
+ if (!ensureDraft()) return;
+ const c = state.draft.config, [kind, id] = what.split(":");
+ if (kind === "tile") { const t = c.tiles.find((x) => x.id === id); if (!t) return; if (pos) t.pos = pos; else delete t.pos; }
+ else if (kind === "rkey") { const k = c.restart_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; }
+ else if (kind === "jump" && pos) c.restart_page.jump_pos = pos;
+ else if (kind === "back" && pos) c.restart_page.back_pos = pos;
+ saveDraft(); route();
+ };
+ let dragging = null;
+ for (const el of main.querySelectorAll("[data-drag]")) {
+ el.addEventListener("dragstart", (e) => { dragging = el.dataset.drag; e.dataTransfer.setData("text/plain", dragging); e.dataTransfer.effectAllowed = "move"; });
+ el.addEventListener("dragend", () => { dragging = null; });
+ }
+ for (const el of main.querySelectorAll("[data-drop]")) {
+ el.addEventListener("dragover", (e) => { e.preventDefault(); el.classList.add("over"); });
+ el.addEventListener("dragleave", () => el.classList.remove("over"));
+ el.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (what) move(what, el.dataset.drop.split(",").map(Number)); });
+ }
+ const tray = main.querySelector("#tray");
+ if (tray && editing()) {
+ tray.addEventListener("dragover", (e) => { e.preventDefault(); tray.classList.add("over"); });
+ tray.addEventListener("dragleave", () => tray.classList.remove("over"));
+ tray.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (!what || !what.startsWith("tile:")) return; const t = cfg().tiles.find((x) => x.id === what.slice(5)); if (t && t.kind) return alertBox("That key always has a spot on the deck."); move(what, null); });
+ }
+ for (const inp of main.querySelectorAll("input.pagenum")) inp.onchange = () => {
+ const n = parseInt(inp.value, 10); if (!(n >= 1)) return;
+ if (!ensureDraft()) return;
+ const c = state.draft.config;
+ if (inp.dataset.which === "status") { c.page = n; c.page_name = `${n}\n${(c.page_name || "\nStatus").split("\n").slice(1).join("\n") || "Status"}`; }
+ else { c.restart_page.page = n; c.restart_page.page_name = `${n}\n${(c.restart_page.page_name || "\nRestart").split("\n").slice(1).join("\n") || "Restart"}`; }
+ saveDraft(); route();
+ };
+ const hr = main.querySelector("#hasrestart"); if (hr) hr.onchange = () => {
+ if (!ensureDraft()) { hr.checked = !hr.checked; return; }
+ const c = state.draft.config;
+ if (!hr.checked) confirmBox("Switch the Restart page off? Its keys are removed from the settings; page " + c.restart_page.page + " stays in Companion until someone clears it.", "Switch it off", () => { delete c.restart_page; saveDraft(); location.hash = "#keys"; route(); }, null);
+ else { const pos = firstEmpty(c, "status"); if (!pos) return alertBox("No free spot on the Status page for the RESTART PAGE key."); c.restart_page = { page: c.page + 1, page_name: `${c.page + 1}\nRestart`, jump_pos: pos, back_pos: pos, keys: [] }; saveDraft(); location.hash = "#keys/restart"; route(); }
+ if (!hr.checked) hr.checked = true;
+ };
+ const nk = main.querySelector("#newkey"); if (nk) nk.onclick = () => dialog(`New key Add the key Cancel
It starts with no checks: add them on the key, then Apply.
`, (b) => {
+ b.querySelector("#dcancel").onclick = closeDialog;
+ b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = b.querySelector("#nlabel").value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); };
+ b.querySelector("#dok").onclick = () => {
+ const label = b.querySelector("#nlabel").value.trim(), id = b.querySelector("#nid").value.trim();
+ if (!label || !/^[A-Za-z0-9_]+$/.test(id)) return;
+ if (cfg().tiles.some((t) => t.id === id)) return alertBox("A key with that id exists.");
+ const pos = parsePos(b.querySelector("#npos").value, cfg(), "status", true); if (pos === false) return;
+ if (!ensureDraft()) return;
+ const t = { id, label, checks: [] }; if (pos) t.pos = pos;
+ state.draft.config.tiles.push(t); saveDraft(); closeDialog(); location.hash = "#keys/" + id;
+ };
+ });
+ const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`New restart key ${Object.entries(c.fixes).map(([id, f]) => `${esc(f.short || id)} – ${esc(f.label || "")} `).join("")} Add the key Cancel
`, (b) => {
+ b.querySelector("#dcancel").onclick = closeDialog;
+ b.querySelector("#dok").onclick = () => {
+ const label = b.querySelector("#nlabel").value.trim(), fix = b.querySelector("#nfix").value;
+ if (!label) return;
+ const pos = parsePos(b.querySelector("#npos").value, cfg(), "restart", false); if (!pos) return;
+ if (!ensureDraft()) return;
+ let id = "rs_" + label.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""), n = 2;
+ while (state.draft.config.restart_page.keys.some((k) => k.id === id)) id = id.replace(/_\d+$/, "") + "_" + n++;
+ state.draft.config.restart_page.keys.push({ id, label, fix, pos, watch: [] }); saveDraft(); closeDialog(); location.hash = "#keys/restart/" + id;
+ };
+ }); };
}
async function keyPanel(i, hold) {
@@ -332,17 +433,20 @@
return `${esc(t.label)} ${t.pos ? `row ${t.pos[0]}, column ${t.pos[1]}` : "inside its group"}${t.kind ? ` · ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}` : ""}
${kt.says && kt.says.length ? `
WHAT THIS KEY WILL SAY: ${esc(kt.says.join(", "))}
` : ""}
HOLDING ${esc(hold)} WILL: ${esc(kt.holding)}${t.hold_always ? " also when green " : ""}
- ${label}${members}${holdAlways}${checks ? `
Checks:
${checks} ` : ""}${addMenu}${pos}
`;
+ ${label}${members}${holdAlways}${checks ? `Checks:
${checks} ` : ""}${addMenu}${pos}
+ ${editing() && (!t.kind || t.kind === "network") ? `Delete this key
` : ""}`;
}
function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; }
function wireKeyPanel(sel, restart) {
const c = cfg();
const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel);
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
+ const dk = main.querySelector("#delkey"); if (dk) dk.onclick = () => confirmBox(`Delete the key ${c.tiles[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const id = cc.tiles[i].id; cc.tiles.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = "#keys"; });
+ const dr = main.querySelector("#delrkey"); if (dr) dr.onclick = () => confirmBox("Delete this restart key?", "Delete", () => { if (!ensureDraft()) return; const ks = state.draft.config.restart_page.keys; ks.splice(ks.findIndex((x) => x.id === sel), 1); saveDraft(); location.hash = "#keys/restart"; });
for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; state.draft.config.tiles[i].checks.splice(+b.dataset.removecheck, 1); saveDraft(); route(); });
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); };
- const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); };
+ const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { const t0 = cfg().tiles[i]; const was = t0.pos; delete t0.pos; const p2 = parsePos(pos.value, cfg(), "status", true); if (was) t0.pos = was; if (p2 === false) return; if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; if (p2) t.pos = p2; else delete t.pos; saveDraft(); route(); };
const rebuildCheck = async (j) => {
if (!ensureDraft()) return;
const t = state.draft.config.tiles[i], chk = t.checks[j];
@@ -369,7 +473,7 @@
What holding runs ${Object.entries(c.fixes).map(([id, fx]) => `${esc(fx.short || id)} – ${esc(fx.label || "")} `).join("")}
Keep flashing HOLD TO FIX while these are down (hold cmd/ctrl for several) ${Object.keys(c.fixes).map((id) => `fix ${esc(id)} `).join("")}${[...conns].sort().map((cn) => `connection ${esc(cn)} `).join("")}
` : "";
return `${esc(r.label)} row ${r.pos[0]}, column ${r.pos[1]}
HOLDING ${esc(hold)} WILL: ${esc(f.label || r.fix)}, even when the Status page says it is fine.
${r.watch && r.watch.length ? `
Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}
` : ""}${edit}
- ${canRunNow(r.fix) && editing() ? `
Run this fix now ` : ""}
Advanced id ${esc(r.id)} `;
+ ${canRunNow(r.fix) && editing() ? `Run this fix now ` : ""}Advanced id ${esc(r.id)} ${editing() ? `Delete this key
` : ""}`;
}
// ---- Computers ---------------------------------------------------------------
@@ -479,6 +583,7 @@
// ---- Review & Apply -------------------------------------------------------------
async function review() {
+ await flush();
const st = await api("/api/apply/status");
if (st.state === "running") return applyProgress(st);
const d = await api("/api/draft"); state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; badge();
diff --git a/settings/ui/style.css b/settings/ui/style.css
index 4cfbe50..c05e506 100644
--- a/settings/ui/style.css
+++ b/settings/ui/style.css
@@ -91,3 +91,7 @@ ul.changes li.command { font-family: var(--mono); font-size: .9em; color: var(--
.notice.problem { background: #fdecea; color: #7a1d16; border: 1px solid var(--problem); }
ol.steps { padding-left: 18px; } ol.steps li { margin: 3px 0; }
tr.sel { background: var(--accent-soft); }
+/* step 3: layout */
+.key[draggable="true"] { cursor: grab; } .key.empty[data-drop] { outline: 1px dashed #555; opacity: .7; } .key.empty.over { outline: 2px solid var(--accent); opacity: 1; }
+.tray.over { outline: 2px solid var(--accent); border-radius: 8px; } .tray button[draggable="true"] { cursor: grab; }
+input.pagenum { width: 4.5em; padding: 2px 4px; font: inherit; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }