Merge pull request #6 from michaelmcgary2008/claude/proddash-app-build-gnwq7y

Unified endpoint config + fullscreen auto-fit and UI polish
This commit is contained in:
Michael McGary
2026-08-31 16:59:22 -04:00
committed by GitHub
16 changed files with 394 additions and 67 deletions
+4 -4
View File
@@ -83,16 +83,16 @@ are written by the app itself.
- **ProdCom** — live transcript stream with in-place updates of in-progress - **ProdCom** — live transcript stream with in-place updates of in-progress
speech, channel colors, per-tile channel visibility toggles and group speech, channel colors, per-tile channel visibility toggles and group
presets, timestamps, text size and jump-to-latest in the title bar. presets, timestamps, text size and jump-to-latest in the title bar.
Admin config: ProdCom URL, API key. Admin config: ProdCom server (host : port), API key.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
Admin config: host, port, password. Admin config: ProPresenter endpoint (host : port), password.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time, - **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers" discovered timer (and the LTC card) as its own tile, or add "All timers"
and pick a selection via the tile's "Timers ▾" menu; overruns are and pick a selection via the tile's "Timers ▾" menu; overruns are
unmistakably red. Read-only — no start/stop controls. Admin config: host, unmistakably red. Read-only — no start/stop controls. Admin config:
port, password. ProPresenter endpoint (host : port), password.
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
+29 -1
View File
@@ -64,7 +64,7 @@ shell code changes, ever.
Both schemas map field names to specs: Both schemas map field names to specs:
``` ```
{ "type": "string" | "number" | "boolean" | "select" | "password", { "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint",
"label": "Shown next to the field", "label": "Shown next to the field",
"default": <value>, "default": <value>,
"options": [ { "value": "a", "label": "A" }, … ] // "select" only "options": [ { "value": "a", "label": "A" }, … ] // "select" only
@@ -77,6 +77,34 @@ browsers** — not in `/api/modules`, and never echoed back into the admin
form. Anything upstream that needs the secret must go through your server form. Anything upstream that needs the secret must go through your server
routes. routes.
### Connection settings: use `endpoint`
Anything that points at a device or service on the network — ProPresenter,
ProdCom, a camera, a mixer — must be **one `endpoint` field**, so every
module's connection is entered the same way. The admin page renders an
endpoint as a single "Host / IP : Port" control; the value is stored and
handed to your server as an object:
```json
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"default": { "host": "", "port": 1025 }
}
```
```js
// in your server entry
const { host, port } = config.propresenter;
const base = `http://${host}:${port}`;
```
Do **not** split host and port into separate `string`/`number` fields, and
do **not** ask for a full `http://…` URL unless the setting genuinely is a
web address where protocol or path matter (a page to embed, a webhook).
Endpoints belong in admin config, not per-tile settings — connections are
server-wide by design.
Admin config lives in `config/modules.json` (written by the admin page — Admin config lives in `config/modules.json` (written by the admin page —
your module never touches that file). Per-tile settings live inside each your module never touches that file). Per-tile settings live inside each
browser's layout. browser's layout.
+8 -2
View File
@@ -296,11 +296,17 @@ export default function create({ root, moduleApi }) {
const text = document.createElement('span'); const text = document.createElement('span');
text.className = 'text'; text.className = 'text';
// channel name with its timestamp to the right; the clock button
// only toggles the timestamp's visibility
const chLine = document.createElement('span');
chLine.className = 'channel-line';
chLine.append(ch, time);
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'body'; body.className = 'body';
body.append(ch, text); body.append(chLine, text);
el.append(time, avatar, body); el.append(avatar, body);
entryEls.set(id, el); entryEls.set(id, el);
applyFilter(el); applyFilter(el);
entriesEl.appendChild(el); entriesEl.appendChild(el);
+4 -4
View File
@@ -9,10 +9,10 @@
"minSize": { "w": 3, "h": 2 }, "minSize": { "w": 3, "h": 2 },
"defaultSize": { "w": 4, "h": 4 }, "defaultSize": { "w": 4, "h": 4 },
"configSchema": { "configSchema": {
"prodcomUrl": { "prodcom": {
"type": "string", "type": "endpoint",
"label": "ProdCom URL", "label": "ProdCom server",
"default": "http://10.3.11.152:24480" "default": { "host": "10.3.11.152", "port": 24480 }
}, },
"apiKey": { "apiKey": {
"type": "password", "type": "password",
+15 -4
View File
@@ -71,11 +71,22 @@ function proxy(req, res, target, apiKey) {
req.pipe(preq); req.pipe(preq);
} }
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
before the endpoint field existed) can only still be present until the
admin form is re-saved, so when it exists it wins over the endpoint's
schema default. */
function resolveTarget(config) {
if (config.prodcomUrl) return new URL(config.prodcomUrl);
const ep = config.prodcom;
if (ep && ep.host && Number(ep.port)) return new URL(`http://${ep.host}:${Number(ep.port)}`);
return null;
}
module.exports = { module.exports = {
init({ config }) { init({ config }) {
health = config.prodcomUrl health = resolveTarget(config)
? { status: 'connecting', message: 'No traffic yet' } ? { status: 'connecting', message: 'No traffic yet' }
: { status: 'error', message: 'No ProdCom URL configured' }; : { status: 'error', message: 'No ProdCom server configured' };
return { return {
stop() { /* nothing persistent to tear down — streams die with their sockets */ }, stop() { /* nothing persistent to tear down — streams die with their sockets */ },
health: () => health, health: () => health,
@@ -83,8 +94,8 @@ module.exports = {
}, },
routes({ config, log }) { routes({ config, log }) {
// An invalid URL throws here, which surfaces as a mount error in /admin. // An invalid host/URL throws here, which surfaces as a mount error in /admin.
const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480'); const target = resolveTarget(config) || new URL('http://10.3.11.152:24480');
log(`proxying /prodcom/* → ${target.origin}`); log(`proxying /prodcom/* → ${target.origin}`);
return { return {
'* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''), '* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
+14 -3
View File
@@ -82,15 +82,24 @@
to { opacity: 1; transform: none; } to { opacity: 1; transform: none; }
} }
[data-module="prodcom-transcript"] .channel-line {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
min-width: 0;
}
/* timestamp sits to the right of the channel name; the clock button only
toggles its visibility */
[data-module="prodcom-transcript"] .pt-entry .time { [data-module="prodcom-transcript"] .pt-entry .time {
display: none; display: none;
color: var(--muted); color: var(--muted);
font-size: 0.72em; font-size: 0.72em;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
flex: none; flex: none;
padding-top: 3px;
} }
[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: block; } [data-module="prodcom-transcript"] .show-times .pt-entry .time { display: inline; }
[data-module="prodcom-transcript"] .pt-entry .avatar { [data-module="prodcom-transcript"] .pt-entry .avatar {
flex: none; flex: none;
@@ -114,7 +123,9 @@
letter-spacing: 0.01em; letter-spacing: 0.01em;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
margin-bottom: 4px; overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
[data-module="prodcom-transcript"] .pt-entry .text { [data-module="prodcom-transcript"] .pt-entry .text {
+4 -9
View File
@@ -9,15 +9,10 @@
"minSize": { "w": 3, "h": 3 }, "minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 4, "h": 5 }, "defaultSize": { "w": 4, "h": 5 },
"configSchema": { "configSchema": {
"host": { "propresenter": {
"type": "string", "type": "endpoint",
"label": "ProPresenter host / IP", "label": "ProPresenter",
"default": "" "default": { "host": "", "port": 1025 }
},
"port": {
"type": "number",
"label": "ProPresenter network port",
"default": 1025
}, },
"password": { "password": {
"type": "password", "type": "password",
+11 -5
View File
@@ -23,7 +23,13 @@ let current = null;
module.exports = { module.exports = {
init({ config, log }) { init({ config, log }) {
const streams = new Set(); const streams = new Set();
const enabled = Boolean(config.host && Number(config.port)); // Endpoint config. Legacy flat host/port keys (saved before the endpoint
// field existed) can only still be present until the admin form is
// re-saved, so when they exist they win over the endpoint's default.
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(config.host ?? ep.host ?? '');
const port = Number(config.port ?? ep.port) || 1025;
const enabled = Boolean(host && port);
let latest = clearedViewModel(); let latest = clearedViewModel();
let lastFrame = ''; let lastFrame = '';
@@ -54,14 +60,14 @@ module.exports = {
client.configure({ client.configure({
enabled, enabled,
host: String(config.host || ''), host,
port: Number(config.port) || 1025, port,
password: String(config.password || ''), password: String(config.password || ''),
}); });
if (enabled) { if (enabled) {
client.start(); client.start();
log(`following ProPresenter at ${config.host}:${config.port}`); log(`following ProPresenter at ${host}:${port}`);
} else { } else {
log('no ProPresenter host configured — set one in /admin'); log('no ProPresenter host configured — set one in /admin');
} }
@@ -91,7 +97,7 @@ module.exports = {
}, },
health() { health() {
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' }; if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` }; if (latest.reachable) return { status: 'ok', message: `Following ${host}:${port}` };
return { return {
status: 'error', status: 'error',
message: latest.lastError message: latest.lastError
+4 -9
View File
@@ -9,15 +9,10 @@
"minSize": { "w": 2, "h": 1 }, "minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 4, "h": 2 }, "defaultSize": { "w": 4, "h": 2 },
"configSchema": { "configSchema": {
"host": { "propresenter": {
"type": "string", "type": "endpoint",
"label": "ProPresenter host / IP", "label": "ProPresenter",
"default": "" "default": { "host": "", "port": 1025 }
},
"port": {
"type": "number",
"label": "ProPresenter network port",
"default": 1025
}, },
"password": { "password": {
"type": "password", "type": "password",
+6 -2
View File
@@ -65,8 +65,12 @@ function clearedState(enabled) {
module.exports = { module.exports = {
init({ config, log }) { init({ config, log }) {
const streams = new Set(); const streams = new Set();
const host = String(config.host || ''); // Endpoint config. Legacy flat host/port keys (saved before the endpoint
const port = Number(config.port) || 1025; // field existed) can only still be present until the admin form is
// re-saved, so when they exist they win over the endpoint's default.
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(config.host ?? ep.host ?? '');
const port = Number(config.port ?? ep.port) || 1025;
const password = String(config.password || ''); const password = String(config.password || '');
const enabled = Boolean(host && port); const enabled = Boolean(host && port);
+6
View File
@@ -93,6 +93,12 @@ body { overflow: auto; }
.module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; } .module-form { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
.module-form .field { max-width: 420px; } .module-form .field { max-width: 420px; }
/* endpoint fields: every module's upstream is entered the same way */
.field .endpoint-row { display: flex; align-items: center; gap: 7px; }
.endpoint-row .ep-host { flex: 1; min-width: 0; }
.endpoint-row .ep-port { width: 92px; flex: none; }
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } .module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
.module-actions { .module-actions {
+26
View File
@@ -96,6 +96,32 @@ function fieldFor(key, spec, value, passwordSet) {
input.checked = Boolean(value); input.checked = Boolean(value);
field.append(input, document.createTextNode(label)); field.append(input, document.createTextNode(label));
read = () => input.checked; read = () => input.checked;
} else if (type === 'endpoint') {
// One consistent "Host / IP : Port" control for every module's upstream.
const span = document.createElement('span');
span.textContent = label;
const row = document.createElement('span');
row.className = 'endpoint-row';
const host = document.createElement('input');
host.type = 'text';
host.className = 'ep-host';
host.placeholder = 'Host / IP';
host.autocomplete = 'off';
host.spellcheck = false;
host.value = value?.host ? String(value.host) : '';
const sep = document.createElement('span');
sep.className = 'ep-sep';
sep.textContent = ':';
const port = document.createElement('input');
port.type = 'number';
port.className = 'ep-port';
port.placeholder = 'Port';
port.min = '1';
port.max = '65535';
port.value = value?.port ? String(value.port) : '';
row.append(host, sep, port);
field.append(span, row);
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
} else if (type === 'select' && Array.isArray(spec.options)) { } else if (type === 'select' && Array.isArray(spec.options)) {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
+9
View File
@@ -19,6 +19,13 @@
<span class="spacer"></span> <span class="spacer"></span>
<button class="tb-btn icon" id="fs-btn" title="Fullscreen — auto-fits the layout to the screen" aria-label="Fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
<path d="M4 15v4a1 1 0 0 0 1 1h4"/><path d="M20 15v4a1 1 0 0 1-1 1h-4"/>
</svg>
</button>
<div class="dropdown" id="add-dd"> <div class="dropdown" id="add-dd">
<button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button> <button class="tb-btn icon" id="add-btn" title="Add a module tile" aria-label="Add tile">+</button>
<div class="menu" id="add-menu" hidden></div> <div class="menu" id="add-menu" hidden></div>
@@ -43,6 +50,8 @@
</a> </a>
</header> </header>
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
<main id="gridwrap"> <main id="gridwrap">
<div id="grid"> <div id="grid">
<div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br> <div id="empty-state">No tiles yet — use “+ Add tile” to build this dashboard.<br>
+168 -11
View File
@@ -273,6 +273,10 @@ function buildTile(tile) {
resize.className = 'tile-resize'; resize.className = 'tile-resize';
resize.title = 'Drag to resize'; resize.title = 'Drag to resize';
const resizeLeft = document.createElement('div');
resizeLeft.className = 'tile-resize-left';
resizeLeft.title = 'Drag to resize';
// notch on the title bar's bottom edge: hides/shows the bar // notch on the title bar's bottom edge: hides/shows the bar
const notch = document.createElement('button'); const notch = document.createElement('button');
notch.className = 'tile-notch'; notch.className = 'tile-notch';
@@ -284,7 +288,7 @@ function buildTile(tile) {
saveLayout(); saveLayout();
}); });
el.append(head, body, resize, notch); el.append(head, body, resize, resizeLeft, notch);
el.classList.toggle('head-hidden', Boolean(tile.headHidden)); el.classList.toggle('head-hidden', Boolean(tile.headHidden));
grid.appendChild(el); grid.appendChild(el);
@@ -293,7 +297,8 @@ function buildTile(tile) {
applyRect(tile); applyRect(tile);
wireDrag(tile, el, head); wireDrag(tile, el, head);
wireResize(tile, el, resize); wireResize(tile, el, resize, false);
wireResize(tile, el, resizeLeft, true);
mountModule(tile); mountModule(tile);
return entry; return entry;
@@ -309,6 +314,7 @@ function wireDrag(tile, el, handle) {
handle.addEventListener('pointerdown', (ev) => { handle.addEventListener('pointerdown', (ev) => {
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return; if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return;
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault(); ev.preventDefault();
closeSettingsPopovers(); closeSettingsPopovers();
handle.setPointerCapture(ev.pointerId); handle.setPointerCapture(ev.pointerId);
@@ -356,17 +362,19 @@ function wireDrag(tile, el, handle) {
}); });
} }
/* ── drag to resize ─────────────────────────────────────────────────── */ /* ── drag to resize (bottom-right and bottom-left corners) ──────────── */
function wireResize(tile, el, handle) { function wireResize(tile, el, handle, fromLeft) {
handle.addEventListener('pointerdown', (ev) => { handle.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
handle.setPointerCapture(ev.pointerId); handle.setPointerCapture(ev.pointerId);
const m = cellMetrics(); const m = cellMetrics();
const sx = ev.clientX; const sx = ev.clientX;
const sy = ev.clientY; const sy = ev.clientY;
const startX = tile.x;
const startW = tile.w; const startW = tile.w;
const startH = tile.h; const startH = tile.h;
const min = minSizeOf(tile); const min = minSizeOf(tile);
@@ -377,13 +385,26 @@ function wireResize(tile, el, handle) {
const onMove = (e) => { const onMove = (e) => {
const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap)); const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap));
const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap)); const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap));
const cand = { let cand;
x: tile.x, if (fromLeft) {
y: tile.y, // the left edge moves; the right edge stays put
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)), const newX = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
h: Math.max(min.h, startH + dRows), cand = {
}; x: newX,
if ((cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) { y: tile.y,
w: startX + startW - newX,
h: Math.max(min.h, startH + dRows),
};
} else {
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.x !== tile.x || cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) {
tile.x = cand.x;
tile.w = cand.w; tile.w = cand.w;
tile.h = cand.h; tile.h = cand.h;
applyRect(tile); applyRect(tile);
@@ -452,6 +473,7 @@ function buildDivider(L, R, top, bottom) {
el.addEventListener('pointerdown', (ev) => { el.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 && ev.pointerType === 'mouse') return; if (ev.button !== 0 && ev.pointerType === 'mouse') return;
if (document.body.classList.contains('fullscreen')) return;
ev.preventDefault(); ev.preventDefault();
closeSettingsPopovers(); closeSettingsPopovers();
el.setPointerCapture(ev.pointerId); el.setPointerCapture(ev.pointerId);
@@ -915,6 +937,7 @@ function renderLayout(list) {
for (const tile of tiles) buildTile(tile); for (const tile of tiles) buildTile(tile);
refreshEmptyState(); refreshEmptyState();
rebuildDividers(); rebuildDividers();
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
} }
/* ── menus ──────────────────────────────────────────────────────────── */ /* ── menus ──────────────────────────────────────────────────────────── */
@@ -1026,6 +1049,140 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => {
}, { danger: true, sub: 'Remove every tile from this browser' })); }, { danger: true, sub: 'Remove every tile from this browser' }));
}); });
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
const topbar = document.getElementById('topbar');
const topbarNotch = document.getElementById('topbar-notch');
const LS_MENU = 'proddash:menuHidden';
/** Keep the notch glued to the menubar's bottom edge, whatever its height. */
const topbarSizer = new ResizeObserver(() => {
document.documentElement.style.setProperty('--topbar-h', topbar.offsetHeight + 'px');
});
topbarSizer.observe(topbar);
function setMenuHidden(hidden, { persist = true } = {}) {
document.body.classList.toggle('menu-hidden', hidden);
if (persist) {
try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ }
}
}
topbarNotch.addEventListener('click', () => {
setMenuHidden(!document.body.classList.contains('menu-hidden'));
});
try {
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
} catch { /* fine */ }
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
to the full width; tiles whose bottom edge lands near the bottom of the
viewport are stretched to end exactly at it; when nothing extends below
the viewport, scrolling is disabled. All of it is visual-only — the saved
layout is untouched and everything reverts on exit. */
const fsBtn = document.getElementById('fs-btn');
/** Rows this close to the viewport bottom count as "near" and get stretched. */
const FS_NEAR_ROWS = 2;
let menuHiddenBeforeFs = false;
function fsSmallExempt(tile) {
// A small tile (like the clock) alone in its columns must not become a
// full-height ribbon.
if (tile.h > 2) return false;
return !tiles.some((t) => t !== tile && t.x < tile.x + tile.w && tile.x < t.x + t.w);
}
function fsBottomMost(tile) {
return !tiles.some((t) => t !== tile
&& t.x < tile.x + tile.w && tile.x < t.x + t.w
&& t.y >= tile.y + tile.h);
}
function applyFullscreenFit() {
if (!document.body.classList.contains('fullscreen') || !tiles.length) return;
const m = cellMetrics();
const baseCellH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-h')) || 72;
const wrapH = gridwrap.clientHeight;
const maxCol = Math.max(1, ...tiles.map((t) => t.x + t.w));
const maxRow = Math.max(1, ...tiles.map((t) => t.y + t.h));
// x: the occupied columns stretch proportionally across the full width
grid.style.gridTemplateColumns = `repeat(${maxCol}, minmax(0, 1fr))`;
const layoutH = maxRow * (baseCellH + m.gap) - m.gap;
if (layoutH > wrapH) {
// deeper than the screen: keep natural rows and scrolling
grid.style.gridAutoRows = '';
gridwrap.classList.remove('no-scroll');
for (const tile of tiles) applyRect(tile);
return;
}
// nothing below the fold: lock scrolling and make R rows fill the height exactly
gridwrap.classList.add('no-scroll');
const R = Math.max(maxRow, Math.floor((wrapH + m.gap) / (baseCellH + m.gap)));
const cellH = (wrapH - (R - 1) * m.gap) / R;
grid.style.gridAutoRows = cellH + 'px';
for (const tile of tiles) {
applyRect(tile); // reset any previous stretch first
const bottom = tile.y + tile.h;
if (R - bottom <= FS_NEAR_ROWS && fsBottomMost(tile) && !fsSmallExempt(tile)) {
const entry = tileEls.get(tile.id);
if (entry) {
entry.el.style.gridRow = `${tile.y + 1} / span ${R - tile.y}`;
try { entry.instance?.onResize?.(tile.w, R - tile.y); } catch { /* module's problem */ }
}
}
}
}
function clearFullscreenFit() {
grid.style.gridTemplateColumns = '';
grid.style.gridAutoRows = '';
gridwrap.classList.remove('no-scroll');
for (const tile of tiles) {
applyRect(tile);
notifyResize(tile);
}
}
fsBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
} else {
document.exitFullscreen?.().catch(() => {});
}
});
document.addEventListener('fullscreenchange', () => {
const active = Boolean(document.fullscreenElement);
document.body.classList.toggle('fullscreen', active);
if (active) {
closeSettingsPopovers();
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
// let the collapsed menubar's space settle before measuring
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
} else {
setMenuHidden(menuHiddenBeforeFs, { persist: false });
clearFullscreenFit();
}
});
window.addEventListener('resize', () => {
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
});
// the menubar collapse animates, so the grid's final height arrives late
topbar.addEventListener('transitionend', () => {
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
});
/* ── live updates from the admin page ───────────────────────────────── */ /* ── live updates from the admin page ───────────────────────────────── */
/* The server broadcasts `modules-changed` on /api/events whenever admin /* The server broadcasts `modules-changed` on /api/events whenever admin
+79 -12
View File
@@ -39,12 +39,50 @@ button { font-family: inherit; }
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 12px; height: 46px;
padding: 0 12px;
background: var(--panel); background: var(--panel);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
transition: height 0.22s ease, border-bottom-width 0.22s ease;
} }
/* collapsed by its notch (and automatically in fullscreen) */
body.menu-hidden #topbar {
height: 0;
border-bottom-width: 0;
overflow: hidden;
}
/* the menubar's own show/hide tab, same as the tile notches */
#topbar-notch {
position: fixed;
top: var(--topbar-h, 46px);
left: 50%;
transform: translateX(-50%);
width: 54px;
height: 14px;
padding: 0;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 10px 10px;
cursor: pointer;
z-index: 60;
opacity: 0.35;
transition: opacity 0.15s ease, top 0.22s ease;
}
#topbar-notch:hover { opacity: 1; }
#topbar-notch::after {
content: "";
display: block;
width: 20px;
height: 3px;
margin: 4px auto 0;
border-radius: 999px;
background: var(--muted);
}
#topbar-notch:hover::after { background: var(--text); }
#brand { #brand {
font-weight: 800; font-weight: 800;
@@ -207,9 +245,15 @@ button { font-family: inherit; }
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
touch-action: none; touch-action: none;
transition: height 0.22s ease, border-bottom-width 0.22s ease;
} }
.tile.dragging .tile-head { cursor: grabbing; } .tile.dragging .tile-head { cursor: grabbing; }
.tile.head-hidden .tile-head { display: none; } /* hidden = collapsed, not removed, so the notch toggle animates */
.tile.head-hidden .tile-head {
height: 0;
border-bottom-width: 0;
overflow: hidden;
}
.tile-head .dot { .tile-head .dot {
flex: none; flex: none;
@@ -305,7 +349,7 @@ button { font-family: inherit; }
cursor: pointer; cursor: pointer;
z-index: 6; z-index: 6;
opacity: 0; opacity: 0;
transition: opacity 0.15s ease, top 0.15s ease; transition: opacity 0.15s ease, top 0.22s ease;
} }
.tile:hover .tile-notch { opacity: 0.6; } .tile:hover .tile-notch { opacity: 0.6; }
.tile .tile-notch:hover { opacity: 1; } .tile .tile-notch:hover { opacity: 1; }
@@ -329,30 +373,43 @@ button { font-family: inherit; }
overflow: hidden; overflow: hidden;
} }
.tile-resize { .tile-resize,
.tile-resize-left {
position: absolute; position: absolute;
right: 0;
bottom: 0; bottom: 0;
width: 20px; width: 20px;
height: 20px; height: 20px;
cursor: nwse-resize;
touch-action: none; touch-action: none;
z-index: 5; z-index: 5;
color: var(--muted); color: var(--muted);
opacity: 0.6; opacity: 0; /* corner indicators only show while hovering the tile */
transition: opacity 0.15s ease;
} }
.tile-resize:hover { opacity: 1; color: var(--text); } .tile-resize { right: 0; cursor: nwse-resize; }
.tile-resize::after { .tile-resize-left { left: 0; cursor: nesw-resize; }
.tile:hover .tile-resize,
.tile:hover .tile-resize-left { opacity: 0.6; }
.tile .tile-resize:hover,
.tile .tile-resize-left:hover { opacity: 1; color: var(--text); }
.tile-resize::after,
.tile-resize-left::after {
content: ""; content: "";
position: absolute; position: absolute;
right: 4px;
bottom: 4px; bottom: 4px;
width: 9px; width: 9px;
height: 9px; height: 9px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor; border-bottom: 2px solid currentColor;
}
.tile-resize::after {
right: 4px;
border-right: 2px solid currentColor;
border-radius: 0 0 3px 0; border-radius: 0 0 3px 0;
} }
.tile-resize-left::after {
left: 4px;
border-left: 2px solid currentColor;
border-radius: 0 0 0 3px;
}
/* ── seam divider between adjacent tiles ───────────────────────────── */ /* ── seam divider between adjacent tiles ───────────────────────────── */
@@ -389,6 +446,16 @@ button { font-family: inherit; }
they're rebuilt for the new geometry on release. */ they're rebuilt for the new geometry on release. */
#grid.no-dividers .tile-divider { display: none; } #grid.no-dividers .tile-divider { display: none; }
/* ── fullscreen mode ───────────────────────────────────────────────── */
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
editing affordances (corner handles, seam pills) are parked. */
body.fullscreen .tile-resize,
body.fullscreen .tile-resize-left,
body.fullscreen .tile-divider { display: none; }
body.fullscreen #grid { padding-bottom: 0; }
#gridwrap.no-scroll { overflow-y: hidden; }
/* status message a module can show while degraded (also used by the shell /* status message a module can show while degraded (also used by the shell
when a module fails to load) */ when a module fails to load) */
.tile-msg { .tile-msg {
@@ -544,5 +611,5 @@ button { font-family: inherit; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
#topbar { padding: 6px 8px; flex-wrap: wrap; } #topbar { padding: 0 8px; gap: 6px; }
} }
+7 -1
View File
@@ -413,7 +413,13 @@ function applyConfigPatch(id, patch) {
} }
if (type === 'number') value = Number(value) || 0; if (type === 'number') value = Number(value) || 0;
else if (type === 'boolean') value = Boolean(value); else if (type === 'boolean') value = Boolean(value);
else value = value === undefined || value === null ? '' : String(value); else if (type === 'endpoint') {
const src = value && typeof value === 'object' ? value : {};
value = {
host: String(src.host ?? '').trim(),
port: Math.max(0, Math.min(65535, Math.trunc(Number(src.port)) || 0)),
};
} else value = value === undefined || value === null ? '' : String(value);
next[key] = value; next[key] = value;
} }
modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next }; modulesConfig[id] = { ...moduleState(id), enabled: isEnabled(id), config: next };