From dafa14a51281b4c76ea409bd2dea316e2f24af53 Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 14 Oct 2026 14:25:09 -0400 Subject: [PATCH] Step 3: the layout - drag keys to move or hide them, add and delete keys, page numbers, the Restart page switch Keys screen: every key is a draggable chip (empty cells and the tray are drop zones), + New key and + New restart key dialogs, Delete this key, Companion page numbers, 'this campus has a Restart page'. A taken cell is refused on the spot; 'Layout changed: Apply will re-import ...' comes from the server's plan. Screens wait for a pending draft save before asking the server about it. Co-Authored-By: Claude Fable 5.1 --- settings/ui/app.js | 151 +++++++++++++++++++++++++++++++++++------- settings/ui/style.css | 4 ++ 2 files changed, 132 insertions(+), 23 deletions(-) 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 += ``; - else if (cell && cell.nav) out += ``; + if (nav[k]) out += ``; + else if (cell && cell.nav) out += ``; 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 = `
${c.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." : ""}

`; + const tabs = `
${c.restart_page ? `` : ""}
+ ${editing() ? `

In Companion the Status page is page ${c.restart_page ? ` and the Restart page is 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) => ``).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) => ``).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 ? `` : ``) : ""; + 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

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

`, (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") ? `

` : ""}`; } 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 @@
` : ""; 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() ? `` : ""}
Advanced
id
${esc(r.id)}
`; + ${canRunNow(r.fix) && editing() ? `` : ""}
Advanced
id
${esc(r.id)}
${editing() ? `

` : ""}`; } // ---- 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); }