diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 55a4491..65e7d7c 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -59,6 +59,7 @@ shell code changes, ever. | `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. | | `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. | | `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). | +| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. | Both schemas map field names to specs: @@ -107,6 +108,7 @@ export default function create({ root, moduleApi }) { | --- | --- | | `id` | Your module id. | | `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. | +| `variant` | For multi-tile modules: the id of the tile-list entry this tile was added as, `''` otherwise (see **Presenting multiple tiles**). | | `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. | | `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. | | `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). | @@ -260,6 +262,50 @@ module.exports = { shows in `/admin`, and API calls answer 502 until a config fix re-inits it. Validate config early so bad input fails loudly there. +### Presenting multiple tiles + +By default a module is one entry in the + Add-tile picker. A module can +instead present a **list of tiles** — one picker entry each, grouped under +the module's name. This is how a module whose content is naturally plural +works: one tile per ProPresenter timer, one tile per admin-configured page. + +Export `tiles` from your server entry (sync or async): + +```js +module.exports = { + init({ config, log }) { … }, + routes({ config, log }) { … }, + + tiles({ config, log }) { + // e.g. derived from admin config, or discovered from your upstream + return config.pages.map((page) => ({ + id: page.name, // stable id, stored with the tile + name: page.name, // picker label AND the tile's title + description: page.url, // optional line under the label + settings: { page: page.name }, // preset instance settings + defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides + minSize: { w: 2, h: 2 }, + })); + }, +}; +``` + +- `tiles()` runs on **every picker load**, so the list may be live data — + keep it fast (answer from state you already hold; don't fetch upstream on + demand). A throw or a hang (>2 s) falls back to the classic single entry, + so a dead upstream can't break the picker. +- When the user adds an entry, its `settings` overlay your `instanceSchema` + defaults in that tile's instance settings, the entry's `name` becomes the + tile's title, and `moduleApi.variant` carries its `id`. +- The tile keeps its variant id and title even if the entry later vanishes + from your list (a timer deleted in ProPresenter, a page removed in admin). + **Your client decides what that means** — show a clear "no longer + configured" state rather than erroring. +- A client-only module can declare a fixed list as a `tiles` array in + `module.json` instead (same entry shape). +- An empty list (or no `tiles` at all) keeps today's behavior: one picker + entry, no preset. + ### Server-Sent Events from a module For live data, expose a `/stream` route and let clients use diff --git a/public/shell.js b/public/shell.js index 7e44ef4..9e0785f 100644 --- a/public/shell.js +++ b/public/shell.js @@ -142,7 +142,7 @@ function loadLayoutFromStorage() { function sanitizeTile(t) { const w = Math.max(1, Math.min(COLS, Number(t.w) || 3)); - return { + const tile = { id: String(t.id || newId()), module: String(t.module || ''), x: Math.max(0, Math.min(COLS - w, Number(t.x) || 0)), @@ -151,7 +151,14 @@ function sanitizeTile(t) { h: Math.max(1, Number(t.h) || 2), settings: (t.settings && typeof t.settings === 'object') ? t.settings : {}, headHidden: Boolean(t.headHidden), + // set when the tile was added from a module's tile list (multi-tile modules) + variant: t.variant ? String(t.variant) : '', + title: t.title ? String(t.title) : '', }; + if (t.minSize && Number(t.minSize.w) > 0 && Number(t.minSize.h) > 0) { + tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) }; + } + return tile; } function newId() { @@ -200,10 +207,10 @@ function applyRect(tile) { } function minSizeOf(tile) { - const man = registry.get(tile.module); + const min = tile.minSize || registry.get(tile.module)?.minSize; return { - w: Math.max(1, man?.minSize?.w || 1), - h: Math.max(1, man?.minSize?.h || 1), + w: Math.max(1, min?.w || 1), + h: Math.max(1, min?.h || 1), }; } @@ -228,7 +235,7 @@ function buildTile(tile) { const title = document.createElement('span'); title.className = 'tile-title'; - title.textContent = man ? man.name : tile.module + ' (not installed)'; + title.textContent = man ? (tile.title || man.name) : tile.module + ' (not installed)'; // where moduleApi.header puts a module's own buttons and menus const controls = document.createElement('span'); @@ -492,18 +499,30 @@ function buildDivider(L, R, top, bottom) { /* ── add / remove tiles ─────────────────────────────────────────────── */ -function addTile(moduleId) { +/** + * Add a tile. `entry` is one item of a multi-tile module's tile list + * (picker variants): its preset settings overlay the schema defaults, and + * its name/sizes stick to the tile. + */ +function addTile(moduleId, entry = null) { const man = registry.get(moduleId); + const defaultSize = entry?.defaultSize || man?.defaultSize; const size = { - w: Math.max(1, Math.min(COLS, man?.defaultSize?.w || 4)), - h: Math.max(1, man?.defaultSize?.h || 3), + w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)), + h: Math.max(1, 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; } + Object.assign(settings, entry?.settings || {}); const tile = { id: newId(), module: moduleId, ...spot, ...size, settings }; + if (entry) { + tile.variant = String(entry.id); + tile.title = String(entry.name || ''); + if (entry.minSize) tile.minSize = { w: entry.minSize.w, h: entry.minSize.h }; + } tiles.push(tile); buildTile(tile); refreshEmptyState(); @@ -579,6 +598,10 @@ function buildModuleApi(tile, man, entry) { id: man.id, instanceId: tile.id, + /** For multi-tile modules: which entry of the module's tile list this + tile was added as ('' when added as the plain module). */ + variant: tile.variant || '', + /** Admin (server-wide) config, read-only. Password fields never reach the client. Live: reflects the latest registry after admin saves. */ get config() { @@ -908,10 +931,26 @@ wireDropdown('add-btn', 'add-menu', async (menu) => { return; } for (const man of manifests) { - menu.appendChild(menuItem(man.name, () => { - menu.hidden = true; - addTile(man.id); - }, { sub: man.description || '' })); + const entries = Array.isArray(man.tiles) ? man.tiles : []; + if (entries.length) { + // A multi-tile module: its name becomes a group heading and each of + // its tiles is added individually. + const title = document.createElement('div'); + title.className = 'menu-title'; + title.textContent = man.name; + menu.appendChild(title); + for (const entry of entries) { + menu.appendChild(menuItem(entry.name, () => { + menu.hidden = true; + addTile(man.id, entry); + }, { sub: entry.description || '' })); + } + } else { + menu.appendChild(menuItem(man.name, () => { + menu.hidden = true; + addTile(man.id); + }, { sub: man.description || '' })); + } } }); diff --git a/server.js b/server.js index 1d8dd38..d9055fe 100644 --- a/server.js +++ b/server.js @@ -125,6 +125,13 @@ function clientConfig(id) { * init({ config, log }) -> handle with stop() (and optionally health()) * routes({ config, log }) -> { 'GET /state': handler, 'GET /stream': handler, * 'GET /proxy/*': handler, '* /any/*': handler } + * tiles({ config, log }) -> optional, sync or async: the tiles this module + * presents in the Add-tile picker, e.g. + * [{ id, name, description, settings, minSize, + * defaultSize }]. Called on every picker load, + * so the list may be dynamic (discovered timers, + * admin-configured pages). Omitted or empty → + * the module appears once, as before. * Routes are mounted under /api/modules//. A trailing "/*" makes a prefix * route; the matched remainder is passed as req.wildcard (req.search carries * the query string). init() runs before routes(), so routes can close over @@ -173,6 +180,9 @@ function mountModule(id) { if (typeof mod.routes === 'function') { entry.routes = parseRouteTable(mod.routes({ config, log })); } + if (typeof mod.tiles === 'function') { + entry.tiles = () => mod.tiles({ config, log }); + } log('mounted' + (entry.routes.length ? ` (${entry.routes.length} routes)` : '')); } catch (err) { entry.error = err instanceof Error ? err.message : String(err); @@ -243,8 +253,51 @@ function readBody(req, limitBytes = 512 * 1024) { }); } +/** One entry of a module's tile list, with everything untrusted dropped. */ +function sanitizeTileEntry(raw) { + if (!raw || typeof raw !== 'object' || !raw.id || !raw.name) return null; + const out = { + id: String(raw.id), + name: String(raw.name), + description: raw.description ? String(raw.description) : '', + settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {}, + }; + for (const key of ['minSize', 'defaultSize']) { + const size = raw[key]; + if (size && Number(size.w) > 0 && Number(size.h) > 0) { + out[key] = { w: Number(size.w), h: Number(size.h) }; + } + } + return out; +} + +/** + * The tiles a module presents in the picker. A module's tiles() runs on every + * picker load so the list can be live data — but a hung or throwing module + * must never stall the picker, so it gets a short deadline and any failure + * falls back to the classic single entry (empty list). + */ +async function moduleTiles(id) { + const man = manifests.get(id); + const entry = mounted.get(id); + let list = Array.isArray(man.tiles) ? man.tiles : []; // static, for client-only modules + if (entry?.tiles) { + try { + const dynamic = await Promise.race([ + Promise.resolve(entry.tiles()), + new Promise((_, reject) => setTimeout(() => reject(new Error('tiles() timed out')), 2000).unref()), + ]); + if (Array.isArray(dynamic)) list = dynamic; + } catch (err) { + console.error(`[proddash] module "${id}" tiles() failed:`, err?.message || err); + list = []; + } + } + return list.map(sanitizeTileEntry).filter(Boolean).slice(0, 100); +} + /** Manifest as sent to the dashboard client (enabled modules only). */ -function clientManifest(id) { +async function clientManifest(id) { const man = manifests.get(id); return { id: man.id, @@ -258,6 +311,7 @@ function clientManifest(id) { instanceSchema: man.instanceSchema || {}, hasServer: Boolean(man.server), config: clientConfig(id), + tiles: await moduleTiles(id), }; } @@ -652,8 +706,13 @@ function handleRequest(req, res) { /* — shell API — */ if (urlPath === '/api/modules' && req.method === 'GET') { - const list = [...manifests.keys()].filter(isEnabled).map(clientManifest); - return sendJson(res, 200, { modules: list }); + Promise.all([...manifests.keys()].filter(isEnabled).map(clientManifest)) + .then((list) => sendJson(res, 200, { modules: list })) + .catch((err) => { + console.error('[proddash] /api/modules failed:', err); + if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' }); + }); + return; } /* — shell events: open dashboards learn about admin changes live — */