Shell: server, dashboard grid with drag/resize/remove, localStorage layouts
Milestone 1 of SPEC.md. Zero-dependency Node server serving public/; 12-column tile grid with add (placeholder tile), drag to move, drag to resize, remove; collision-free placement; layout autosaves to this browser's localStorage and survives reload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"port": 24500,
|
||||||
|
"adminPasscode": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ProdDash</title>
|
||||||
|
<meta name="theme-color" content="#0b0e12">
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header id="topbar">
|
||||||
|
<span id="brand">Prod<span class="accent">Dash</span></span>
|
||||||
|
|
||||||
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
<div class="dropdown" id="add-dd">
|
||||||
|
<button class="tb-btn" id="add-btn" title="Add a module tile">+ Add tile ▾</button>
|
||||||
|
<div class="menu" id="add-menu" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown" id="layout-dd">
|
||||||
|
<button class="tb-btn" id="layout-btn" title="Save, load or reset layouts">Layout ▾</button>
|
||||||
|
<div class="menu" id="layout-menu" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="tb-btn" href="/admin/" title="Configure modules (server-wide)">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/>
|
||||||
|
</svg>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="gridwrap">
|
||||||
|
<div id="grid">
|
||||||
|
<div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br>
|
||||||
|
Every browser keeps its own layout.</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="modal-backdrop" hidden>
|
||||||
|
<div id="modal" role="dialog" aria-modal="true"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" hidden></div>
|
||||||
|
|
||||||
|
<script type="module" src="/shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+605
@@ -0,0 +1,605 @@
|
|||||||
|
/* ProdDash shell client.
|
||||||
|
|
||||||
|
Owns the tile grid: adding, dragging, resizing and removing module tiles,
|
||||||
|
plus layout persistence. The layout (tiles, positions, sizes, per-instance
|
||||||
|
settings) autosaves to this browser's localStorage — every browser is fully
|
||||||
|
independent. Modules render inside tile bodies and never touch the shell. */
|
||||||
|
|
||||||
|
const COLS = 12;
|
||||||
|
const LS_LAYOUT = 'proddash:layout';
|
||||||
|
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
const gridwrap = document.getElementById('gridwrap');
|
||||||
|
const emptyEl = document.getElementById('empty-state');
|
||||||
|
const toastEl = document.getElementById('toast');
|
||||||
|
|
||||||
|
/* ── tiny helpers ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
let toastTimer = null;
|
||||||
|
function toast(msg, isError = false) {
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.classList.toggle('error', isError);
|
||||||
|
toastEl.hidden = false;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalBackdrop = document.getElementById('modal-backdrop');
|
||||||
|
const modalEl = document.getElementById('modal');
|
||||||
|
|
||||||
|
/** Minimal prompt dialog; resolves with the string or null on cancel. */
|
||||||
|
function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
modalEl.innerHTML = '';
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = title;
|
||||||
|
const field = document.createElement('div');
|
||||||
|
field.className = 'field';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.placeholder = placeholder;
|
||||||
|
input.value = value;
|
||||||
|
field.appendChild(input);
|
||||||
|
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, field, actions);
|
||||||
|
modalBackdrop.hidden = false;
|
||||||
|
input.focus();
|
||||||
|
const done = (result) => {
|
||||||
|
modalBackdrop.hidden = true;
|
||||||
|
modalEl.innerHTML = '';
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
cancel.addEventListener('click', () => done(null));
|
||||||
|
ok.addEventListener('click', () => done(input.value.trim() || null));
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') done(input.value.trim() || null);
|
||||||
|
if (e.key === 'Escape') done(null);
|
||||||
|
});
|
||||||
|
modalBackdrop.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modalBackdrop) done(null);
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── dropdown menus ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function wireDropdown(btnId, menuId, rebuild) {
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
const menu = document.getElementById(menuId);
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (menu.hidden) await rebuild(menu);
|
||||||
|
menu.hidden = !menu.hidden;
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!menu.hidden && !e.target.closest('#' + btnId) && !e.target.closest('#' + menuId)) {
|
||||||
|
menu.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuItem(label, onClick, { sub = '', danger = false } = {}) {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'menu-item' + (danger ? ' danger' : '');
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
if (sub) {
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.className = 'sub';
|
||||||
|
s.textContent = sub;
|
||||||
|
span.appendChild(s);
|
||||||
|
}
|
||||||
|
item.appendChild(span);
|
||||||
|
item.addEventListener('click', onClick);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── module registry ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Filled from GET /api/modules once the module system is up (milestone 2).
|
||||||
|
Until then — or if the fetch fails — the shell offers a built-in
|
||||||
|
placeholder tile so the grid itself is usable and testable. */
|
||||||
|
|
||||||
|
const PLACEHOLDER_MANIFEST = {
|
||||||
|
id: '__placeholder',
|
||||||
|
name: 'Placeholder',
|
||||||
|
description: 'An empty tile for trying out the grid',
|
||||||
|
defaultSize: { w: 3, h: 2 },
|
||||||
|
minSize: { w: 1, h: 1 },
|
||||||
|
builtin: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let registry = new Map(); // module id -> manifest
|
||||||
|
|
||||||
|
async function loadRegistry() {
|
||||||
|
registry = new Map();
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/modules');
|
||||||
|
if (!res.ok) throw new Error('modules ' + res.status);
|
||||||
|
const body = await res.json();
|
||||||
|
for (const m of body.modules || []) registry.set(m.id, m);
|
||||||
|
} catch {
|
||||||
|
/* no module API yet (or server just restarted) — placeholder only */
|
||||||
|
}
|
||||||
|
if (!registry.size) registry.set(PLACEHOLDER_MANIFEST.id, PLACEHOLDER_MANIFEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── layout state ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** @type {{id:string,module:string,x:number,y:number,w:number,h:number,settings:object}[]} */
|
||||||
|
let tiles = [];
|
||||||
|
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
||||||
|
|
||||||
|
function saveLayout() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
|
||||||
|
} 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.tiles;
|
||||||
|
} catch { /* corrupted — start fresh */ }
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTile(t) {
|
||||||
|
const w = Math.max(1, Math.min(COLS, Number(t.w) || 3));
|
||||||
|
return {
|
||||||
|
id: String(t.id || newId()),
|
||||||
|
module: String(t.module || ''),
|
||||||
|
x: Math.max(0, Math.min(COLS - w, Number(t.x) || 0)),
|
||||||
|
y: Math.max(0, Number(t.y) || 0),
|
||||||
|
w,
|
||||||
|
h: Math.max(1, Number(t.h) || 2),
|
||||||
|
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function newId() {
|
||||||
|
return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── grid geometry & collision ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
function cellMetrics() {
|
||||||
|
const style = getComputedStyle(grid);
|
||||||
|
const gap = parseFloat(style.columnGap) || 10;
|
||||||
|
const cw = (grid.clientWidth - gap * (COLS - 1)) / COLS;
|
||||||
|
const ch = parseFloat(style.gridAutoRows) || 72;
|
||||||
|
return { cw, ch, gap };
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(a, b) {
|
||||||
|
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFree(rect, excludeId) {
|
||||||
|
if (rect.x < 0 || rect.y < 0 || rect.x + rect.w > COLS) return false;
|
||||||
|
return !tiles.some((t) => t.id !== excludeId && overlaps(rect, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First free spot scanning top-to-bottom, left-to-right. */
|
||||||
|
function findSpot(w, h) {
|
||||||
|
for (let y = 0; y < 200; y += 1) {
|
||||||
|
for (let x = 0; x <= COLS - w; x += 1) {
|
||||||
|
if (isFree({ x, y, w, h })) return { x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRect(tile) {
|
||||||
|
const entry = tileEls.get(tile.id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
|
||||||
|
entry.el.style.gridRow = `${tile.y + 1} / span ${tile.h}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minSizeOf(tile) {
|
||||||
|
const man = registry.get(tile.module);
|
||||||
|
return {
|
||||||
|
w: Math.max(1, man?.minSize?.w || 1),
|
||||||
|
h: Math.max(1, man?.minSize?.h || 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── tile DOM ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const GEAR_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm8.4-2.1.1-1.4-.1-1.4 1.7-1.3-1.6-2.8-2 .7a7.9 7.9 0 0 0-2.4-1.4l-.3-2.1H11l-.3 2.1a7.9 7.9 0 0 0-2.4 1.4l-2-.7L4.7 9.3 6.4 10.6l-.1 1.4.1 1.4-1.7 1.3 1.6 2.8 2-.7a7.9 7.9 0 0 0 2.4 1.4l.3 2.1h2.8l.3-2.1a7.9 7.9 0 0 0 2.4-1.4l2 .7 1.6-2.8-1.7-1.3Z"/></svg>';
|
||||||
|
|
||||||
|
function buildTile(tile) {
|
||||||
|
const man = registry.get(tile.module);
|
||||||
|
|
||||||
|
const el = document.createElement('section');
|
||||||
|
el.className = 'tile';
|
||||||
|
el.dataset.tileId = tile.id;
|
||||||
|
if (man) el.dataset.module = man.id;
|
||||||
|
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'tile-head';
|
||||||
|
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'dot';
|
||||||
|
dot.title = '';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'tile-title';
|
||||||
|
title.textContent = man ? man.name : tile.module + ' (not installed)';
|
||||||
|
|
||||||
|
head.append(dot, title);
|
||||||
|
|
||||||
|
const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length;
|
||||||
|
if (hasSettings) {
|
||||||
|
const gear = document.createElement('button');
|
||||||
|
gear.className = 'tile-btn';
|
||||||
|
gear.title = 'Tile settings (this tile only)';
|
||||||
|
gear.innerHTML = GEAR_SVG;
|
||||||
|
gear.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleSettingsPopover(tile, el);
|
||||||
|
});
|
||||||
|
head.appendChild(gear);
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = document.createElement('button');
|
||||||
|
close.className = 'tile-btn close';
|
||||||
|
close.title = 'Remove this tile';
|
||||||
|
close.textContent = '✕';
|
||||||
|
close.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeTile(tile.id);
|
||||||
|
});
|
||||||
|
head.appendChild(close);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'tile-body';
|
||||||
|
|
||||||
|
const resize = document.createElement('div');
|
||||||
|
resize.className = 'tile-resize';
|
||||||
|
resize.title = 'Drag to resize';
|
||||||
|
|
||||||
|
el.append(head, body, resize);
|
||||||
|
grid.appendChild(el);
|
||||||
|
|
||||||
|
const entry = { el, body, dot, titleEl: title, instance: null };
|
||||||
|
tileEls.set(tile.id, entry);
|
||||||
|
applyRect(tile);
|
||||||
|
|
||||||
|
wireDrag(tile, el, head);
|
||||||
|
wireResize(tile, el, resize);
|
||||||
|
|
||||||
|
mountModule(tile);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshEmptyState() {
|
||||||
|
emptyEl.hidden = tiles.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── drag to move ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function wireDrag(tile, el, handle) {
|
||||||
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
|
if (ev.target.closest('.tile-btn')) return;
|
||||||
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
ev.preventDefault();
|
||||||
|
closeSettingsPopovers();
|
||||||
|
handle.setPointerCapture(ev.pointerId);
|
||||||
|
const m = cellMetrics();
|
||||||
|
const sx = ev.clientX;
|
||||||
|
const sy = ev.clientY;
|
||||||
|
const startX = tile.x;
|
||||||
|
const startY = tile.y;
|
||||||
|
let moved = false;
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
|
||||||
|
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
||||||
|
if (!moved && (dCols || dRows)) {
|
||||||
|
moved = true;
|
||||||
|
el.classList.add('dragging');
|
||||||
|
}
|
||||||
|
const cand = {
|
||||||
|
x: Math.max(0, Math.min(COLS - tile.w, startX + dCols)),
|
||||||
|
y: Math.max(0, startY + dRows),
|
||||||
|
w: tile.w,
|
||||||
|
h: tile.h,
|
||||||
|
};
|
||||||
|
if ((cand.x !== tile.x || cand.y !== tile.y) && isFree(cand, tile.id)) {
|
||||||
|
tile.x = cand.x;
|
||||||
|
tile.y = cand.y;
|
||||||
|
applyRect(tile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
handle.removeEventListener('pointermove', onMove);
|
||||||
|
handle.removeEventListener('pointerup', onUp);
|
||||||
|
handle.removeEventListener('pointercancel', onUp);
|
||||||
|
el.classList.remove('dragging');
|
||||||
|
if (moved) saveLayout();
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', onUp);
|
||||||
|
handle.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── drag to resize ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function wireResize(tile, el, handle) {
|
||||||
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
handle.setPointerCapture(ev.pointerId);
|
||||||
|
const m = cellMetrics();
|
||||||
|
const sx = ev.clientX;
|
||||||
|
const sy = ev.clientY;
|
||||||
|
const startW = tile.w;
|
||||||
|
const startH = tile.h;
|
||||||
|
const min = minSizeOf(tile);
|
||||||
|
el.classList.add('resizing');
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
|
||||||
|
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
|
||||||
|
const cand = {
|
||||||
|
x: tile.x,
|
||||||
|
y: tile.y,
|
||||||
|
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
|
||||||
|
h: Math.max(min.h, startH + dRows),
|
||||||
|
};
|
||||||
|
if ((cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) {
|
||||||
|
tile.w = cand.w;
|
||||||
|
tile.h = cand.h;
|
||||||
|
applyRect(tile);
|
||||||
|
changed = true;
|
||||||
|
notifyResize(tile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
handle.removeEventListener('pointermove', onMove);
|
||||||
|
handle.removeEventListener('pointerup', onUp);
|
||||||
|
handle.removeEventListener('pointercancel', onUp);
|
||||||
|
el.classList.remove('resizing');
|
||||||
|
if (changed) saveLayout();
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', onUp);
|
||||||
|
handle.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyResize(tile) {
|
||||||
|
const entry = tileEls.get(tile.id);
|
||||||
|
try {
|
||||||
|
entry?.instance?.onResize?.(tile.w, tile.h);
|
||||||
|
} catch { /* module's problem, not the shell's */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── add / remove tiles ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function addTile(moduleId) {
|
||||||
|
const man = registry.get(moduleId);
|
||||||
|
const size = {
|
||||||
|
w: Math.max(1, Math.min(COLS, man?.defaultSize?.w || 4)),
|
||||||
|
h: Math.max(1, man?.defaultSize?.h || 3),
|
||||||
|
};
|
||||||
|
const spot = findSpot(size.w, size.h);
|
||||||
|
const settings = {};
|
||||||
|
for (const [key, spec] of Object.entries(man?.instanceSchema || {})) {
|
||||||
|
if (spec && 'default' in spec) settings[key] = spec.default;
|
||||||
|
}
|
||||||
|
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
|
||||||
|
tiles.push(tile);
|
||||||
|
buildTile(tile);
|
||||||
|
refreshEmptyState();
|
||||||
|
saveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTile(id) {
|
||||||
|
const idx = tiles.findIndex((t) => t.id === id);
|
||||||
|
if (idx < 0) return;
|
||||||
|
unmountModule(tiles[idx]);
|
||||||
|
tiles.splice(idx, 1);
|
||||||
|
const entry = tileEls.get(id);
|
||||||
|
if (entry) {
|
||||||
|
entry.el.remove();
|
||||||
|
tileEls.delete(id);
|
||||||
|
}
|
||||||
|
refreshEmptyState();
|
||||||
|
saveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllTiles() {
|
||||||
|
for (const tile of tiles) {
|
||||||
|
unmountModule(tile);
|
||||||
|
tileEls.get(tile.id)?.el.remove();
|
||||||
|
}
|
||||||
|
tiles = [];
|
||||||
|
tileEls.clear();
|
||||||
|
refreshEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── module mounting ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Milestone 2 turns this into the real module loader (dynamic import of each
|
||||||
|
module's client.js plus the moduleApi). For now only the built-in
|
||||||
|
placeholder renders. */
|
||||||
|
|
||||||
|
function mountModule(tile) {
|
||||||
|
const entry = tileEls.get(tile.id);
|
||||||
|
if (!entry) return;
|
||||||
|
const man = registry.get(tile.module);
|
||||||
|
if (!man) {
|
||||||
|
entry.body.innerHTML = '<div class="tile-msg">Module “' + tile.module + '” is not installed.</div>';
|
||||||
|
entry.dot.className = 'dot error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (man.builtin) {
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
msg.className = 'tile-msg';
|
||||||
|
msg.textContent = 'Placeholder tile — drag me by the header, resize me by the corner.';
|
||||||
|
entry.body.appendChild(msg);
|
||||||
|
entry.dot.className = 'dot ok';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmountModule(tile) {
|
||||||
|
const entry = tileEls.get(tile.id);
|
||||||
|
try {
|
||||||
|
entry?.instance?.stop?.();
|
||||||
|
} catch { /* stopping is best-effort */ }
|
||||||
|
if (entry) entry.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── per-tile settings popover ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
function closeSettingsPopovers() {
|
||||||
|
document.querySelectorAll('.tile-settings').forEach((p) => p.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSettingsPopover(tile, el) {
|
||||||
|
const existing = el.querySelector('.tile-settings');
|
||||||
|
closeSettingsPopovers();
|
||||||
|
if (existing) return;
|
||||||
|
const man = registry.get(tile.module);
|
||||||
|
if (!man || !man.instanceSchema) return;
|
||||||
|
|
||||||
|
const pop = document.createElement('div');
|
||||||
|
pop.className = 'tile-settings';
|
||||||
|
const inputs = new Map();
|
||||||
|
|
||||||
|
for (const [key, spec] of Object.entries(man.instanceSchema)) {
|
||||||
|
const type = spec?.type || 'string';
|
||||||
|
const label = spec?.label || key;
|
||||||
|
const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default;
|
||||||
|
|
||||||
|
const field = document.createElement('label');
|
||||||
|
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
||||||
|
|
||||||
|
if (type === 'boolean') {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.checked = Boolean(current);
|
||||||
|
field.append(input, document.createTextNode(label));
|
||||||
|
inputs.set(key, () => input.checked);
|
||||||
|
} else if (type === 'select' && Array.isArray(spec.options)) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const select = document.createElement('select');
|
||||||
|
for (const opt of spec.options) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = String(typeof opt === 'object' ? opt.value : opt);
|
||||||
|
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||||
|
select.appendChild(o);
|
||||||
|
}
|
||||||
|
select.value = String(current ?? '');
|
||||||
|
field.append(span, select);
|
||||||
|
inputs.set(key, () => select.value);
|
||||||
|
} else {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = type === 'number' ? 'number' : (type === 'password' ? 'password' : 'text');
|
||||||
|
input.value = current === undefined || current === null ? '' : String(current);
|
||||||
|
field.append(span, input);
|
||||||
|
inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value));
|
||||||
|
}
|
||||||
|
pop.appendChild(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'actions';
|
||||||
|
actions.style.display = 'flex';
|
||||||
|
actions.style.justifyContent = 'flex-end';
|
||||||
|
actions.style.gap = '8px';
|
||||||
|
const cancel = document.createElement('button');
|
||||||
|
cancel.className = 'btn';
|
||||||
|
cancel.textContent = 'Cancel';
|
||||||
|
cancel.addEventListener('click', () => pop.remove());
|
||||||
|
const apply = document.createElement('button');
|
||||||
|
apply.className = 'btn primary';
|
||||||
|
apply.textContent = 'Apply';
|
||||||
|
apply.addEventListener('click', () => {
|
||||||
|
for (const [key, read] of inputs) tile.settings[key] = read();
|
||||||
|
pop.remove();
|
||||||
|
saveLayout();
|
||||||
|
remountTile(tile);
|
||||||
|
});
|
||||||
|
actions.append(cancel, apply);
|
||||||
|
pop.appendChild(actions);
|
||||||
|
|
||||||
|
pop.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
|
el.appendChild(pop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear a tile's module down and start it again (settings changed). */
|
||||||
|
function remountTile(tile) {
|
||||||
|
const entry = tileEls.get(tile.id);
|
||||||
|
if (!entry) return;
|
||||||
|
unmountModule(tile);
|
||||||
|
entry.body.innerHTML = '';
|
||||||
|
entry.dot.className = 'dot';
|
||||||
|
mountModule(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── layout apply / reset ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function renderLayout(list) {
|
||||||
|
clearAllTiles();
|
||||||
|
tiles = list.map(sanitizeTile);
|
||||||
|
for (const tile of tiles) buildTile(tile);
|
||||||
|
refreshEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── menus ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
wireDropdown('add-btn', 'add-menu', async (menu) => {
|
||||||
|
await loadRegistry();
|
||||||
|
menu.innerHTML = '';
|
||||||
|
const manifests = [...registry.values()];
|
||||||
|
if (!manifests.length) {
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.className = 'menu-note';
|
||||||
|
note.textContent = 'No modules are enabled. Enable some in Admin.';
|
||||||
|
menu.appendChild(note);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const man of manifests) {
|
||||||
|
menu.appendChild(menuItem(man.name, () => {
|
||||||
|
menu.hidden = true;
|
||||||
|
addTile(man.id);
|
||||||
|
}, { sub: man.description || '' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wireDropdown('layout-btn', 'layout-menu', async (menu) => {
|
||||||
|
menu.innerHTML = '';
|
||||||
|
menu.appendChild(menuItem('Reset layout', () => {
|
||||||
|
menu.hidden = true;
|
||||||
|
renderLayout([]);
|
||||||
|
saveLayout();
|
||||||
|
toast('Layout cleared');
|
||||||
|
}, { danger: true, sub: 'Remove every tile from this browser' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── boot ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
await loadRegistry();
|
||||||
|
renderLayout(loadLayoutFromStorage());
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
/* ProdDash shell — dark, high-contrast, production-booth friendly.
|
||||||
|
Visual language carried over from ProdCom Listener (same variable names, so
|
||||||
|
module CSS written against these looks native everywhere). Modules must
|
||||||
|
style with these variables only — no hardcoded colors. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0b0e12;
|
||||||
|
--panel: #131920;
|
||||||
|
--border: #232b35;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #8b98a5;
|
||||||
|
--accent: #2ee59a;
|
||||||
|
--danger: #f4433c;
|
||||||
|
--warn: #f0b429;
|
||||||
|
/* grid geometry (shell.js reads these) */
|
||||||
|
--cell-h: 72px;
|
||||||
|
--gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button { font-family: inherit; }
|
||||||
|
|
||||||
|
/* ── topbar ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#topbar {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#brand {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
#brand .accent { color: var(--accent); }
|
||||||
|
|
||||||
|
#topbar .spacer { flex: 1; }
|
||||||
|
|
||||||
|
.tb-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tb-btn:hover { border-color: var(--muted); }
|
||||||
|
.tb-btn:active { background: var(--border); }
|
||||||
|
|
||||||
|
/* ── dropdown menus ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.dropdown { position: relative; }
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 210px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.menu[hidden] { display: none; }
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.menu-item:hover { background: var(--border); }
|
||||||
|
.menu-item .sub {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.menu-item.danger { color: var(--danger); }
|
||||||
|
.menu-note {
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
|
||||||
|
.menu-title {
|
||||||
|
padding: 6px 10px 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── grid ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#gridwrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: var(--gap);
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
#grid {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: var(--cell-h);
|
||||||
|
gap: var(--gap);
|
||||||
|
/* Always leave room to drop/grow a tile below the last row. */
|
||||||
|
padding-bottom: calc(3 * (var(--cell-h) + var(--gap)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#empty-state {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 12vh;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#empty-state[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* ── tiles ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.dragging,
|
||||||
|
.tile.resizing {
|
||||||
|
z-index: 40;
|
||||||
|
border-color: var(--muted);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-head {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.tile.dragging .tile-head { cursor: grabbing; }
|
||||||
|
|
||||||
|
.tile-head .dot {
|
||||||
|
flex: none;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.tile-head .dot.ok { background: var(--accent); box-shadow: 0 0 5px var(--accent); }
|
||||||
|
.tile-head .dot.connecting { background: var(--warn); }
|
||||||
|
.tile-head .dot.error { background: var(--danger); }
|
||||||
|
|
||||||
|
.tile-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-btn {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tile-btn:hover { background: var(--border); color: var(--text); }
|
||||||
|
.tile-btn.close:hover { color: var(--danger); }
|
||||||
|
.tile-btn svg { width: 13px; height: 13px; display: block; }
|
||||||
|
|
||||||
|
.tile-body {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-resize {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 5;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.tile-resize:hover { opacity: 1; color: var(--text); }
|
||||||
|
.tile-resize::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-right: 2px solid currentColor;
|
||||||
|
border-bottom: 2px solid currentColor;
|
||||||
|
border-radius: 0 0 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* status message a module can show while degraded (also used by the shell
|
||||||
|
when a module fails to load) */
|
||||||
|
.tile-msg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--panel);
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── per-tile settings popover ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tile-settings {
|
||||||
|
position: absolute;
|
||||||
|
top: 30px;
|
||||||
|
right: 6px;
|
||||||
|
z-index: 45;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: calc(100% - 12px);
|
||||||
|
max-height: calc(100% - 40px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.field input[type="text"],
|
||||||
|
.field input[type="number"],
|
||||||
|
.field input[type="password"],
|
||||||
|
.field select {
|
||||||
|
padding: 7px 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.field.check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.field.check input { width: 15px; height: 15px; accent-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── modal dialogs (layout name prompt etc.) ───────────────────────── */
|
||||||
|
|
||||||
|
#modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 90;
|
||||||
|
background: rgba(4, 6, 8, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 18vh;
|
||||||
|
}
|
||||||
|
#modal-backdrop[hidden] { display: none; }
|
||||||
|
|
||||||
|
#modal {
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
#modal h2 { font-size: 14px; margin-bottom: 12px; }
|
||||||
|
#modal .actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--muted); }
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #06281a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* toast */
|
||||||
|
#toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 18px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 95;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
#toast[hidden] { display: none; }
|
||||||
|
#toast.error { border-color: var(--danger); }
|
||||||
|
|
||||||
|
/* ── scrollbars ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent; /* inset gutter */
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #364253;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-corner { background: transparent; }
|
||||||
|
|
||||||
|
/* Firefox (webkit pseudo-elements unsupported there) */
|
||||||
|
@supports (-moz-appearance: none) {
|
||||||
|
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
#topbar { padding: 6px 8px; flex-wrap: wrap; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ProdDash — modular production dashboard for Waters Church.
|
||||||
|
*
|
||||||
|
* Shell server: serves the dashboard client from public/, discovers modules
|
||||||
|
* in modules/, mounts each enabled module's server routes under
|
||||||
|
* /api/modules/<id>/, and stores server-side config + named layouts under
|
||||||
|
* config/.
|
||||||
|
*
|
||||||
|
* Zero dependencies — Node built-ins only (Node 18+ for global fetch).
|
||||||
|
* Configure via config/proddash.json or env vars:
|
||||||
|
* PORT (default: value in proddash.json, else 24500)
|
||||||
|
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||||
|
const CONFIG_DIR = path.join(ROOT, 'config');
|
||||||
|
|
||||||
|
/* ── config ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function readJson(file, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellConfig = readJson(path.join(CONFIG_DIR, 'proddash.json'), {});
|
||||||
|
const PORT = Number(process.env.PORT || shellConfig.port || 24500);
|
||||||
|
|
||||||
|
/* ── static files ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Serve a file from inside baseDir; urlPath is relative to it. */
|
||||||
|
function serveFile(res, baseDir, urlPath) {
|
||||||
|
let rel;
|
||||||
|
try {
|
||||||
|
rel = decodeURIComponent(urlPath).replace(/^\/+/, '');
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('Bad request');
|
||||||
|
}
|
||||||
|
if (rel === '') rel = 'index.html';
|
||||||
|
// Resolve inside baseDir only — never let a crafted path escape the web root.
|
||||||
|
const target = path.resolve(baseDir, rel);
|
||||||
|
if (target !== baseDir && !target.startsWith(baseDir + path.sep)) {
|
||||||
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('Forbidden');
|
||||||
|
}
|
||||||
|
fs.stat(target, (serr, stat) => {
|
||||||
|
const file = !serr && stat.isDirectory() ? path.join(target, 'index.html') : target;
|
||||||
|
fs.readFile(file, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('Not found');
|
||||||
|
}
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
// Icons can cache; everything else must always revalidate so UI updates
|
||||||
|
// reach open dashboards immediately.
|
||||||
|
const noCache = ext !== '.png' && ext !== '.ico' && ext !== '.svg' && ext !== '.woff2';
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': MIME[ext] || 'application/octet-stream',
|
||||||
|
'Cache-Control': noCache ? 'no-cache' : 'public, max-age=3600',
|
||||||
|
});
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── request routing ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
// Everything is guarded: one throw must never take the dashboard down mid-service.
|
||||||
|
try {
|
||||||
|
let urlPath;
|
||||||
|
try {
|
||||||
|
urlPath = new URL(req.url, 'http://proddash.invalid').pathname;
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('Bad request');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
|
res.writeHead(405, { Allow: 'GET, HEAD' });
|
||||||
|
return res.end('Method not allowed');
|
||||||
|
}
|
||||||
|
serveFile(res, PUBLIC_DIR, urlPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[proddash] request error:', err);
|
||||||
|
try {
|
||||||
|
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Server error');
|
||||||
|
} catch { /* connection already gone */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Never time out long-lived SSE connections.
|
||||||
|
server.requestTimeout = 0;
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log('ProdDash running');
|
||||||
|
console.log(' Local : http://localhost:' + PORT);
|
||||||
|
for (const ifaces of Object.values(os.networkInterfaces())) {
|
||||||
|
for (const iface of ifaces || []) {
|
||||||
|
if (iface.family === 'IPv4' && !iface.internal) {
|
||||||
|
console.log(' Network : http://' + iface.address + ':' + PORT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user