Named layouts: save to server, load from any browser, admin management

Milestone 6. Layouts CRUD as JSON under config/layouts/ — GET list,
GET/PUT by name (open to every dashboard), DELETE and rename gated by the
admin passcode. Layout menu grows 'Save as named layout…' and a
'Load from server' list; loading copies the layout into the browser's own
local state, never live-links viewers. Admin page lists, renames and
deletes named layouts. Verified with two isolated browser contexts
holding different layouts simultaneously.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-08-30 15:17:52 +00:00
parent 0b53616160
commit f0c5a0f1a7
2 changed files with 193 additions and 0 deletions
+62
View File
@@ -711,6 +711,68 @@ wireDropdown('add-btn', 'add-menu', async (menu) => {
wireDropdown('layout-btn', 'layout-menu', async (menu) => {
menu.innerHTML = '';
menu.appendChild(menuItem('Save as named layout…', async () => {
menu.hidden = true;
if (!tiles.length) {
toast('Nothing to save — this dashboard is empty', true);
return;
}
const name = await promptModal({
title: 'Save this layout to the server',
placeholder: 'e.g. FOH booth, Video world',
okLabel: 'Save',
});
if (!name) return;
try {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { tiles } }),
});
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`);
} catch (e) {
toast(e.message, true);
}
}, { sub: 'Share this arrangement with other browsers' }));
const divider = document.createElement('div');
divider.className = 'menu-divider';
menu.appendChild(divider);
// named layouts stored on the server
let layouts = [];
try {
const res = await fetch('/api/layouts');
if (res.ok) layouts = (await res.json()).layouts || [];
} catch { /* server briefly away — the menu just shows none */ }
if (layouts.length) {
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Load from server';
menu.appendChild(title);
for (const l of layouts) {
menu.appendChild(menuItem(l.name, async () => {
menu.hidden = true;
try {
const res = await fetch('/api/layouts/' + encodeURIComponent(l.name));
const body = await res.json().catch(() => ({}));
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.
renderLayout(body.layout.tiles || []);
saveLayout();
toast(`Loaded “${l.name}”`);
} catch (e) {
toast(e.message, true);
}
}, { sub: `${l.tiles} tile${l.tiles === 1 ? '' : 's'}` }));
}
menu.appendChild(divider.cloneNode());
}
menu.appendChild(menuItem('Reset layout', () => {
menu.hidden = true;
renderLayout([]);