diff --git a/settings/ui/app.js b/settings/ui/app.js index b3ebf38..afaa277 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -169,7 +169,7 @@ function fieldFor(path) { const F = state.schema.fields; if (F[path]) return F[path]; - const generic = path.replace(/^tiles\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^tiles\.\d+\./, "tiles[].") + const generic = path.replace(/^(tiles|ready_page\.keys)\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^(tiles|ready_page\.keys)\.\d+\./, "tiles[].") .replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.") .replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|site\.live_services|service_windows)\.\d+\./, "$1[]."); return F[generic] || null; @@ -311,20 +311,48 @@ const tabs = `
${c.restart_page ? `` : ""}${c.ready_page ? `` : ""} ${editing() ? `page ${c.restart_page ? ` · restart page ` : ""} ` : ""}
`; let panel = `

Click a key.${editing() ? " Drag to move; drop on the tray to hide." : ""}

`; - if (ready) { const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1; panel = await readyKeyPanel(i, hold); } + if (ready) { + const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1; + panel = await readyKeyPanel(i, hold); + if (i >= 0 && editing() && c.ready_page.keys[i].kind !== "ready") panel += await keyPanel(i, hold, "ready_page.keys"); + } else 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 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 re-imports the ${v.plan.pages.filter((p) => p === "status" || p === "restart" || p === "ready").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.
`; } catch (e) {} } - const adders = editing() && !ready ? (restart ? `` : ``) : ""; + const adders = editing() ? (ready ? (c.ready_page ? `` : "") : restart ? `` : ``) : ""; main.innerHTML = `

Keys${hint("The deck as people hold it. Click a key to see what it watches and what holding it does.")}

${tabs}${note}
${restart && !c.restart_page ? "

No Restart page.

" : ready && !c.ready_page ? "

No READY page.

" : deckHtml(o.deck, c, restart ? "restart" : ready ? "ready" : "status", sel, true)} ${!restart && !ready ? `

In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray || "none"}

` : ""}
${adders}
${panel}
`; - wireKeyPanel(sel, restart || ready); + wireKeyPanel(sel, restart, ready ? "ready_page.keys" : "tiles"); wireLayout(restart); + const nd = main.querySelector("#newdkey"); if (nd) nd.onclick = () => newReadyKey(); + } + // A new READY page key: a checklist line the page checks itself, a tap-to-confirm line, or any check. + function newReadyKey() { + const c = cfg(); + dialog(`

New READY key

+ + +
`, (b) => { + const kind = b.querySelector("#nkind"), item = b.querySelector("#nitem"); + kind.onchange = () => { item.hidden = kind.value !== "ready"; }; + b.querySelector("#dcancel").onclick = closeDialog; + b.querySelector("#dok").onclick = () => { + const label = b.querySelector("#nlabel").value.trim().toUpperCase(); + if (!label) return alertBox("Give it a label."); + const pos = parsePos(b.querySelector("#npos").value, cfg(), "ready", false); if (!pos) return; + if (!ensureDraft()) return; + const keys = state.draft.config.ready_page.keys; + let id = "rd_" + (slug(label) || "key"), n = 2; while (keys.some((k) => k.id === id) || state.draft.config.tiles.some((t) => t.id === id)) id = "rd_" + slug(label) + "_" + n++; + const checks = kind.value === "manual" ? [{ type: "ready_manual", item: slug(label) || id }] + : kind.value === "ready" ? [{ type: "ready", item: item.value, fix: "ready_recheck" }] : []; + keys.push({ id, label, pos, checks }); saveDraft(); closeDialog(); location.hash = "#keys/ready/" + id; + }; + }); } function takenCells(c, which) { const taken = new Set(["0,0", "1,0", "2,0"]); @@ -416,8 +444,11 @@ }); }; } - async function keyPanel(i, hold) { - const c = cfg(), t = c.tiles[i]; + // A Status page tile ("tiles") or a READY page key ("ready_page.keys"): the same editor for both. + const keyList = (c, list) => (list === "ready_page.keys" ? (c.ready_page || { keys: [] }).keys : c.tiles); + const READY_ONLY = ["ready", "ready_manual"]; + async function keyPanel(i, hold, list = "tiles") { + const c = cfg(), t = keyList(c, list)[i], onReady = list !== "tiles"; const k = await api("/api/keys"); const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] }; // A Companion connection is picked from the ones Companion has (typing still works). @@ -434,9 +465,13 @@ const header = q !== lastQ ? `
${esc(q)}
` : ""; lastQ = q; const summary = `${what}${hint(sentence)}`; if (!editing()) return `${header}
${summary}

