Icon topbar, module title-bar controls, hideable title bars, renames
Topbar: the add button is now just + and the layout button a 3-segment grid icon (two squares + one tall rectangle). Modules no longer build their own toolbars — moduleApi.header.addButton/addMenu put controls in the tile's title bar next to the gear/close (menus are fixed-positioned by the shell so a tile's overflow can't clip them, and everything a module adds is removed automatically on unmount). Each title bar grows a notch on its bottom edge that hides/shows the bar, ProdCom-Listener style; the choice persists with the layout. Modules renamed: ProdCom and ProPresenter. The ProdCom tile regains the original listener tools as icon buttons in its title bar: timestamps toggle (clock), A−/A+ text size, jump-to-latest, and a circle-with-cross clear button, plus the Channels menu. MODULE-GUIDE documents the header API; README updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
@@ -60,29 +60,33 @@ are written by the app itself.
|
|||||||
|
|
||||||
## Using the dashboard
|
## Using the dashboard
|
||||||
|
|
||||||
- **+ Add tile** — add any enabled module; multiple tiles of the same
|
- **+** — add any enabled module; multiple tiles of the same module are
|
||||||
module are fine (e.g. two transcripts filtered to different channels).
|
fine (e.g. two transcripts filtered to different channels).
|
||||||
- Drag a tile by its **header** to move it; drag the **corner handle** to
|
- Drag a tile by its **title bar** to move it; drag the **corner handle**
|
||||||
resize; **✕** removes it. Tiles sit flush — when two share an edge, hover
|
to resize; **✕** removes it. Tiles sit flush — when two share an edge,
|
||||||
the seam and a small grab pill appears; drag it to trade width between
|
hover the seam and a small grab pill appears; drag it to trade width
|
||||||
the two tiles (macOS split-view style).
|
between the two tiles (macOS split-view style).
|
||||||
|
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
||||||
|
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
||||||
|
for a clean wall-display look.
|
||||||
- The **gear** on a tile holds per-tile settings (filters, text size,
|
- The **gear** on a tile holds per-tile settings (filters, text size,
|
||||||
display options) — these belong to this browser's layout, not the server.
|
display options) — these belong to this browser's layout, not the server.
|
||||||
- **Layout ▾** — save the current arrangement to the server under a name,
|
- The **grid icon** — save the current arrangement to the server under a
|
||||||
load a named layout (it becomes a local copy — browsers are never linked),
|
name, load a named layout (it becomes a local copy — browsers are never
|
||||||
or reset this browser's dashboard.
|
linked), or reset this browser's dashboard.
|
||||||
- Every tile has a **status dot**: green = live, amber = connecting,
|
- Every tile has a **status dot**: green = live, amber = connecting,
|
||||||
red = upstream unreachable (hover it for details). Tiles reconnect
|
red = upstream unreachable (hover it for details). Tiles reconnect
|
||||||
automatically.
|
automatically.
|
||||||
|
|
||||||
## Included modules
|
## Included modules
|
||||||
|
|
||||||
- **ProdCom Transcript** — live transcript stream with in-place updates of
|
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||||
in-progress speech, channel colors, per-tile channel visibility toggles
|
speech, channel colors, per-tile channel visibility toggles and group
|
||||||
and group presets. Admin config: ProdCom URL, API key.
|
presets, timestamps, text size and jump-to-latest in the title bar.
|
||||||
- **ProPresenter Now / Next** — what's live and what's next, with slide
|
Admin config: ProdCom URL, API key.
|
||||||
progress, driven by the shared `propresenter-core` client (ProPresenter
|
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||||
7/20). Admin config: host, port, password.
|
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||||
|
Admin config: 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.
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,43 @@ export default function create({ root, moduleApi }) {
|
|||||||
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
|
| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules/<id>/state`. |
|
||||||
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
|
| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. |
|
||||||
| `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. |
|
| `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. |
|
||||||
|
| `header.addButton(…)` | Put a small button in the tile's own title bar (see below). |
|
||||||
|
| `header.addMenu(…)` | Put a dropdown menu button there (see below). |
|
||||||
|
|
||||||
|
### Title-bar controls
|
||||||
|
|
||||||
|
Modules don't build their own toolbars — actions live in the tile's title
|
||||||
|
bar, next to the shell's gear/close buttons:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const btn = moduleApi.header.addButton({
|
||||||
|
icon: '<svg …>…</svg>', // inline SVG string, and/or
|
||||||
|
label: 'A+', // short text
|
||||||
|
title: 'Larger text', // hover tooltip
|
||||||
|
onClick() { … },
|
||||||
|
});
|
||||||
|
btn.classList.add('active'); // toggle styling for on/off buttons
|
||||||
|
|
||||||
|
const menu = moduleApi.header.addMenu({
|
||||||
|
label: 'Channels ▾',
|
||||||
|
title: 'Choose visible channels',
|
||||||
|
build(menuEl) {
|
||||||
|
// called on every open with an emptied menu element — fill it with
|
||||||
|
// buttons (give them your scoped classes; the container is styled and
|
||||||
|
// fixed-positioned by the shell, so the tile's overflow can't clip it)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything you add is removed automatically when your instance stops, so a
|
||||||
|
remount never duplicates controls. Keep them compact (icon buttons, short
|
||||||
|
labels): the title bar is 32 px tall and shared with the tile's name.
|
||||||
|
|
||||||
|
One more thing to know: the user can hide the whole title bar with the
|
||||||
|
small notch on its bottom edge (a clean-view mode for wall displays). Your
|
||||||
|
controls disappear with it, so nothing essential — status, reconnects,
|
||||||
|
live data — may exist *only* as a header control.
|
||||||
|
|
||||||
`sse(path, handlers)` handlers:
|
`sse(path, handlers)` handlers:
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="pt-wrap">
|
<div class="pt-wrap">
|
||||||
<div class="pt-bar">
|
|
||||||
<div class="pt-dd">
|
|
||||||
<button class="pt-btn pt-channels-btn" title="Choose visible channels">Channels ▾</button>
|
|
||||||
<div class="pt-menu" hidden></div>
|
|
||||||
</div>
|
|
||||||
<span class="pt-spacer"></span>
|
|
||||||
<button class="pt-btn pt-clear" title="Clear this tile (does not affect ProdCom)">Clear</button>
|
|
||||||
</div>
|
|
||||||
<div class="pt-stream">
|
<div class="pt-stream">
|
||||||
<div class="pt-empty">Waiting for transcript…</div>
|
<div class="pt-empty">Waiting for transcript…</div>
|
||||||
<div class="pt-entries"></div>
|
<div class="pt-entries"></div>
|
||||||
@@ -42,14 +34,66 @@ export default function create({ root, moduleApi }) {
|
|||||||
const entriesEl = root.querySelector('.pt-entries');
|
const entriesEl = root.querySelector('.pt-entries');
|
||||||
const emptyEl = root.querySelector('.pt-empty');
|
const emptyEl = root.querySelector('.pt-empty');
|
||||||
const jumpBtn = root.querySelector('.pt-jump');
|
const jumpBtn = root.querySelector('.pt-jump');
|
||||||
const channelBtn = root.querySelector('.pt-channels-btn');
|
|
||||||
const channelMenu = root.querySelector('.pt-menu');
|
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||||
|
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
|
||||||
|
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
|
||||||
|
|
||||||
|
let channelMenuEl = null; // the open menu's element (filled on each open)
|
||||||
|
const channelsMenu = moduleApi.header.addMenu({
|
||||||
|
label: 'Channels ▾',
|
||||||
|
title: 'Choose visible channels',
|
||||||
|
build(menu) {
|
||||||
|
menu.classList.add('pt-menu');
|
||||||
|
channelMenuEl = menu;
|
||||||
|
rebuildChannelMenu();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
channelsMenu.button.classList.add('pt-channels-btn');
|
||||||
|
|
||||||
|
let timeBtn = null;
|
||||||
|
timeBtn = moduleApi.header.addButton({
|
||||||
|
icon: CLOCK_SVG,
|
||||||
|
title: 'Show / hide timestamps',
|
||||||
|
onClick() {
|
||||||
|
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
|
||||||
|
applyInstanceSettings();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function bumpTextSize(delta) {
|
||||||
|
const current = Number(moduleApi.instanceSettings.textSize) || 15;
|
||||||
|
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||||
|
applyInstanceSettings();
|
||||||
|
}
|
||||||
|
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||||
|
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||||
|
|
||||||
|
moduleApi.header.addButton({
|
||||||
|
icon: JUMP_SVG,
|
||||||
|
title: 'Jump to latest',
|
||||||
|
onClick() {
|
||||||
|
pinnedToBottom = true;
|
||||||
|
jumpBtn.hidden = true;
|
||||||
|
scrollToBottom();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearBtn = moduleApi.header.addButton({
|
||||||
|
icon: CLEAR_SVG,
|
||||||
|
title: 'Clear this tile (does not affect ProdCom)',
|
||||||
|
onClick: clearView,
|
||||||
|
});
|
||||||
|
clearBtn.classList.add('pt-clear');
|
||||||
|
|
||||||
function applyInstanceSettings() {
|
function applyInstanceSettings() {
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||||
wrap.style.setProperty('--pt-size', size + 'px');
|
wrap.style.setProperty('--pt-size', size + 'px');
|
||||||
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
||||||
|
timeBtn?.classList.toggle('active', Boolean(s.showTimes));
|
||||||
}
|
}
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
|
|
||||||
@@ -106,7 +150,20 @@ export default function create({ root, moduleApi }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateChannelsLabel() {
|
||||||
|
let visibleCount = 0;
|
||||||
|
for (const id of channels.keys()) {
|
||||||
|
if (!hiddenChannels.has(id)) visibleCount++;
|
||||||
|
}
|
||||||
|
channelsMenu.setLabel(visibleCount === channels.size
|
||||||
|
? 'Channels ▾'
|
||||||
|
: `Channels (${visibleCount}/${channels.size}) ▾`);
|
||||||
|
}
|
||||||
|
|
||||||
function rebuildChannelMenu() {
|
function rebuildChannelMenu() {
|
||||||
|
updateChannelsLabel();
|
||||||
|
const channelMenu = channelMenuEl;
|
||||||
|
if (!channelMenu) return; // menu not opened yet — the label is enough
|
||||||
channelMenu.innerHTML = '';
|
channelMenu.innerHTML = '';
|
||||||
|
|
||||||
if (groups.size) {
|
if (groups.size) {
|
||||||
@@ -134,10 +191,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
channelMenu.appendChild(divider);
|
channelMenu.appendChild(divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
let visibleCount = 0;
|
|
||||||
for (const [id, info] of channels) {
|
for (const [id, info] of channels) {
|
||||||
const on = !hiddenChannels.has(id);
|
const on = !hiddenChannels.has(id);
|
||||||
if (on) visibleCount++;
|
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||||
item.setAttribute('role', 'menuitemcheckbox');
|
item.setAttribute('role', 'menuitemcheckbox');
|
||||||
@@ -158,27 +213,15 @@ export default function create({ root, moduleApi }) {
|
|||||||
});
|
});
|
||||||
channelMenu.appendChild(item);
|
channelMenu.appendChild(item);
|
||||||
}
|
}
|
||||||
channelBtn.textContent = visibleCount === channels.size
|
|
||||||
? 'Channels ▾'
|
|
||||||
: `Channels (${visibleCount}/${channels.size}) ▾`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
channelBtn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
channelMenu.hidden = !channelMenu.hidden;
|
|
||||||
});
|
|
||||||
const onDocClick = (e) => {
|
|
||||||
if (!channelMenu.hidden && !e.target.closest('.pt-dd')) channelMenu.hidden = true;
|
|
||||||
};
|
|
||||||
document.addEventListener('click', onDocClick);
|
|
||||||
|
|
||||||
/* ── clear (local view only) ────────────────────────────────────── */
|
/* ── clear (local view only) ────────────────────────────────────── */
|
||||||
|
|
||||||
root.querySelector('.pt-clear').addEventListener('click', () => {
|
function clearView() {
|
||||||
entryEls.forEach((el) => el.remove());
|
entryEls.forEach((el) => el.remove());
|
||||||
entryEls.clear();
|
entryEls.clear();
|
||||||
emptyEl.hidden = false;
|
emptyEl.hidden = false;
|
||||||
});
|
}
|
||||||
|
|
||||||
/* ── scrolling ──────────────────────────────────────────────────── */
|
/* ── scrolling ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -393,8 +436,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
stopped = true;
|
stopped = true;
|
||||||
clearTimeout(bootTimer);
|
clearTimeout(bootTimer);
|
||||||
stream?.close();
|
stream?.close();
|
||||||
document.removeEventListener('click', onDocClick);
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
root.innerHTML = '';
|
|
||||||
},
|
},
|
||||||
onResize() {
|
onResize() {
|
||||||
if (pinnedToBottom) scrollToBottom();
|
if (pinnedToBottom) scrollToBottom();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom Transcript",
|
"name": "ProdCom",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Live ProdCom transcript stream",
|
"description": "Live ProdCom transcript stream",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
|
|||||||
@@ -9,49 +9,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── toolbar ── */
|
/* ── channels menu ──
|
||||||
|
The menu container is created and positioned by the shell (.tile-menu);
|
||||||
[data-module="prodcom-transcript"] .pt-bar {
|
it lives inside the tile, so [data-module] scoping still applies. */
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 5px 8px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-spacer { flex: 1; }
|
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-btn {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
[data-module="prodcom-transcript"] .pt-btn:hover { border-color: var(--muted); }
|
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-dd { position: relative; }
|
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 5px);
|
|
||||||
left: 0;
|
|
||||||
z-index: 30;
|
|
||||||
min-width: 170px;
|
|
||||||
max-height: 45vh;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
[data-module="prodcom-transcript"] .pt-menu[hidden] { display: none; }
|
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-now-next",
|
"id": "propresenter-now-next",
|
||||||
"name": "ProPresenter Now / Next",
|
"name": "ProPresenter",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "What is live in ProPresenter and what comes next",
|
"description": "What is live in ProPresenter and what comes next",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
|
|||||||
+8
-2
@@ -20,12 +20,18 @@
|
|||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
<div class="dropdown" id="add-dd">
|
<div class="dropdown" id="add-dd">
|
||||||
<button class="tb-btn" id="add-btn" title="Add a module tile">+ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dropdown" id="layout-dd">
|
<div class="dropdown" id="layout-dd">
|
||||||
<button class="tb-btn" id="layout-btn" title="Save, load or reset layouts">Layout ▾</button>
|
<button class="tb-btn icon" id="layout-btn" title="Save, load or reset layouts" aria-label="Layouts">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="8" height="8" rx="1.5"/>
|
||||||
|
<rect x="3" y="13" width="8" height="8" rx="1.5"/>
|
||||||
|
<rect x="13" y="3" width="8" height="18" rx="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div class="menu" id="layout-menu" hidden></div>
|
<div class="menu" id="layout-menu" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+111
-5
@@ -150,6 +150,7 @@ function sanitizeTile(t) {
|
|||||||
w,
|
w,
|
||||||
h: Math.max(1, Number(t.h) || 2),
|
h: Math.max(1, Number(t.h) || 2),
|
||||||
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
||||||
|
headHidden: Boolean(t.headHidden),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +230,11 @@ function buildTile(tile) {
|
|||||||
title.className = 'tile-title';
|
title.className = 'tile-title';
|
||||||
title.textContent = man ? man.name : tile.module + ' (not installed)';
|
title.textContent = man ? man.name : tile.module + ' (not installed)';
|
||||||
|
|
||||||
head.append(dot, title);
|
// where moduleApi.header puts a module's own buttons and menus
|
||||||
|
const controls = document.createElement('span');
|
||||||
|
controls.className = 'tile-controls';
|
||||||
|
|
||||||
|
head.append(dot, title, controls);
|
||||||
|
|
||||||
const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length;
|
const hasSettings = man && man.instanceSchema && Object.keys(man.instanceSchema).length;
|
||||||
if (hasSettings) {
|
if (hasSettings) {
|
||||||
@@ -261,10 +266,22 @@ function buildTile(tile) {
|
|||||||
resize.className = 'tile-resize';
|
resize.className = 'tile-resize';
|
||||||
resize.title = 'Drag to resize';
|
resize.title = 'Drag to resize';
|
||||||
|
|
||||||
el.append(head, body, resize);
|
// notch on the title bar's bottom edge: hides/shows the bar
|
||||||
|
const notch = document.createElement('button');
|
||||||
|
notch.className = 'tile-notch';
|
||||||
|
notch.title = 'Hide / show the title bar';
|
||||||
|
notch.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
tile.headHidden = !tile.headHidden;
|
||||||
|
el.classList.toggle('head-hidden', tile.headHidden);
|
||||||
|
saveLayout();
|
||||||
|
});
|
||||||
|
|
||||||
|
el.append(head, body, resize, notch);
|
||||||
|
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
const entry = { el, body, dot, titleEl: title, instance: null };
|
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
||||||
tileEls.set(tile.id, entry);
|
tileEls.set(tile.id, entry);
|
||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
|
|
||||||
@@ -283,7 +300,7 @@ function refreshEmptyState() {
|
|||||||
|
|
||||||
function wireDrag(tile, el, handle) {
|
function wireDrag(tile, el, handle) {
|
||||||
handle.addEventListener('pointerdown', (ev) => {
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.target.closest('.tile-btn')) 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;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
closeSettingsPopovers();
|
closeSettingsPopovers();
|
||||||
@@ -555,6 +572,8 @@ function tileMessage(entry, text) {
|
|||||||
*/
|
*/
|
||||||
function buildModuleApi(tile, man, entry) {
|
function buildModuleApi(tile, man, entry) {
|
||||||
const sseHandles = new Set();
|
const sseHandles = new Set();
|
||||||
|
const headerEls = new Set(); // controls this instance added to the title bar
|
||||||
|
const docListeners = new Set(); // outside-click closers for header menus
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
id: man.id,
|
id: man.id,
|
||||||
@@ -636,9 +655,96 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
entry.dot.title = msg;
|
entry.dot.title = msg;
|
||||||
},
|
},
|
||||||
|
|
||||||
/* shell-internal: safety net so a forgotten stream can't outlive the tile */
|
/**
|
||||||
|
* The tile's title bar. Controls added here live next to the gear/close
|
||||||
|
* buttons and are removed automatically when the instance stops. The
|
||||||
|
* whole bar can be hidden by the user via the notch, so nothing
|
||||||
|
* essential should exist ONLY as a header control.
|
||||||
|
*/
|
||||||
|
header: {
|
||||||
|
/** A small button. icon = inline SVG string, label = short text (either or both). */
|
||||||
|
addButton({ icon = '', label = '', title = '', onClick } = {}) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'tile-btn module-btn';
|
||||||
|
btn.title = title || label;
|
||||||
|
if (icon) btn.innerHTML = icon;
|
||||||
|
if (label) btn.appendChild(document.createTextNode(label));
|
||||||
|
if (typeof onClick === 'function') {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
entry.controls.appendChild(btn);
|
||||||
|
headerEls.add(btn);
|
||||||
|
return btn;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dropdown menu button. build(menuEl) runs on every open and fills
|
||||||
|
* the (emptied) menu element. Returns { button, menu, setLabel, close }.
|
||||||
|
*/
|
||||||
|
addMenu({ icon = '', label = '', title = '', build } = {}) {
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'tile-dd';
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'tile-btn module-btn';
|
||||||
|
btn.title = title || label;
|
||||||
|
if (icon) btn.innerHTML = icon;
|
||||||
|
if (label) btn.appendChild(document.createTextNode(label));
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'tile-menu';
|
||||||
|
menu.hidden = true;
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
if (typeof build === 'function') {
|
||||||
|
menu.innerHTML = '';
|
||||||
|
build(menu);
|
||||||
|
}
|
||||||
|
menu.hidden = false;
|
||||||
|
// fixed positioning (escapes the tile's overflow): under the
|
||||||
|
// button, right-aligned, clamped to the viewport
|
||||||
|
const r = btn.getBoundingClientRect();
|
||||||
|
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
|
||||||
|
menu.style.left = '0px';
|
||||||
|
const w = menu.offsetWidth;
|
||||||
|
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
||||||
|
};
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (menu.hidden) open();
|
||||||
|
else menu.hidden = true;
|
||||||
|
});
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onDocClick);
|
||||||
|
docListeners.add(onDocClick);
|
||||||
|
|
||||||
|
wrap.append(btn, menu);
|
||||||
|
entry.controls.appendChild(wrap);
|
||||||
|
headerEls.add(wrap);
|
||||||
|
return {
|
||||||
|
button: btn,
|
||||||
|
menu,
|
||||||
|
setLabel(text) {
|
||||||
|
btn.textContent = text;
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
menu.hidden = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* shell-internal: safety net so a forgotten stream, header control or
|
||||||
|
listener can't outlive the tile */
|
||||||
_closeAll() {
|
_closeAll() {
|
||||||
for (const handle of [...sseHandles]) handle.close();
|
for (const handle of [...sseHandles]) handle.close();
|
||||||
|
for (const el of headerEls) el.remove();
|
||||||
|
headerEls.clear();
|
||||||
|
for (const fn of docListeners) document.removeEventListener('click', fn);
|
||||||
|
docListeners.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return api;
|
return api;
|
||||||
|
|||||||
+80
-2
@@ -71,6 +71,14 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
.tb-btn:hover { border-color: var(--muted); }
|
.tb-btn:hover { border-color: var(--muted); }
|
||||||
.tb-btn:active { background: var(--border); }
|
.tb-btn:active { background: var(--border); }
|
||||||
|
.tb-btn.icon {
|
||||||
|
padding: 6px 8px;
|
||||||
|
min-width: 30px;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.tb-btn.icon svg { display: block; }
|
||||||
|
|
||||||
/* ── dropdown menus ────────────────────────────────────────────────── */
|
/* ── dropdown menus ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -191,7 +199,8 @@ button { font-family: inherit; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 5px 8px;
|
height: 32px; /* fixed so the notch below can sit at a known offset */
|
||||||
|
padding: 0 8px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
@@ -200,6 +209,7 @@ button { font-family: inherit; }
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
.tile.dragging .tile-head { cursor: grabbing; }
|
.tile.dragging .tile-head { cursor: grabbing; }
|
||||||
|
.tile.head-hidden .tile-head { display: none; }
|
||||||
|
|
||||||
.tile-head .dot {
|
.tile-head .dot {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -241,8 +251,76 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
.tile-btn:hover { background: var(--border); color: var(--text); }
|
.tile-btn:hover { background: var(--border); color: var(--text); }
|
||||||
.tile-btn.close:hover { color: var(--danger); }
|
.tile-btn.close:hover { color: var(--danger); }
|
||||||
|
.tile-btn.active { color: var(--accent); }
|
||||||
.tile-btn svg { width: 13px; height: 13px; display: block; }
|
.tile-btn svg { width: 13px; height: 13px; display: block; }
|
||||||
|
|
||||||
|
/* controls a module adds to its own title bar (moduleApi.header) */
|
||||||
|
.tile-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tile-btn.module-btn {
|
||||||
|
width: auto;
|
||||||
|
min-width: 22px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tile-dd { position: relative; display: inline-flex; }
|
||||||
|
|
||||||
|
/* a module header menu — fixed-positioned by the shell so the tile's
|
||||||
|
overflow:hidden can't clip it */
|
||||||
|
.tile-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 70;
|
||||||
|
min-width: 180px;
|
||||||
|
max-height: 60vh;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.tile-menu[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* notch on the title bar's bottom edge: click to hide/show the bar
|
||||||
|
(same idea as the ProdCom Listener's pull-down menubar handle) */
|
||||||
|
.tile-notch {
|
||||||
|
position: absolute;
|
||||||
|
top: 32px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 46px;
|
||||||
|
height: 13px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 6;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, top 0.15s ease;
|
||||||
|
}
|
||||||
|
.tile:hover .tile-notch { opacity: 0.6; }
|
||||||
|
.tile .tile-notch:hover { opacity: 1; }
|
||||||
|
.tile.head-hidden .tile-notch { top: 0; }
|
||||||
|
.tile-notch::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 3px;
|
||||||
|
margin: 4px auto 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
.tile-notch:hover::after { background: var(--text); }
|
||||||
|
|
||||||
.tile-body {
|
.tile-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -331,7 +409,7 @@ button { font-family: inherit; }
|
|||||||
|
|
||||||
.tile-settings {
|
.tile-settings {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 30px;
|
top: 36px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
z-index: 45;
|
z-index: 45;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
|
|||||||
Reference in New Issue
Block a user