Module contract: modules can present multiple tiles to the picker
A module may now present a list of tiles instead of a single picker
entry. A server module exports tiles({config, log}) — sync or async,
called on every picker load so the list can be live data (discovered
ProPresenter timers, admin-configured pages); a client-only module can
declare a static tiles array in module.json. The + picker groups the
entries under the module's name; adding one presets that entry's
instance settings, gives the tile the entry's name as its title, and
honors per-entry default/min sizes, all persisted with the layout.
moduleApi.variant tells the instance which entry it was added as. A
throwing or hung tiles() (2 s deadline) falls back to the classic single
entry so a dead upstream can never break the picker; modules without
tiles are byte-for-byte unaffected. Documented in docs/MODULE-GUIDE.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
@@ -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
|
||||
|
||||
+51
-12
@@ -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 || '' }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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/<id>/. 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 — */
|
||||
|
||||
Reference in New Issue
Block a user