${esc(sentence)}

`; - const base = `tiles.${i}.checks.${j}.`; + const base = `${list}.${i}.checks.${j}.`; let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => f === "conn" ? inputHtml(base + f, F("tiles[].checks[]." + f), chk[f], connList).replace("checklist line${hint("What the READY page looks at for this key")}` + : f === "item" && chk.type === "ready_manual" + ? inputHtml(base + f, Object.assign({}, F("tiles[].checks[].item"), { help: "A short name for what is tapped done, like green_room (its tap is remembered by this name for the week)" }), chk[f]) : inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join(""); if (chk.type === "remote") { const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]); @@ -451,13 +486,14 @@
more${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}

type ${esc(chk.type)}

${run}
`; }).join(""); - const plain = !t.members && t.kind !== "master"; - const members = t.members ? (editing() ? `
group members${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => ``).join(" ")}
` : `

Sums up ${t.members.map((m) => `${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}

`) : ""; - const addMenu = editing() && plain ? `` : ""; - const label = editing() ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : ""; - const holdAlways = editing() && plain ? `
hold also when green${hint("A fix that runs on every hold, even with nothing failing")}
` : ""; + const plain = !t.members && t.kind !== "master" && t.kind !== "ready"; + const members = t.members && !onReady ? (editing() ? `
group members${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => ``).join(" ")}
` : `

Sums up ${t.members.map((m) => `${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}

`) : ""; + const addMenu = editing() && plain ? `` : ""; + const label = editing() && !onReady ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : ""; + const holdAlways = editing() && plain && !onReady ? `
hold also when green${hint("A fix that runs on every hold, even with nothing failing")}
` : ""; const holdList = (kt.holding || "").split(/;\s*/).filter(Boolean); - const more = `
more
id
${esc(t.id)}
${t.kind ? `
kind
${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}
` : ""}
${editing() && !t.kind ? `
position${hint("row, column; blank = inside its group only")}
` : ""}${editing() && (!t.kind || t.kind === "network") ? `

` : ""}
`; + const more = `
more
id
${esc(t.id)}
${t.kind ? `
kind
${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}
` : ""}
${editing() && !t.kind && !onReady ? `
position${hint("row, column; blank = inside its group only")}
` : ""}${editing() && (!t.kind || t.kind === "network") ? `

` : ""}
`; + if (onReady) return `

Edit ${esc(t.label)}

${checks ? `
${checks}
` : ""}${addMenu}${more}
`; return `

${esc(t.label)} ${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}

${label}${members}${holdAlways} ${holdList.length ? `
Hold ${esc(hold)}: ${esc(short(holdList[0], 48))}${holdList.length > 1 ? ` +${holdList.length - 1}` : ""}${t.hold_always ? " also when green" : ""}
    ${holdList.map((h) => `
  1. ${esc(h)}
  2. `).join("")}
` : ""} @@ -465,19 +501,20 @@ ${checks ? `
${checks}
` : ""}${addMenu}${more}
`; } 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) { + function wireKeyPanel(sel, restart, list = "tiles") { const c = cfg(); - const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel); + const i = restart ? -1 : keyList(c, list).findIndex((x) => x.id === sel); + const mine = () => keyList(state.draft.config, list)[i]; 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 dk = main.querySelector("#delkey"); if (dk) dk.onclick = () => confirmBox(`Delete the key ${keyList(c, list)[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const keys = keyList(cc, list); const id = keys[i].id; keys.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = list === "tiles" ? "#keys" : "#keys/ready"; }); 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 b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; mine().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 add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = mine(); t.checks = t.checks || []; t.checks.push(add.value === "ready" ? { type: "ready", fix: "ready_recheck" } : { type: add.value }); 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]; + const t = mine(), chk = t.checks[j]; const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value; if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; } const ct = state.builders.checks.find((x) => x.name === tplName); @@ -765,6 +802,7 @@ const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `
${esc(g)}

${esc(words.join(" · "))}

`).join(""); main.innerHTML = `

Help

${cards}

What each key does Cheat sheet Where things are

+
How do I add… (everything is done here; nothing needs a terminal)
Try it (ten minutes with Editing on; nothing is live until APPLY)
  1. Keys › HD2: untick "may have no disk"; the badge counts it; Apply reads it back. Throw away.
  2. Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.
  3. @@ -782,10 +820,12 @@ } // ---- Settings: one page, every section ------------------------------------------ - const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code"]; + const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code", + "ready_page.keys", "ready_page.back_pos", "ready.prop_lists"]; const SECTIONS = [ ["playlist", "Playlist", "The Spotify playlist the SPOTIFY key and the play fix use"], ["autofix", "Auto-fix", "When the panel may fix things by itself, and which fixes"], + ["ready", "READY page", "Where the weekly checklist looks: Planning Center, ProPresenter, the Tracks Mac, the lineup TV, Resolume, the bundles"], ["wait", "Changes wait", "When changes to the panel must wait (a recording deck always counts)"], ["hyperdecks", "HyperDecks", "Which decks are in use"], ["midi", "MIDI links", "Network MIDI sessions and the peers that must stay in them"], @@ -809,6 +849,7 @@ let body = ""; if (which === "playlist") body = spotifyHtml(c); else if (which === "autofix") body = autofixHtml(c); + else if (which === "ready") body = readySettingsHtml(c); else if (which === "wait") body = `
    ${calendarHtml("site.live_services", (c.site || {}).live_services)}

    A recording HyperDeck always counts, whatever the clock says.

    `; else if (which === "hyperdecks") body = await hyperdecksHtml(c); else if (which === "midi") body = midiHtml(c); @@ -825,6 +866,14 @@ main.querySelector("#subsel").onchange = (e) => { location.hash = "#settings/" + e.target.value; }; wireSettings(which); } + function readySettingsHtml(c) { + if (!c.ready_page) return "

    This campus has no READY page.

    "; + const lists = JSON.stringify((c.ready || {}).prop_lists || [], null, 1); + return `

    When gaps turn red${hint("Before this, a READY key that is not done is amber; inside it, still missing is red and blinks")}

    ${calendarHtml("ready.due", (c.ready || {}).due)}
    ` + + fieldsHtml("Ready") + + `
    lists the slides must match${hint('PCO items whose description lists things: {"match": a word in the item title, "kind": "count" (one slide per - line) or "names" (every name has a slide), "short": what the key calls it}')}
    +

    The keys themselves (add, move, delete, what each checks) are on Keys › READY page.

    `; + } function fieldsHtml(screen, includeAdvanced) { const own = Object.entries(state.schema.fields).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !HAND_MADE.includes(p) && !HAND_MADE.some((h) => h.startsWith(p + ".")) && !Object.keys(state.schema.fields).some((k) => k.startsWith(p + ".") || k.startsWith(p + "[]"))); const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced); @@ -1011,6 +1060,7 @@ function wireSettings(which) { const q = (s) => main.querySelector(s); wireCalendars(); + for (const ta of main.querySelectorAll("[data-jsonpath]")) ta.onchange = () => { try { setPath(ta.dataset.jsonpath, JSON.parse(ta.value)); } catch (e) { alertBox("That is not valid: " + e.message); } }; for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job); const ok = q("#okeys"); if (ok) ok.onclick = async () => { ok.disabled = true; ok.textContent = "Testing…"; try { const r = await post("/api/outlets/keys", { access_id: q("#oid").value, access_secret: q("#osecret").value, region: q("#oregion").value }); alertBox(r.ok ? `Works: ${r.devices} devices on the account.` : r.error); route(); } catch (e) { alertBox(e.message); route(); } }; const oa = q("#oadd"); if (oa) oa.onclick = outletDialog; @@ -1214,7 +1264,7 @@ detail = `

    ${esc(f.short || id)} ${esc(f.label || "")}

    ${inputs}${keys.length ? `

    Used by ${esc(keys.join(", "))}.

    ` : ""}
    everything stored
    ${Object.entries(f).map(([k, v]) => `
    ${esc(k)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}
    id
    ${esc(id)}
    ${editing() && !keys.length ? `` : ""}
    `; } const add = editing() ? `` : ""; - main.innerHTML = `

    Keys${hint("Everything a hold can run. Click a fix to see how it is built.")}

    ${c.restart_page ? `` : ""}${add}
    + main.innerHTML = `

    Keys${hint("Everything a hold can run. Click a fix to see how it is built.")}

    ${c.restart_page ? `` : ""}${c.ready_page ? `` : ""}${add}
    ${rows}
    key textdoesbuilt asused byruns
    ${detail}
    `; const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value === "another computer" ? "ssh" : ks.value === "this Mac" ? "local" : ks.value); route(); }; const rebuild = async () => {