/* 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 = ''; 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 = '
Module “' + tile.module + '” is not installed.
'; 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();