Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41811ead1c | ||
|
|
c11389473f | ||
|
|
c6a44e6ddc | ||
|
|
2f14b30d68 | ||
|
|
b5d39c60dc | ||
|
|
407eaecc23 | ||
|
|
b5c539ba05 |
@@ -14,6 +14,8 @@ modules/propresenter-timers/ltc-capture
|
||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||
launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
launcher/windows/build/
|
||||
launcher/windows/.cache/
|
||||
|
||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
||||
modules/spl-meter/spl-capture
|
||||
|
||||
@@ -30,9 +30,19 @@ and a clear degraded state, then recover on their own.
|
||||
|
||||
## Run it
|
||||
|
||||
Ready-made builds are on the
|
||||
[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases);
|
||||
the files themselves live in
|
||||
[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases).
|
||||
There is a disk image for Apple silicon Macs, one for Intel Macs, and a
|
||||
bundle for 64-bit Windows. Every build updates itself afterwards from the
|
||||
admin page, so the download only has to be recent, not current.
|
||||
|
||||
### macOS — the app
|
||||
|
||||
Open the disk image and drag **ProdDash** to Applications. That's the whole
|
||||
Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
|
||||
Apple silicon, `ProdDash-<version>-x86_64.dmg` for Intel — open it and drag
|
||||
**ProdDash** to Applications. That's the whole
|
||||
installation: the app carries the dashboard and its own Node runtime, so
|
||||
nothing else has to be installed. It carries no modules — open **Admin →
|
||||
Available modules**, tick the ones this machine needs and press **Install
|
||||
@@ -45,12 +55,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to
|
||||
zeroes.
|
||||
|
||||
Build the disk image from a checkout with
|
||||
[launcher/macos](launcher/macos)/`build.sh`:
|
||||
[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it
|
||||
for Intel):
|
||||
|
||||
```bash
|
||||
cd launcher/macos && ./build.sh --dmg
|
||||
```
|
||||
|
||||
### Windows — the bundle
|
||||
|
||||
Download `ProdDash-<version>-win-x64.zip`, unzip it somewhere you can write
|
||||
to — `C:\ProdDash`, say, not Program Files, because updates write into the
|
||||
folder — and double-click **ProdDash.bat**. The window it opens is the
|
||||
dashboard running: it stays open, and it starts the server again when the
|
||||
admin page applies an update. Allow `node.exe` on private networks when the
|
||||
firewall asks, so other screens can reach it. Settings and layouts live in
|
||||
`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with
|
||||
Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no
|
||||
menu-bar-style launcher for Windows yet; the bundle is built with
|
||||
[launcher/windows](launcher/windows)/`build.sh`.
|
||||
|
||||
### Anywhere else, or from a checkout
|
||||
|
||||
Requires [Node.js](https://nodejs.org) 18 or newer.
|
||||
|
||||
@@ -461,7 +461,8 @@ const menu = moduleApi.header.addMenu({
|
||||
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)
|
||||
// positioned by the shell, hosted on the body under an element carrying
|
||||
// your data-module, so your scoped CSS applies and nothing clips it)
|
||||
},
|
||||
});
|
||||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||
|
||||
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
|
||||
```bash
|
||||
./build.sh # build/ProdDash.app — the whole application
|
||||
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
||||
ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg)
|
||||
./build.sh --install # …and copy it to /Applications
|
||||
./build.sh --run # …and open it
|
||||
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
||||
|
||||
+10
-3
@@ -13,6 +13,8 @@
|
||||
# ./build.sh --dev launcher only — no Node, no server payload, so
|
||||
# it runs against the checkout. Seconds, not
|
||||
# minutes, while working on the launcher itself.
|
||||
# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple
|
||||
# silicon); the Node it embeds follows.
|
||||
#
|
||||
# Signing: a "Developer ID Application" certificate is used when one is
|
||||
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
||||
@@ -29,8 +31,13 @@ LAUNCHER_VERSION="1.0.0"
|
||||
# The Node the app ships. Bump it here; the download is checksum-verified
|
||||
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
||||
NODE_VERSION="v24.21.0"
|
||||
ARCH="arm64"
|
||||
ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
|
||||
DEPLOYMENT_TARGET="13.0"
|
||||
case "$ARCH" in
|
||||
arm64) NODE_ARCH="arm64" ;;
|
||||
x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it
|
||||
*) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
REPO_ROOT="../.."
|
||||
OUT_DIR="build"
|
||||
@@ -108,7 +115,7 @@ fi
|
||||
|
||||
# ── the server, and the Node that runs it ────────────────────────────────
|
||||
if [[ $DEV -eq 0 ]]; then
|
||||
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
|
||||
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
||||
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
||||
@@ -127,7 +134,7 @@ if [[ $DEV -eq 0 ]]; then
|
||||
echo " embedding Node $NODE_VERSION"
|
||||
mkdir -p "$CONTENTS/Resources/node/bin"
|
||||
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
||||
"node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node"
|
||||
|
||||
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||
mkdir -p "$PAYLOAD/config"
|
||||
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/bin/zsh
|
||||
# Build the Windows bundle — ProdDash-<version>-win-x64.zip: the dashboard,
|
||||
# its own Node runtime and a start script, so nothing has to be installed on
|
||||
# the Windows machine. Unzip, double-click ProdDash.bat, done. It carries no
|
||||
# modules: those come from the admin page's catalog into %APPDATA%\ProdDash.
|
||||
#
|
||||
# ./build.sh build/ProdDash-<version>-win-x64.zip
|
||||
#
|
||||
# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows
|
||||
# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and
|
||||
# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps
|
||||
# the server running and starts it again when the admin page applies an
|
||||
# update (the server exits with code 75 to ask for that).
|
||||
set -e -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APP_NAME="ProdDash"
|
||||
# The Node the bundle ships — keep in step with launcher/macos/build.sh.
|
||||
NODE_VERSION="v24.21.0"
|
||||
NODE_DIST="node-$NODE_VERSION-win-x64"
|
||||
|
||||
REPO_ROOT="../.."
|
||||
OUT_DIR="build"
|
||||
CACHE_DIR=".cache"
|
||||
|
||||
for tool in curl shasum unzip zip; do
|
||||
command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; }
|
||||
done
|
||||
|
||||
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
|
||||
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
|
||||
NAME="$APP_NAME-$VERSION-win-x64"
|
||||
STAGE="$OUT_DIR/$NAME"
|
||||
|
||||
echo "ProdDash $VERSION — building the Windows bundle"
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR"
|
||||
|
||||
# ── Node for Windows ─────────────────────────────────────────────────────
|
||||
ZIPFILE="$NODE_DIST.zip"
|
||||
if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then
|
||||
echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)"
|
||||
curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE"
|
||||
mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE"
|
||||
fi
|
||||
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
|
||||
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
|
||||
# This binary ships under ProdDash's name — check it every build.
|
||||
WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS")
|
||||
GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}')
|
||||
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
|
||||
echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " embedding Node $NODE_VERSION (node.exe and its licence)"
|
||||
unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node"
|
||||
|
||||
# ── ProdDash ─────────────────────────────────────────────────────────────
|
||||
echo " embedding ProdDash $VERSION (no modules — the catalog installs them)"
|
||||
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/"
|
||||
cp -R "$REPO_ROOT/public" "$STAGE/"
|
||||
# Only the checked-in defaults: config/ also holds this machine's own
|
||||
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||
cp "$REPO_ROOT/config/proddash.json" "$STAGE/config/"
|
||||
find "$STAGE" -name '.DS_Store' -delete
|
||||
|
||||
# ── the start script and the read-me, with Windows line endings ──────────
|
||||
crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; }
|
||||
|
||||
crlf "$STAGE/ProdDash.bat" <<'BAT'
|
||||
@echo off
|
||||
rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard.
|
||||
title ProdDash
|
||||
cd /d "%~dp0"
|
||||
rem The server exits with code 75 after applying an update from the admin page; start it again.
|
||||
set PRODDASH_LAUNCHER=1
|
||||
:run
|
||||
"%~dp0node\node.exe" server.js
|
||||
set CODE=%ERRORLEVEL%
|
||||
if %CODE% EQU 75 goto run
|
||||
echo.
|
||||
echo ProdDash stopped (exit code %CODE%). Press any key to close this window.
|
||||
pause >nul
|
||||
BAT
|
||||
|
||||
crlf "$STAGE/README.txt" <<README
|
||||
ProdDash $VERSION for Windows (64-bit)
|
||||
=======================================
|
||||
|
||||
Nothing to install. This folder holds the dashboard and its own copy of Node.
|
||||
|
||||
1. Move this folder somewhere you can write to, for example C:\ProdDash
|
||||
(not Program Files: updates write into this folder).
|
||||
2. Double-click ProdDash.bat. A window opens and stays open while the
|
||||
dashboard runs; closing it stops the dashboard.
|
||||
- If Windows warns about the file, choose More info, then Run anyway.
|
||||
- When Windows Defender Firewall asks about node.exe, allow it on
|
||||
private networks so other screens can reach the dashboard.
|
||||
3. Open http://localhost:24500 on this PC. Other devices on the network use
|
||||
http://<this PC's IP address>:24500. Settings are on the admin page,
|
||||
http://localhost:24500/admin.
|
||||
4. The bundle ships without modules. On the admin page, tick the ones this
|
||||
PC needs under Available modules and press Install selected; each one
|
||||
updates on its own from then on.
|
||||
|
||||
Your settings and layouts live in %APPDATA%\ProdDash, not in this folder,
|
||||
so unzipping a new version or updating never loses them.
|
||||
|
||||
Start with Windows: press Win+R, type shell:startup, press Enter, and put a
|
||||
shortcut to ProdDash.bat in the folder that opens.
|
||||
|
||||
Updates: the admin page's Update now fetches the new version into this
|
||||
folder and restarts the dashboard; the window stays open. A copy of the
|
||||
version it replaced is kept for a short while in case the new one will not
|
||||
start.
|
||||
README
|
||||
|
||||
# ── zip ──────────────────────────────────────────────────────────────────
|
||||
echo " zipping"
|
||||
(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME")
|
||||
echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))"
|
||||
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
|
||||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||
applyInstanceSettings();
|
||||
}
|
||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||
|
||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||
const flowBtn = moduleApi.header.addButton({
|
||||
@@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) {
|
||||
},
|
||||
});
|
||||
|
||||
moduleApi.header.addButton({
|
||||
const latestBtn = moduleApi.header.addButton({
|
||||
icon: JUMP_SVG,
|
||||
title: 'Latest',
|
||||
onClick() {
|
||||
@@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) {
|
||||
});
|
||||
clearBtn.classList.add('pt-clear');
|
||||
|
||||
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
|
||||
Seven controls need ~175 px, and the shell clips whatever the bar
|
||||
cannot fit — on a 3-column tile among many, or one squeezed by the
|
||||
fullscreen fit, that used to take the channel filter with it. Below
|
||||
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
|
||||
the same actions, so the filter button is the last thing to go. */
|
||||
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
|
||||
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
|
||||
|
||||
let moreMenuEl = null;
|
||||
const moreMenu = moduleApi.header.addMenu({
|
||||
icon: MORE_SVG,
|
||||
title: 'More',
|
||||
build(menu) {
|
||||
menu.classList.add('pt-menu');
|
||||
moreMenuEl = menu;
|
||||
rebuildMoreMenu();
|
||||
},
|
||||
});
|
||||
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
|
||||
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
|
||||
|
||||
function addMoreItem(label, { on = null, onClick } = {}) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||
if (on !== null) {
|
||||
item.setAttribute('role', 'menuitemcheckbox');
|
||||
item.setAttribute('aria-checked', String(on));
|
||||
} else {
|
||||
item.setAttribute('role', 'menuitem');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'check';
|
||||
check.textContent = '✓';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'name';
|
||||
name.textContent = label;
|
||||
item.append(check, name);
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
|
||||
onClick();
|
||||
});
|
||||
moreMenuEl.appendChild(item);
|
||||
}
|
||||
|
||||
function rebuildMoreMenu() {
|
||||
if (!moreMenuEl) return;
|
||||
moreMenuEl.innerHTML = '';
|
||||
const s = moduleApi.instanceSettings;
|
||||
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
|
||||
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'pt-menu-divider';
|
||||
moreMenuEl.appendChild(divider);
|
||||
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
|
||||
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
|
||||
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
|
||||
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
|
||||
}
|
||||
|
||||
let compact = null; // unknown until the first measurement
|
||||
function applyCompact(width) {
|
||||
const next = width > 0 && width < COMPACT_PX;
|
||||
if (next === compact) return;
|
||||
compact = next;
|
||||
for (const btn of secondaryBtns) btn.hidden = compact;
|
||||
moreWrap.hidden = !compact;
|
||||
if (!compact) moreMenu.close();
|
||||
}
|
||||
// the tile's own body tells the width; onResize only knows grid cells
|
||||
const sizeWatch = new ResizeObserver((entries) => {
|
||||
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
|
||||
});
|
||||
sizeWatch.observe(root);
|
||||
applyCompact(root.clientWidth);
|
||||
|
||||
function applyInstanceSettings() {
|
||||
const s = moduleApi.instanceSettings;
|
||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||
@@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) {
|
||||
flowBtn.classList.toggle('active', up);
|
||||
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||
rebuildMoreMenu(); // its checkmarks mirror the buttons
|
||||
}
|
||||
applyInstanceSettings();
|
||||
|
||||
@@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) {
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
sizeWatch.disconnect();
|
||||
stream?.close();
|
||||
root.innerHTML = ''; // header controls are removed by the shell
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProdCom transcript",
|
||||
"permissions": [
|
||||
|
||||
@@ -20,9 +20,13 @@
|
||||
}
|
||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||
|
||||
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
|
||||
[data-module="prodcom-transcript"] .tile-btn[hidden],
|
||||
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
|
||||
|
||||
/* ── channels menu ──
|
||||
The menu container is created and positioned by the shell (.tile-menu);
|
||||
it lives inside the tile, so [data-module] scoping still applies. */
|
||||
it hangs off the body under a [data-module] host, so scoping still applies. */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||
display: flex;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
+41
-10
@@ -640,8 +640,10 @@ function buildTile(tile) {
|
||||
el.addEventListener('pointermove', (ev) => {
|
||||
if (!headIsOff(tile)) return;
|
||||
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||
// — neither is a moment to take the bar away.
|
||||
// — neither is a moment to take the bar away. Nor is an open header
|
||||
// menu, which hangs below the bar and is about to be pointed at.
|
||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||
if (el.classList.contains('menu-open')) return;
|
||||
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||
const shown = el.classList.contains('head-peek');
|
||||
// It takes less to hold the bar open than to open it, so a bar that
|
||||
@@ -652,6 +654,7 @@ function buildTile(tile) {
|
||||
|
||||
el.addEventListener('pointerleave', () => {
|
||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
|
||||
el.classList.remove('head-peek');
|
||||
});
|
||||
|
||||
@@ -659,7 +662,7 @@ function buildTile(tile) {
|
||||
applyHeadState(tile, el);
|
||||
grid.appendChild(el);
|
||||
|
||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
||||
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
|
||||
tileEls.set(tile.id, entry);
|
||||
applyRect(tile);
|
||||
|
||||
@@ -1426,34 +1429,60 @@ function buildModuleApi(tile, man, entry) {
|
||||
menu.className = 'tile-menu';
|
||||
menu.hidden = true;
|
||||
|
||||
/* The menu lives on the body, not in the bar. A folded-away bar
|
||||
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
|
||||
and either one makes a fixed-positioned descendant position
|
||||
itself against the bar instead of the viewport — the menu landed
|
||||
a screen-height below its button, inside the tile's overflow, and
|
||||
never showed. Its host carries the tile's data-module so the
|
||||
module's scoped CSS still reaches the items. */
|
||||
const host = document.createElement('div');
|
||||
host.className = 'tile-menu-host';
|
||||
if (man) host.dataset.module = man.id;
|
||||
host.appendChild(menu);
|
||||
|
||||
const setOpen = (on) => {
|
||||
if (on === !menu.hidden) return;
|
||||
menu.hidden = !on;
|
||||
btn.classList.toggle('is-open', on);
|
||||
if (on) entry.openMenus.add(menu);
|
||||
else entry.openMenus.delete(menu);
|
||||
// an open menu holds a folded-away bar out; when the last one
|
||||
// closes with the pointer elsewhere, the bar goes back
|
||||
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
|
||||
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
|
||||
};
|
||||
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
|
||||
setOpen(true);
|
||||
// under the button, right-aligned, kept whole inside the viewport
|
||||
const r = btn.getBoundingClientRect();
|
||||
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
|
||||
menu.style.left = '0px';
|
||||
menu.style.top = '0px';
|
||||
const w = menu.offsetWidth;
|
||||
const h = menu.offsetHeight;
|
||||
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
|
||||
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;
|
||||
else setOpen(false);
|
||||
});
|
||||
const onDocClick = (e) => {
|
||||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
|
||||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('click', onDocClick);
|
||||
docListeners.add(onDocClick);
|
||||
|
||||
wrap.append(btn, menu);
|
||||
wrap.append(btn);
|
||||
entry.controls.appendChild(wrap);
|
||||
document.body.appendChild(host);
|
||||
headerEls.add(wrap);
|
||||
headerEls.add(host);
|
||||
return {
|
||||
button: btn,
|
||||
menu,
|
||||
@@ -1461,7 +1490,7 @@ function buildModuleApi(tile, man, entry) {
|
||||
btn.textContent = text;
|
||||
},
|
||||
close() {
|
||||
menu.hidden = true;
|
||||
setOpen(false);
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -1473,6 +1502,8 @@ function buildModuleApi(tile, man, entry) {
|
||||
for (const handle of [...sseHandles]) handle.close();
|
||||
for (const el of headerEls) el.remove();
|
||||
headerEls.clear();
|
||||
entry.openMenus.clear();
|
||||
entry.el.classList.remove('menu-open');
|
||||
for (const fn of docListeners) document.removeEventListener('click', fn);
|
||||
docListeners.clear();
|
||||
},
|
||||
|
||||
+3
-2
@@ -445,8 +445,9 @@ body.menu-hidden #topbar {
|
||||
}
|
||||
.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 */
|
||||
/* a module header menu — hosted on the body (shell.js addMenu) so neither
|
||||
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
|
||||
.tile-menu-host { display: contents; }
|
||||
.tile-menu {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
|
||||
Reference in New Issue
Block a user