Merge pull request 'Layouts keep every tile they are given, and show what is on offer' (#12) from claude/layout-audit into main
This commit was merged in pull request #12.
This commit is contained in:
@@ -266,7 +266,14 @@ below. Nothing is installed without someone clicking.
|
|||||||
display options) — these belong to this browser's layout, not the server.
|
display options) — these belong to this browser's layout, not the server.
|
||||||
- The **grid icon** — save the current arrangement to the server under a
|
- The **grid icon** — save the current arrangement to the server under a
|
||||||
name, load a named layout (it becomes a local copy — browsers are never
|
name, load a named layout (it becomes a local copy — browsers are never
|
||||||
linked), or reset this browser's dashboard.
|
linked), or reset this browser's dashboard. Loading says what it did —
|
||||||
|
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
||||||
|
now* — with **Undo** in the message to get back the arrangement you had.
|
||||||
|
A tile whose module or picker entry isn't offered at the moment keeps its
|
||||||
|
place and shows nothing, then appears when it is; it is never replaced by
|
||||||
|
the module's default tile. Saving over an existing name asks first, and
|
||||||
|
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||||
|
Saved layouts swaps it back (and swapping again undoes that).
|
||||||
- Every tile has a **status dot**: green = live, amber = connecting,
|
- Every tile has a **status dot**: green = live, amber = connecting,
|
||||||
red = upstream unreachable (hover it for details). Tiles reconnect
|
red = upstream unreachable (hover it for details). Tiles reconnect
|
||||||
automatically.
|
automatically.
|
||||||
|
|||||||
+11
-4
@@ -592,10 +592,17 @@ module.exports = {
|
|||||||
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||||||
defaults in that tile's instance settings, the entry's `name` becomes the
|
defaults in that tile's instance settings, the entry's `name` becomes the
|
||||||
tile's title, and `moduleApi.variant` carries its `id`.
|
tile's title, and `moduleApi.variant` carries its `id`.
|
||||||
- The tile keeps its variant id and title even if the entry later vanishes
|
- **A tile is shown only while its entry is offered.** The shell mounts a
|
||||||
from your list (a timer deleted in ProPresenter, a page removed in admin).
|
tile added from an entry only when that entry (or one of its `aliases`) is
|
||||||
**Your client decides what that means** — show a clear "no longer
|
in your list right now; otherwise the tile keeps its place in the layout,
|
||||||
configured" state rather than erroring.
|
draws nothing, and appears the moment your list offers it again — a
|
||||||
|
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||||||
|
client is never asked to render an entry you did not offer at mount time.
|
||||||
|
One case is still yours: an entry that vanishes *after* the tile mounted
|
||||||
|
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||||||
|
placeholder, no error — and the shell folds the tile away at its next
|
||||||
|
registry refresh. Never fall back to your default view in its place: five
|
||||||
|
different timers turning into five "All timers" is worse than five gaps.
|
||||||
- A client-only module can declare a fixed list as a `tiles` array in
|
- A client-only module can declare a fixed list as a `tiles` array in
|
||||||
`module.json` instead (same entry shape).
|
`module.json` instead (same entry shape).
|
||||||
- An empty list (or no `tiles` at all) keeps today's behavior: one picker
|
- An empty list (or no `tiles` at all) keeps today's behavior: one picker
|
||||||
|
|||||||
@@ -239,8 +239,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
: variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}`
|
: variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}`
|
||||||
: variant.startsWith('solo:') ? variant.slice('solo:'.length)
|
: variant.startsWith('solo:') ? variant.slice('solo:'.length)
|
||||||
: '';
|
: '';
|
||||||
/** Last label seen for a solo timer, so its card stays labeled if the timer is later deleted upstream. */
|
|
||||||
let lastSoloLabel = '';
|
|
||||||
|
|
||||||
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -376,22 +374,21 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
/* ── state → what this tile shows ───────────────────────────────── */
|
/* ── state → what this tile shows ───────────────────────────────── */
|
||||||
|
|
||||||
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
|
/** [{ id, source, items: [{ key, source, timer }] }] */
|
||||||
function visibleGroups() {
|
function visibleGroups() {
|
||||||
if (!state) return [];
|
if (!state) return [];
|
||||||
if (solo) {
|
if (solo) {
|
||||||
for (const source of state.sources) {
|
for (const source of state.sources) {
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
if (keyOf(source, timer) !== solo) continue;
|
if (keyOf(source, timer) !== solo) continue;
|
||||||
lastSoloLabel = timer.label;
|
|
||||||
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The timer this tile was added for isn't offered (yet, or any more) —
|
// The timer this tile was added for is not offered right now. Nothing is
|
||||||
// keep a clearly-labeled card up rather than erroring.
|
// drawn for it — no placeholder that reads like a timer, and never the
|
||||||
const sourceId = solo.slice(0, solo.indexOf(':'));
|
// full list in its place. The shell folds the tile away on its next
|
||||||
const source = state.sources.find((s) => s.id === sourceId) || null;
|
// registry refresh (module guide, Presenting multiple tiles).
|
||||||
return [{ id: 'missing', source, items: [{ key: solo, missing: true, source }] }];
|
return [];
|
||||||
}
|
}
|
||||||
const groups = [];
|
const groups = [];
|
||||||
for (const source of state.sources) {
|
for (const source of state.sources) {
|
||||||
@@ -451,7 +448,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
/** The parts of a card that only change with the snapshot (label, kind). */
|
/** The parts of a card that only change with the snapshot (label, kind). */
|
||||||
function updateCard(card, item) {
|
function updateCard(card, item) {
|
||||||
card.item = item;
|
card.item = item;
|
||||||
card.isClock = !item.missing && item.source?.kind === 'clock';
|
card.isClock = item.source?.kind === 'clock';
|
||||||
const isLtc = item.source?.kind === 'ltc';
|
const isLtc = item.source?.kind === 'ltc';
|
||||||
const badge = card.nameEl.querySelector('.tm-ltc-badge');
|
const badge = card.nameEl.querySelector('.tm-ltc-badge');
|
||||||
if (isLtc && !badge) {
|
if (isLtc && !badge) {
|
||||||
@@ -462,29 +459,13 @@ export default function create({ root, moduleApi }) {
|
|||||||
} else if (!isLtc && badge) {
|
} else if (!isLtc && badge) {
|
||||||
badge.remove();
|
badge.remove();
|
||||||
}
|
}
|
||||||
const label = item.missing ? (lastSoloLabel || 'Timer') : card.isClock ? '' : item.timer.label;
|
const label = card.isClock ? '' : item.timer.label;
|
||||||
if (card.labelEl.textContent !== label) {
|
if (card.labelEl.textContent !== label) {
|
||||||
setText(card.labelEl, label);
|
setText(card.labelEl, label);
|
||||||
needFit = true; // the title's size follows its length
|
needFit = true; // the title's size follows its length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMissingCard(card) {
|
|
||||||
const source = card.item.source;
|
|
||||||
setText(card.timeEl, '—');
|
|
||||||
card.chars = 5;
|
|
||||||
let note;
|
|
||||||
if (!source) note = 'Source not available — check Admin';
|
|
||||||
else if (source.status === 'ok') note = `Not offered by ${source.label}`;
|
|
||||||
else note = '—';
|
|
||||||
setText(card.statusEl, note);
|
|
||||||
card.statusEl.hidden = false;
|
|
||||||
card.statusEl.className = 'tm-status is-note';
|
|
||||||
card.statusEl.dataset.tone = source && source.status === 'ok' ? 'warn' : 'muted';
|
|
||||||
card.detailEl.hidden = true;
|
|
||||||
setCardClass(card, 'tm-card is-muted is-missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── clocks ─────────────────────────────────────────────────────── */
|
/* ── clocks ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function clockOptions() {
|
function clockOptions() {
|
||||||
@@ -633,10 +614,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
let charsChanged = false;
|
let charsChanged = false;
|
||||||
for (const card of cards.values()) {
|
for (const card of cards.values()) {
|
||||||
const { item } = card;
|
const { item } = card;
|
||||||
if (item.missing) {
|
|
||||||
updateMissingCard(card);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const { source, timer } = item;
|
const { source, timer } = item;
|
||||||
let text;
|
let text;
|
||||||
let tone;
|
let tone;
|
||||||
@@ -735,8 +712,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.setStatus('connecting', say(waiting));
|
moduleApi.setStatus('connecting', say(waiting));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const n = groups.reduce((a, g) => a + g.items.filter((i) => !i.missing).length, 0);
|
const n = groups.reduce((a, g) => a + g.items.length, 0);
|
||||||
moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`);
|
if (solo && n === 0) moduleApi.setStatus('connecting', 'Not offered right now');
|
||||||
|
else moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── main render: diff groups and cards, update in place ────────── */
|
/* ── main render: diff groups and cards, update in place ────────── */
|
||||||
@@ -791,7 +769,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
let hint = '';
|
let hint = '';
|
||||||
const groups = visibleGroups();
|
const groups = visibleGroups();
|
||||||
if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.';
|
if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.';
|
||||||
else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
|
else if (state && !groups.length && !solo) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
|
||||||
hintEl.hidden = !hint;
|
hintEl.hidden = !hint;
|
||||||
hintEl.textContent = hint;
|
hintEl.textContent = hint;
|
||||||
groupsEl.hidden = Boolean(hint);
|
groupsEl.hidden = Boolean(hint);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -295,7 +295,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* A solo tile whose timer is no longer offered. */
|
/* A solo tile whose timer is no longer offered. */
|
||||||
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
|
|
||||||
|
|
||||||
/* ── the LTC card: visually its own thing ── */
|
/* ── the LTC card: visually its own thing ── */
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
+19
-1
@@ -909,7 +909,25 @@ async function loadLayouts() {
|
|||||||
else toast(e.message, true);
|
else toast(e.message, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
row.append(name, meta, rename, del);
|
// The copy the last save replaced, one back. Restoring swaps the two,
|
||||||
|
// so a restore is itself one restore away from being undone.
|
||||||
|
const restore = document.createElement('button');
|
||||||
|
restore.className = 'btn';
|
||||||
|
restore.textContent = 'Restore previous';
|
||||||
|
restore.title = 'Put back the copy the last save replaced';
|
||||||
|
restore.hidden = !layout.hasPrevious;
|
||||||
|
restore.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Go back to the previous version of “${layout.name}”? The current one becomes its previous version, so this can be undone the same way.`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/layouts/${encodeURIComponent(layout.name)}/restore`, { method: 'POST', body: '{}' });
|
||||||
|
toast('Previous version restored');
|
||||||
|
loadLayouts();
|
||||||
|
} catch (e) {
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
else toast(e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.append(name, meta, restore, rename, del);
|
||||||
layoutsList.appendChild(row);
|
layoutsList.appendChild(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+187
-11
@@ -16,12 +16,24 @@ const toastEl = document.getElementById('toast');
|
|||||||
/* ── tiny helpers ───────────────────────────────────────────────────── */
|
/* ── tiny helpers ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
function toast(msg, isError = false) {
|
function toast(msg, isError = false, { action = null, ms } = {}) {
|
||||||
toastEl.textContent = msg;
|
toastEl.textContent = msg;
|
||||||
|
if (action) {
|
||||||
|
// One button, one verb — "Undo" — for a change worth taking back.
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = action.label;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastEl.hidden = true;
|
||||||
|
action.onClick();
|
||||||
|
});
|
||||||
|
toastEl.appendChild(btn);
|
||||||
|
}
|
||||||
toastEl.classList.toggle('error', isError);
|
toastEl.classList.toggle('error', isError);
|
||||||
toastEl.hidden = false;
|
toastEl.hidden = false;
|
||||||
clearTimeout(toastTimer);
|
clearTimeout(toastTimer);
|
||||||
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
|
toastTimer = setTimeout(() => { toastEl.hidden = true; }, ms ?? (action ? 8000 : 3200));
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoHideBtn = document.getElementById('autohide-btn');
|
const autoHideBtn = document.getElementById('autohide-btn');
|
||||||
@@ -70,6 +82,41 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' })
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A yes-or-no question in the same window: resolves true for the OK button. */
|
||||||
|
function confirmModal({ title, body = '', okLabel = 'OK' }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
modalEl.innerHTML = '';
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = title;
|
||||||
|
const copy = document.createElement('p');
|
||||||
|
copy.className = 'modal-copy';
|
||||||
|
copy.textContent = body;
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'actions';
|
||||||
|
const cancel = document.createElement('button');
|
||||||
|
cancel.className = 'btn';
|
||||||
|
cancel.textContent = 'Cancel';
|
||||||
|
const ok = document.createElement('button');
|
||||||
|
ok.className = 'btn primary';
|
||||||
|
ok.textContent = okLabel;
|
||||||
|
actions.append(cancel, ok);
|
||||||
|
modalEl.append(h, copy, actions);
|
||||||
|
modalBackdrop.hidden = false;
|
||||||
|
ok.focus();
|
||||||
|
const done = (result) => {
|
||||||
|
modalBackdrop.hidden = true;
|
||||||
|
modalEl.innerHTML = '';
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
cancel.addEventListener('click', () => done(false));
|
||||||
|
ok.addEventListener('click', () => done(true));
|
||||||
|
modalEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') done(false); }, { once: true });
|
||||||
|
modalBackdrop.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modalBackdrop) done(false);
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── dropdown menus ─────────────────────────────────────────────────── */
|
/* ── dropdown menus ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* A menu opens the moment its button is clicked, drawn from what the page
|
/* A menu opens the moment its button is clicked, drawn from what the page
|
||||||
@@ -169,17 +216,35 @@ const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
|||||||
away regardless; this is the windowed equivalent, asked for by hand. */
|
away regardless; this is the windowed equivalent, asked for by hand. */
|
||||||
let autoHideHeads = false;
|
let autoHideHeads = false;
|
||||||
|
|
||||||
|
/* Beside the layout itself: the copy it replaced (restored if the layout
|
||||||
|
ever fails to parse) and, after loading a named layout, the arrangement
|
||||||
|
that was on screen before (what Undo goes back to). */
|
||||||
|
const LS_LAYOUT_BACKUP = LS_LAYOUT + '.backup';
|
||||||
|
const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
|
||||||
|
|
||||||
function saveLayout() {
|
function saveLayout() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, autoHideHeads, tiles }));
|
const next = JSON.stringify({ v: 1, autoHideHeads, tiles });
|
||||||
|
const current = localStorage.getItem(LS_LAYOUT);
|
||||||
|
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
|
||||||
|
localStorage.setItem(LS_LAYOUT, next);
|
||||||
} catch { /* private mode / full — layout just won't persist */ }
|
} catch { /* private mode / full — layout just won't persist */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLayoutFromStorage() {
|
function loadLayoutFromStorage() {
|
||||||
try {
|
for (const key of [LS_LAYOUT, LS_LAYOUT_BACKUP]) {
|
||||||
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
|
try {
|
||||||
if (raw && Array.isArray(raw.tiles)) return raw;
|
const raw = JSON.parse(localStorage.getItem(key) || 'null');
|
||||||
} catch { /* corrupted — start fresh */ }
|
if (raw && Array.isArray(raw.tiles)) {
|
||||||
|
// A layout that would not parse is not a reason for an empty wall.
|
||||||
|
if (key === LS_LAYOUT_BACKUP) {
|
||||||
|
toast('This browser’s layout was unreadable — the last good copy is back', true);
|
||||||
|
try { localStorage.setItem(LS_LAYOUT, JSON.stringify(raw)); } catch { /* fine */ }
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
} catch { /* unreadable — try the copy behind it */ }
|
||||||
|
}
|
||||||
return { tiles: [] };
|
return { tiles: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +366,72 @@ const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
|||||||
const PIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 3z"/></svg>';
|
const PIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 3z"/></svg>';
|
||||||
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
|
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
|
||||||
|
|
||||||
|
/* ── what a saved tile can be shown as ─────────────────────────────────
|
||||||
|
A layout keeps every tile it was given; the board shows the ones whose
|
||||||
|
module is installed and enabled and — for a tile added from a picker
|
||||||
|
entry — whose entry (or an alias of it) the module offers right now. The
|
||||||
|
rest keep their place and draw nothing, and appear the moment the
|
||||||
|
registry offers them again: a Service timer waits for PCO Plan, a
|
||||||
|
transcript for its channel. Never the module's default in their place —
|
||||||
|
a wall of "All timers" where five different timers were is worse than
|
||||||
|
five gaps that fill themselves in. */
|
||||||
|
|
||||||
|
function tileIsOffered(tile) {
|
||||||
|
const man = registry.get(tile.module);
|
||||||
|
if (!man) return false;
|
||||||
|
const variant = String(tile.variant || '');
|
||||||
|
if (!variant) return true; // the plain module tile — the module says what that means
|
||||||
|
const entries = Array.isArray(man.tiles) ? man.tiles : [];
|
||||||
|
// No list at all is a module whose tiles() did not answer in time (the
|
||||||
|
// server falls back to the classic single entry): mount, and let it sort
|
||||||
|
// its own variant out rather than blank the board over a slow answer.
|
||||||
|
if (!entries.length) return true;
|
||||||
|
return entries.some((e) => String(e.id) === variant || (Array.isArray(e.aliases) && e.aliases.includes(variant)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show tiles that have become offered; fold away tiles that have stopped being. */
|
||||||
|
function reconcileOfferedTiles() {
|
||||||
|
let changed = false;
|
||||||
|
for (const tile of tiles) {
|
||||||
|
const shown = tileEls.has(tile.id);
|
||||||
|
const offered = tileIsOffered(tile);
|
||||||
|
if (offered && !shown) {
|
||||||
|
buildTile(tile);
|
||||||
|
changed = true;
|
||||||
|
} else if (!offered && shown) {
|
||||||
|
unmountModule(tile);
|
||||||
|
tileEls.get(tile.id)?.el.remove();
|
||||||
|
tileEls.delete(tile.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) rebuildDividers();
|
||||||
|
scheduleOfferedRecheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A module's entry list can change without anyone saving admin config: PCO
|
||||||
|
Plan comes back and, a few seconds later, the timers module is offering
|
||||||
|
its timers again. No event marks that moment, so while any tile is folded
|
||||||
|
away the registry is re-read every few seconds until nothing is. It costs
|
||||||
|
one GET while something is hidden and nothing at all otherwise. */
|
||||||
|
const OFFERED_RECHECK_MS = 6000;
|
||||||
|
let offeredRecheck = 0;
|
||||||
|
function scheduleOfferedRecheck() {
|
||||||
|
clearTimeout(offeredRecheck);
|
||||||
|
if (!tiles.some((t) => !tileEls.has(t.id))) return;
|
||||||
|
offeredRecheck = setTimeout(async () => {
|
||||||
|
offeredRecheck = 0;
|
||||||
|
if (await loadRegistry()) reconcileOfferedTiles();
|
||||||
|
else scheduleOfferedRecheck();
|
||||||
|
}, OFFERED_RECHECK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tiles a layout holds that the board is not showing, by name. */
|
||||||
|
function unshownTileNames() {
|
||||||
|
return tiles.filter((t) => !tileEls.has(t.id))
|
||||||
|
.map((t) => t.title || registry.get(t.module)?.name || t.module);
|
||||||
|
}
|
||||||
|
|
||||||
function buildTile(tile) {
|
function buildTile(tile) {
|
||||||
const man = registry.get(tile.module);
|
const man = registry.get(tile.module);
|
||||||
|
|
||||||
@@ -741,6 +872,7 @@ function rebuildDividers() {
|
|||||||
for (const L of tiles) {
|
for (const L of tiles) {
|
||||||
for (const R of tiles) {
|
for (const R of tiles) {
|
||||||
if (L === R || L.x + L.w !== R.x) continue;
|
if (L === R || L.x + L.w !== R.x) continue;
|
||||||
|
if (!tileEls.has(L.id) || !tileEls.has(R.id)) continue;
|
||||||
const top = Math.max(L.y, R.y);
|
const top = Math.max(L.y, R.y);
|
||||||
const bottom = Math.min(L.y + L.h, R.y + R.h);
|
const bottom = Math.min(L.y + L.h, R.y + R.h);
|
||||||
if (bottom <= top) continue;
|
if (bottom <= top) continue;
|
||||||
@@ -1861,7 +1993,8 @@ function renderLayout(layout) {
|
|||||||
clearAllTiles();
|
clearAllTiles();
|
||||||
tiles = list.map(sanitizeTile);
|
tiles = list.map(sanitizeTile);
|
||||||
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||||
for (const tile of tiles) buildTile(tile);
|
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
|
||||||
|
scheduleOfferedRecheck();
|
||||||
refreshEmptyState();
|
refreshEmptyState();
|
||||||
rebuildDividers();
|
rebuildDividers();
|
||||||
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||||
@@ -1937,6 +2070,21 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
});
|
});
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
|
// Saving over a name is asked about first — and the copy it replaces
|
||||||
|
// is kept one back on the server, so a yes is not the end of it either.
|
||||||
|
const existing = await fetch('/api/layouts')
|
||||||
|
.then((r) => (r.ok ? r.json() : { layouts: [] }))
|
||||||
|
.then((b) => (b.layouts || []).find((l) => l.name === name))
|
||||||
|
.catch(() => null);
|
||||||
|
if (existing) {
|
||||||
|
const when = existing.updated ? new Date(existing.updated).toLocaleString() : 'earlier';
|
||||||
|
const go = await confirmModal({
|
||||||
|
title: `Replace “${name}”?`,
|
||||||
|
body: `It was saved ${when} with ${existing.tiles} tile${existing.tiles === 1 ? '' : 's'}. The copy it replaces is kept as its previous version.`,
|
||||||
|
okLabel: 'Replace',
|
||||||
|
});
|
||||||
|
if (!go) return;
|
||||||
|
}
|
||||||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -1944,7 +2092,9 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||||||
toast(`Saved “${name}” — any browser can load it now`);
|
toast(body.replaced
|
||||||
|
? `Replaced “${name}” — the earlier copy is kept as its previous version`
|
||||||
|
: `Saved “${name}” — any browser can load it now`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, true);
|
toast(e.message, true);
|
||||||
}
|
}
|
||||||
@@ -1984,9 +2134,31 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
||||||
// Loading copies the layout into this browser's own state — it
|
// Loading copies the layout into this browser's own state — it
|
||||||
// does not live-link browsers together.
|
// does not live-link browsers together.
|
||||||
|
let outgoing = null;
|
||||||
|
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
|
||||||
renderLayout(body.layout);
|
renderLayout(body.layout);
|
||||||
saveLayout();
|
saveLayout();
|
||||||
toast(`Loaded “${l.name}”`);
|
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
|
||||||
|
const unshown = unshownTileNames();
|
||||||
|
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||||
|
const note = unshown.length
|
||||||
|
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||||
|
: '';
|
||||||
|
toast(`Loaded “${l.name}”: ${count}${note}`, false, {
|
||||||
|
ms: unshown.length ? 10000 : 7000,
|
||||||
|
action: outgoing ? {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick() {
|
||||||
|
try {
|
||||||
|
renderLayout(JSON.parse(outgoing));
|
||||||
|
saveLayout();
|
||||||
|
toast('Back to the arrangement you had');
|
||||||
|
} catch {
|
||||||
|
toast('Could not go back', true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} : null,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, true);
|
toast(e.message, true);
|
||||||
}
|
}
|
||||||
@@ -2188,7 +2360,7 @@ function subscribeEvents() {
|
|||||||
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
|
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
|
||||||
sseSubscribe('/api/events', {
|
sseSubscribe('/api/events', {
|
||||||
events: {
|
events: {
|
||||||
'modules-changed': () => handleModulesChanged(),
|
'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles),
|
||||||
theme: handleThemeEvent,
|
theme: handleThemeEvent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2270,6 +2442,10 @@ async function boot() {
|
|||||||
}
|
}
|
||||||
renderLayout(loadLayoutFromStorage());
|
renderLayout(loadLayoutFromStorage());
|
||||||
subscribeEvents();
|
subscribeEvents();
|
||||||
|
// Entries a module builds at runtime (a timer per discovered source) can
|
||||||
|
// land a beat after the first registry read; look once more shortly after
|
||||||
|
// boot so a tile hidden only by that timing appears without a nudge.
|
||||||
|
setTimeout(async () => { if (await loadRegistry()) reconcileOfferedTiles(); }, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
@@ -906,6 +906,24 @@ body.fullscreen #grid { padding-bottom: 0; }
|
|||||||
transition: opacity 0.25s ease;
|
transition: opacity 0.25s ease;
|
||||||
}
|
}
|
||||||
#toast[hidden] { display: none; }
|
#toast[hidden] { display: none; }
|
||||||
|
#toast button {
|
||||||
|
margin-left: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#toast button:hover { background: var(--accent); color: var(--accent-text); }
|
||||||
|
#modal .modal-copy {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 6px 0 14px;
|
||||||
|
}
|
||||||
#toast.error { border-color: var(--danger); }
|
#toast.error { border-color: var(--danger); }
|
||||||
|
|
||||||
/* ── scrollbars ────────────────────────────────────────────────────── */
|
/* ── scrollbars ────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -1601,10 +1601,46 @@ function layoutPath(name) {
|
|||||||
return path.join(LAYOUTS_DIR, name + '.json');
|
return path.join(LAYOUTS_DIR, name + '.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep only the fields a layout is made of; everything else is dropped. */
|
/** The copy a save replaced — one back, kept until the next save replaces it.
|
||||||
|
Names cannot contain a dot (layoutNameOk), so this can never be a real
|
||||||
|
layout's own file. */
|
||||||
|
function previousLayoutPath(name) {
|
||||||
|
return path.join(LAYOUTS_DIR, name + '.previous.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A saved layout is `{ v, autoHideHeads, tiles }`. The fields the shell knows
|
||||||
|
are checked for shape; anything else a tile carries rides along untouched.
|
||||||
|
That second part is the lesson of a bug: this function used to rebuild each
|
||||||
|
tile from the fields it knew and drop the rest, and for seventeen days it
|
||||||
|
dropped `variant` — every solo timer saved to a server came back as "All
|
||||||
|
timers". The server is the part of ProdDash that lags (a booth updates
|
||||||
|
weeks after the dev machine), so it must never be the one deciding which
|
||||||
|
tile fields exist. Retired fields are the one exception: they are dropped
|
||||||
|
so an old file does not haunt a new client. */
|
||||||
|
|
||||||
|
const LAYOUT_VERSION = 2;
|
||||||
|
const MAX_LAYOUT_TILES = 100;
|
||||||
|
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
|
||||||
|
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
|
||||||
|
|
||||||
|
/** An unknown tile field is kept when it is a sane key holding small, plain JSON. */
|
||||||
|
function keepUnknownTileField(key, value) {
|
||||||
|
if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(key)) return false;
|
||||||
|
switch (typeof value) {
|
||||||
|
case 'string': return value.length <= 2000;
|
||||||
|
case 'number': return Number.isFinite(value);
|
||||||
|
case 'boolean': return true;
|
||||||
|
case 'object':
|
||||||
|
if (value === null) return false;
|
||||||
|
try { return JSON.stringify(value).length <= 20000; } catch { return false; }
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeLayout(raw) {
|
function sanitizeLayout(raw) {
|
||||||
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
|
const tiles = Array.isArray(raw?.tiles) ? raw.tiles : [];
|
||||||
return {
|
return {
|
||||||
|
v: LAYOUT_VERSION,
|
||||||
// Whether tile title bars auto-hide is part of how a dashboard is
|
// Whether tile title bars auto-hide is part of how a dashboard is
|
||||||
// arranged, so it travels with the layout like the tiles themselves.
|
// arranged, so it travels with the layout like the tiles themselves.
|
||||||
autoHideHeads: Boolean(raw?.autoHideHeads),
|
autoHideHeads: Boolean(raw?.autoHideHeads),
|
||||||
@@ -1618,8 +1654,6 @@ function sanitizeLayout(raw) {
|
|||||||
h: Number(t?.h) || 1,
|
h: Number(t?.h) || 1,
|
||||||
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
||||||
// Which picker entry the tile came from, and what it was called.
|
// Which picker entry the tile came from, and what it was called.
|
||||||
// Without these a saved "Now" tile comes back as the full Now/Next
|
|
||||||
// card, and a solo timer comes back with a full tile's settings.
|
|
||||||
variant: String(t?.variant || ''),
|
variant: String(t?.variant || ''),
|
||||||
title: String(t?.title || ''),
|
title: String(t?.title || ''),
|
||||||
headPinned: Boolean(t?.headPinned),
|
headPinned: Boolean(t?.headPinned),
|
||||||
@@ -1627,6 +1661,10 @@ function sanitizeLayout(raw) {
|
|||||||
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
|
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
|
||||||
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
|
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
|
||||||
}
|
}
|
||||||
|
for (const [key, value] of Object.entries(t && typeof t === 'object' ? t : {})) {
|
||||||
|
if (KNOWN_TILE_FIELDS.has(key) || RETIRED_TILE_FIELDS.has(key)) continue;
|
||||||
|
if (keepUnknownTileField(key, value)) tile[key] = value;
|
||||||
|
}
|
||||||
return tile;
|
return tile;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -1640,17 +1678,23 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
} catch { /* no layouts dir yet */ }
|
} catch { /* no layouts dir yet */ }
|
||||||
const layouts = [];
|
const layouts = [];
|
||||||
for (const file of files.sort()) {
|
for (const file of files.sort()) {
|
||||||
|
if (file.endsWith('.previous.json')) continue; // a save's one-back copy, listed on its layout
|
||||||
const name = file.slice(0, -5);
|
const name = file.slice(0, -5);
|
||||||
const data = readJson(path.join(LAYOUTS_DIR, file), null);
|
const data = readJson(path.join(LAYOUTS_DIR, file), null);
|
||||||
if (!data) continue;
|
if (!data) continue;
|
||||||
let updated = null;
|
let updated = null;
|
||||||
try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ }
|
try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ }
|
||||||
layouts.push({ name, tiles: Array.isArray(data.tiles) ? data.tiles.length : 0, updated });
|
layouts.push({
|
||||||
|
name,
|
||||||
|
tiles: Array.isArray(data.tiles) ? data.tiles.length : 0,
|
||||||
|
updated,
|
||||||
|
hasPrevious: fs.existsSync(previousLayoutPath(name)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return sendJson(res, 200, { layouts });
|
return sendJson(res, 200, { layouts });
|
||||||
}
|
}
|
||||||
|
|
||||||
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename)?$/);
|
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename|\/restore)?$/);
|
||||||
if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' });
|
if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' });
|
||||||
let name;
|
let name;
|
||||||
try {
|
try {
|
||||||
@@ -1683,12 +1727,20 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
}
|
}
|
||||||
const layout = sanitizeLayout(body.layout);
|
const layout = sanitizeLayout(body.layout);
|
||||||
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
||||||
|
if (layout.tiles.length > MAX_LAYOUT_TILES) {
|
||||||
|
// Saying no beats quietly saving the first hundred and losing the rest.
|
||||||
|
return sendJson(res, 400, { error: `A layout can hold ${MAX_LAYOUT_TILES} tiles; this one has ${layout.tiles.length}.` });
|
||||||
|
}
|
||||||
|
const replaced = fs.existsSync(layoutPath(name));
|
||||||
try {
|
try {
|
||||||
|
// The copy being replaced is kept one back, so a save over the wrong
|
||||||
|
// name is a restore away rather than gone.
|
||||||
|
if (replaced) fs.copyFileSync(layoutPath(name), previousLayoutPath(name));
|
||||||
writeJson(layoutPath(name), layout);
|
writeJson(layoutPath(name), layout);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
|
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
|
||||||
}
|
}
|
||||||
return sendJson(res, 200, { ok: true, name });
|
return sendJson(res, 200, { ok: true, name, replaced });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m[2] && req.method === 'DELETE') {
|
if (!m[2] && req.method === 'DELETE') {
|
||||||
@@ -1698,10 +1750,11 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
} catch {
|
} catch {
|
||||||
return sendJson(res, 404, { error: `No layout named "${name}".` });
|
return sendJson(res, 404, { error: `No layout named "${name}".` });
|
||||||
}
|
}
|
||||||
|
fs.rmSync(previousLayoutPath(name), { force: true });
|
||||||
return sendJson(res, 200, { ok: true });
|
return sendJson(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m[2] && req.method === 'POST') {
|
if (m[2] === '/rename' && req.method === 'POST') {
|
||||||
if (refuseAdminWrite(req, res)) return;
|
if (refuseAdminWrite(req, res)) return;
|
||||||
let body = {};
|
let body = {};
|
||||||
try {
|
try {
|
||||||
@@ -1725,6 +1778,25 @@ async function handleLayoutsApi(req, res, urlPath) {
|
|||||||
return sendJson(res, 200, { ok: true, name: next });
|
return sendJson(res, 200, { ok: true, name: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Put back the copy the last save replaced. The two swap, so a restore is
|
||||||
|
// itself one restore away from being undone.
|
||||||
|
if (m[2] === '/restore' && req.method === 'POST') {
|
||||||
|
if (refuseAdminWrite(req, res)) return;
|
||||||
|
const current = layoutPath(name);
|
||||||
|
const previous = previousLayoutPath(name);
|
||||||
|
if (!fs.existsSync(previous)) return sendJson(res, 404, { error: `"${name}" has no previous version to go back to.` });
|
||||||
|
try {
|
||||||
|
const swapTmp = current + '.swap';
|
||||||
|
const hadCurrent = fs.existsSync(current);
|
||||||
|
if (hadCurrent) fs.renameSync(current, swapTmp);
|
||||||
|
fs.renameSync(previous, current);
|
||||||
|
if (hadCurrent) fs.renameSync(swapTmp, previous);
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { error: 'Restore failed: ' + (err?.message || err) });
|
||||||
|
}
|
||||||
|
return sendJson(res, 200, { ok: true, name });
|
||||||
|
}
|
||||||
|
|
||||||
sendJson(res, 405, { error: 'Method not allowed.' });
|
sendJson(res, 405, { error: 'Method not allowed.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round-trips a layout through the server's layouts API and checks that
|
||||||
|
* nothing is lost on the way: a fully-populated tile, a tile field the server
|
||||||
|
* has never heard of, the one-back copy a save leaves behind, restore, the
|
||||||
|
* tile cap and delete.
|
||||||
|
*
|
||||||
|
* node tools/layout-roundtrip.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies. Starts server.js on a spare port with a
|
||||||
|
* temporary data directory, talks to it over HTTP, exits non-zero on the
|
||||||
|
* first failure and always stops the server it started.
|
||||||
|
*
|
||||||
|
* It exists because of one bug: for seventeen days the server rebuilt each
|
||||||
|
* saved tile from the fields it knew and dropped the rest — including
|
||||||
|
* `variant`, so every solo timer saved to a booth came back as "All timers".
|
||||||
|
* A field added on the client must survive a server that has not heard of
|
||||||
|
* it, and this is where that is checked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const net = require('node:net');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = net.createServer();
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const { port } = probe.address();
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
probe.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(url, ms = 15000) {
|
||||||
|
const deadline = Date.now() + ms;
|
||||||
|
let last = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (r.ok) return;
|
||||||
|
last = 'HTTP ' + r.status;
|
||||||
|
} catch (err) {
|
||||||
|
last = err.message;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
throw new Error('server did not come up: ' + last);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-layout-test-'));
|
||||||
|
const port = await freePort();
|
||||||
|
const child = spawn(process.execPath, ['server.js'], {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let log = '';
|
||||||
|
child.stdout.on('data', (d) => { log += d; });
|
||||||
|
child.stderr.on('data', (d) => { log += d; });
|
||||||
|
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const api = async (p, init = {}) => {
|
||||||
|
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
|
||||||
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||||
|
};
|
||||||
|
const layoutsDir = path.join(dataDir, 'layouts');
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitFor(base + '/api/theme');
|
||||||
|
|
||||||
|
// Every field the shell writes today, one it will write tomorrow, and one
|
||||||
|
// it has stopped writing.
|
||||||
|
const tile = {
|
||||||
|
id: 't1', module: 'propresenter-timers', x: 4, y: 2, w: 3, h: 2,
|
||||||
|
settings: { showNames: false, __tint: '#112233' },
|
||||||
|
variant: 'solo:pco-plan:service', title: 'Service', headPinned: true, minSize: { w: 2, h: 1 },
|
||||||
|
futureField: 'kept', futureFlag: true, futureBox: { a: 1 },
|
||||||
|
headHidden: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) });
|
||||||
|
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
|
||||||
|
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
|
||||||
|
|
||||||
|
r = await api('/api/layouts/Booth%20A');
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const got = r.body.layout;
|
||||||
|
assert.equal(got.v, 2, 'the file carries a schema version');
|
||||||
|
assert.equal(got.autoHideHeads, true);
|
||||||
|
const t = got.tiles[0];
|
||||||
|
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
|
||||||
|
assert.deepEqual(t.settings, tile.settings, 'settings');
|
||||||
|
assert.deepEqual(t.minSize, tile.minSize, 'minSize');
|
||||||
|
assert.equal(t.futureField, 'kept', 'an unknown string field survives');
|
||||||
|
assert.equal(t.futureFlag, true, 'an unknown boolean survives');
|
||||||
|
assert.deepEqual(t.futureBox, { a: 1 }, 'an unknown object survives');
|
||||||
|
assert.equal('headHidden' in t, false, 'a retired field is dropped');
|
||||||
|
|
||||||
|
// Saving over the name keeps the copy it replaced.
|
||||||
|
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
|
||||||
|
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
|
||||||
|
r = await api('/api/layouts');
|
||||||
|
assert.deepEqual(r.body.layouts.map((l) => [l.name, l.hasPrevious]), [['Booth A', true]],
|
||||||
|
'the listing shows one layout with a previous copy — not two layouts');
|
||||||
|
|
||||||
|
// Restore swaps them, so a second restore undoes the first. Admin is open
|
||||||
|
// here (no PIN), which is what a fresh data directory gives.
|
||||||
|
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
|
||||||
|
assert.equal(r.status, 200, 'restore: ' + JSON.stringify(r.body));
|
||||||
|
r = await api('/api/layouts/Booth%20A');
|
||||||
|
assert.equal(r.body.layout.tiles[0].title, 'Service', 'restore brings the earlier save back');
|
||||||
|
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
r = await api('/api/layouts/Booth%20A');
|
||||||
|
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
|
||||||
|
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
|
||||||
|
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
|
||||||
|
|
||||||
|
// Too many tiles is refused, not trimmed.
|
||||||
|
const many = Array.from({ length: 101 }, (_, i) => ({ ...tile, id: 't' + i }));
|
||||||
|
r = await api('/api/layouts/Too%20Big', { method: 'PUT', body: JSON.stringify({ layout: { tiles: many } }) });
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.match(String(r.body.error || ''), /100 tiles/, 'the refusal names the cap');
|
||||||
|
assert.equal(fs.existsSync(path.join(layoutsDir, 'Too Big.json')), false, 'nothing was written');
|
||||||
|
|
||||||
|
// Delete takes the previous copy with it.
|
||||||
|
r = await api('/api/layouts/Booth%20A', { method: 'DELETE' });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.json')), false);
|
||||||
|
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), false, 'the previous copy is gone too');
|
||||||
|
r = await api('/api/layouts');
|
||||||
|
assert.deepEqual(r.body.layouts, [], 'nothing left listed');
|
||||||
|
|
||||||
|
console.log('layout round-trip: every check passed');
|
||||||
|
} catch (err) {
|
||||||
|
failed = true;
|
||||||
|
console.error('FAIL:', err.message);
|
||||||
|
console.error('--- last of the server log ---\n' + log.slice(-2000));
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user