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) => {
|
wireDropdown('layout-btn', 'layout-menu', async (menu) => {
|
||||||
menu.innerHTML = '';
|
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.appendChild(menuItem('Reset layout', () => {
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
renderLayout([]);
|
renderLayout([]);
|
||||||
|
|||||||
@@ -424,6 +424,127 @@ async function handleAdminApi(req, res, urlPath) {
|
|||||||
sendJson(res, 404, { error: 'Unknown admin endpoint.' });
|
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 ────────────────────────────────────────────── */
|
/* ── module API dispatch ────────────────────────────────────────────── */
|
||||||
|
|
||||||
function dispatchModuleApi(req, res, id, subPath, search) {
|
function dispatchModuleApi(req, res, id, subPath, search) {
|
||||||
@@ -550,6 +671,16 @@ function handleRequest(req, res) {
|
|||||||
return;
|
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 — */
|
/* — admin API — */
|
||||||
if (urlPath.startsWith('/api/admin/')) {
|
if (urlPath.startsWith('/api/admin/')) {
|
||||||
handleAdminApi(req, res, urlPath).catch((err) => {
|
handleAdminApi(req, res, urlPath).catch((err) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user