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:
@@ -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([]);
|
||||
|
||||
@@ -424,6 +424,127 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
||||
}
|
||||
|
||||
/* ── named layouts (JSON files under config/layouts/) ───────────────── */
|
||||
|
||||
const LAYOUTS_DIR = path.join(CONFIG_DIR, 'layouts');
|
||||
|
||||
/** Names double as file names — keep them boring on purpose. */
|
||||
function layoutNameOk(name) {
|
||||
return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9 _()-]{0,39}$/.test(name);
|
||||
}
|
||||
|
||||
function layoutPath(name) {
|
||||
return path.join(LAYOUTS_DIR, name + '.json');
|
||||
}
|
||||
|
||||
/** Keep only the fields a layout is made of; everything else is dropped. */
|
||||
function sanitizeLayout(raw) {
|
||||
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
|
||||
return {
|
||||
tiles: tiles.map((t) => ({
|
||||
id: String(t?.id || ''),
|
||||
module: String(t?.module || ''),
|
||||
x: Number(t?.x) || 0,
|
||||
y: Number(t?.y) || 0,
|
||||
w: Number(t?.w) || 1,
|
||||
h: Number(t?.h) || 1,
|
||||
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleLayoutsApi(req, res, urlPath) {
|
||||
if (urlPath === '/api/layouts' && req.method === 'GET') {
|
||||
let files = [];
|
||||
try {
|
||||
files = fs.readdirSync(LAYOUTS_DIR).filter((f) => f.endsWith('.json'));
|
||||
} catch { /* no layouts dir yet */ }
|
||||
const layouts = [];
|
||||
for (const file of files.sort()) {
|
||||
const name = file.slice(0, -5);
|
||||
const data = readJson(path.join(LAYOUTS_DIR, file), null);
|
||||
if (!data) continue;
|
||||
let updated = null;
|
||||
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 });
|
||||
}
|
||||
return sendJson(res, 200, { layouts });
|
||||
}
|
||||
|
||||
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename)?$/);
|
||||
if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' });
|
||||
let name;
|
||||
try {
|
||||
name = decodeURIComponent(m[1]);
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Bad layout name.' });
|
||||
}
|
||||
if (!layoutNameOk(name)) {
|
||||
return sendJson(res, 400, { error: 'Layout names can use letters, numbers, spaces, dashes and parentheses (max 40).' });
|
||||
}
|
||||
|
||||
if (!m[2] && req.method === 'GET') {
|
||||
const data = readJson(layoutPath(name), null);
|
||||
if (!data) return sendJson(res, 404, { error: `No layout named "${name}".` });
|
||||
return sendJson(res, 200, { name, layout: sanitizeLayout(data) });
|
||||
}
|
||||
|
||||
// Saving is open to every dashboard (volunteers save their booth setups);
|
||||
// renaming and deleting are admin actions.
|
||||
if (!m[2] && req.method === 'PUT') {
|
||||
const contentType = String(req.headers['content-type'] || '').toLowerCase();
|
||||
if (!contentType.startsWith('application/json')) {
|
||||
return sendJson(res, 415, { error: 'Send application/json.' });
|
||||
}
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse((await readBody(req)) || '{}');
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
const layout = sanitizeLayout(body.layout);
|
||||
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
||||
writeJson(layoutPath(name), layout);
|
||||
return sendJson(res, 200, { ok: true, name });
|
||||
}
|
||||
|
||||
if (!m[2] && req.method === 'DELETE') {
|
||||
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
||||
try {
|
||||
fs.unlinkSync(layoutPath(name));
|
||||
} catch {
|
||||
return sendJson(res, 404, { error: `No layout named "${name}".` });
|
||||
}
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (m[2] && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse((await readBody(req)) || '{}');
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
const next = String(body.name || '').trim();
|
||||
if (!layoutNameOk(next)) {
|
||||
return sendJson(res, 400, { error: 'Layout names can use letters, numbers, spaces, dashes and parentheses (max 40).' });
|
||||
}
|
||||
if (!fs.existsSync(layoutPath(name))) return sendJson(res, 404, { error: `No layout named "${name}".` });
|
||||
if (next !== name && fs.existsSync(layoutPath(next))) {
|
||||
return sendJson(res, 409, { error: `A layout named "${next}" already exists.` });
|
||||
}
|
||||
try {
|
||||
fs.renameSync(layoutPath(name), layoutPath(next));
|
||||
} catch (err) {
|
||||
return sendJson(res, 500, { error: 'Rename failed: ' + (err?.message || err) });
|
||||
}
|
||||
return sendJson(res, 200, { ok: true, name: next });
|
||||
}
|
||||
|
||||
sendJson(res, 405, { error: 'Method not allowed.' });
|
||||
}
|
||||
|
||||
/* ── module API dispatch ────────────────────────────────────────────── */
|
||||
|
||||
function dispatchModuleApi(req, res, id, subPath, search) {
|
||||
@@ -550,6 +671,16 @@ function handleRequest(req, res) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* — named layouts — */
|
||||
if (urlPath === '/api/layouts' || urlPath.startsWith('/api/layouts/')) {
|
||||
handleLayoutsApi(req, res, urlPath).catch((err) => {
|
||||
console.error('[proddash] layouts API error:', err);
|
||||
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
||||
else try { res.end(); } catch { /* gone */ }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* — admin API — */
|
||||
if (urlPath.startsWith('/api/admin/')) {
|
||||
handleAdminApi(req, res, urlPath).catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user