Layouts keep every tile they are given, and show what is on offer
The bug behind this: for seventeen days the server rebuilt each saved tile from the fields it knew and dropped the rest — including `variant`, so a layout of five different timers came back from the server as five "All timers". The client had carried `variant` since multi-tile modules arrived; the server was the one that lagged, and it is the part of ProdDash that always will (a booth updates weeks after the dev machine). So the server stops deciding which tile fields exist: it checks the shape of the ones it knows, keeps any other small plain-JSON field a tile carries, drops only retired ones, stamps the file `v: 2`, and refuses a layout over a hundred tiles instead of quietly saving the first hundred. A test round-trips a fully-populated tile plus one the server has never heard of, and fails if anything is lost. Then the rule the arrangement is loaded by. A layout keeps every tile it is given; the board shows the ones whose module is installed and enabled and — for a tile added from a picker entry — whose entry is offered 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. Because a module's entry list can change with no event to mark it (PCO Plan comes back and, seconds later, the timers module is offering its timers again), the registry is re-read every few seconds while anything is folded away, and not at all otherwise. The timers module's placeholder card for a missing solo timer goes with this: nothing is drawn, the dot says why. Loading says what it did — "Loaded 'FOH': 9 tiles, 2 not shown — Service, #ops aren't offered right now" — with Undo in the message. Saving over a name asks first, and the server keeps the copy it replaced one back; Admin → Saved layouts gets Restore previous, which swaps the two so a restore is one restore from undone. The browser keeps one good copy of its own layout behind the live one and falls back to it when the live one will not parse. Shell 1.8.0, timers 1.4.1. The layouts already saved on the booth machines had their variants stripped before this and have to be saved again from a browser that still holds the right tiles — nothing here recovers what the files no longer contain. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c075655817
commit
a7e40ee1a8
+187
-11
@@ -16,12 +16,24 @@ const toastEl = document.getElementById('toast');
|
||||
/* ── tiny helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg, isError = false) {
|
||||
function toast(msg, isError = false, { action = null, ms } = {}) {
|
||||
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.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
|
||||
toastTimer = setTimeout(() => { toastEl.hidden = true; }, ms ?? (action ? 8000 : 3200));
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────────── */
|
||||
|
||||
/* 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. */
|
||||
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() {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
function loadLayoutFromStorage() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
|
||||
if (raw && Array.isArray(raw.tiles)) return raw;
|
||||
} catch { /* corrupted — start fresh */ }
|
||||
for (const key of [LS_LAYOUT, LS_LAYOUT_BACKUP]) {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(key) || 'null');
|
||||
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: [] };
|
||||
}
|
||||
|
||||
@@ -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 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) {
|
||||
const man = registry.get(tile.module);
|
||||
|
||||
@@ -741,6 +872,7 @@ function rebuildDividers() {
|
||||
for (const L of tiles) {
|
||||
for (const R of tiles) {
|
||||
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 bottom = Math.min(L.y + L.h, R.y + R.h);
|
||||
if (bottom <= top) continue;
|
||||
@@ -1861,7 +1993,8 @@ function renderLayout(layout) {
|
||||
clearAllTiles();
|
||||
tiles = list.map(sanitizeTile);
|
||||
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();
|
||||
rebuildDividers();
|
||||
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||
@@ -1937,6 +2070,21 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
});
|
||||
if (!name) return;
|
||||
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), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1944,7 +2092,9 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
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) {
|
||||
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 + ')');
|
||||
// Loading copies the layout into this browser's own state — it
|
||||
// does not live-link browsers together.
|
||||
let outgoing = null;
|
||||
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
|
||||
renderLayout(body.layout);
|
||||
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) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
@@ -2188,7 +2360,7 @@ function subscribeEvents() {
|
||||
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
|
||||
sseSubscribe('/api/events', {
|
||||
events: {
|
||||
'modules-changed': () => handleModulesChanged(),
|
||||
'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles),
|
||||
theme: handleThemeEvent,
|
||||
},
|
||||
});
|
||||
@@ -2270,6 +2442,10 @@ async function boot() {
|
||||
}
|
||||
renderLayout(loadLayoutFromStorage());
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user