Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb4bfd860 | ||
|
|
52d700df3d | ||
|
|
6cca6d4d1a | ||
|
|
1be5cbef31 | ||
|
|
1c8cd63cd0 | ||
|
|
af6322d079 | ||
|
|
168f717398 | ||
|
|
67bcd3640a | ||
|
|
095fe66118 | ||
|
|
a3e1282354 | ||
|
|
6417c3aa54 | ||
|
|
c1d9fa0527 |
@@ -44,7 +44,7 @@ title-bar controls). The rules that matter most:
|
|||||||
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
|
- `prodcom-listener/` — live ProdCom transcript web app. Zero-dependency Node
|
||||||
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
|
`server.js` that serves `public/` and proxies `/prodcom/*` (including the SSE
|
||||||
transcript stream) to the ProdCom API at `config.json → prodcomUrl`
|
transcript stream) to the ProdCom API at `config.json → prodcomUrl`
|
||||||
(default `http://10.3.11.152:24480`). Good reference for: SSE handling,
|
(set per site; each campus runs its own). Good reference for: SSE handling,
|
||||||
reconnect logic, channel colors/filtering, PWA install.
|
reconnect logic, channel colors/filtering, PWA install.
|
||||||
- `band-lineup-display/` — backstage Electron app (stage lineup photos with
|
- `band-lineup-display/` — backstage Electron app (stage lineup photos with
|
||||||
mic/pack labels) plus ProPresenter Now/Next:
|
mic/pack labels) plus ProPresenter Now/Next:
|
||||||
|
|||||||
@@ -100,10 +100,24 @@ with this machine's shell settings — the same keys as the checked-in
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
|
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
|
||||||
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
||||||
|
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
|
||||||
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||||
|
|
||||||
|
**Updating from your own git server.** ProdDash fetches one `tar.gz` of the
|
||||||
|
branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its
|
||||||
|
base URL and `repo` at the owner/repo there:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "forge": "http://10.3.8.90:3000", "repo": "michael/ProdDash", "branch": "main" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitea spells an archive URL differently from GitHub, which is the only thing
|
||||||
|
that changes; the tarball itself is identical, commit hash included, so update
|
||||||
|
detection and module installs behave exactly as before. Make the repository
|
||||||
|
readable without signing in, or ProdDash cannot fetch it.
|
||||||
|
|
||||||
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||||
`PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
`PRODDASH_FORGE`, and `PRODDASH_DATA_DIR` to put the data directory somewhere else (a relative path
|
||||||
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
|
is taken from the app folder; `PRODDASH_DATA_DIR=config` restores the old
|
||||||
in-repo location). If the default directory can't be created, the server says
|
in-repo location). If the default directory can't be created, the server says
|
||||||
so and falls back to the app's `config/` folder.
|
so and falls back to the app's `config/` folder.
|
||||||
@@ -131,10 +145,11 @@ set on a tile itself (a PCO Plan header colour, say) is deliberately kept.
|
|||||||
|
|
||||||
## Installing modules and updating
|
## Installing modules and updating
|
||||||
|
|
||||||
Everything below happens in the **admin page**; ProdDash talks to its GitHub
|
Everything below happens in the **admin page**; ProdDash talks to its git
|
||||||
repository (`michaelmcgary2008/ProdDash`, branch `main` — change with
|
repository — `michael/ProdDash` on the church's own Gitea at
|
||||||
`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable,
|
`chronicles-ab.waterschurch.org:3000`, branch `main`. Change it with `forge`,
|
||||||
`owner/repo#branch`). It downloads one small archive of the branch, at most
|
`repo` and `branch` in `proddash.json`, or the `PRODDASH_FORGE` and
|
||||||
|
`PRODDASH_REPO` variables (`owner/repo#branch`). It downloads one small archive of the branch, at most
|
||||||
every few minutes, only while the admin page is open or for the update check
|
every few minutes, only while the admin page is open or for the update check
|
||||||
below. Nothing is installed without someone clicking.
|
below. Nothing is installed without someone clicking.
|
||||||
|
|
||||||
@@ -216,7 +231,13 @@ below. Nothing is installed without someone clicking.
|
|||||||
Tile settings: channel icons, direction and timestamps.
|
Tile settings: channel icons, direction and timestamps.
|
||||||
- **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).
|
||||||
|
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
|
||||||
|
the live item alone with the gradient filling the tile edge to edge — a
|
||||||
|
wall panel that says one thing.
|
||||||
Admin config: ProPresenter endpoint (host : port), password.
|
Admin config: ProPresenter endpoint (host : port), password.
|
||||||
|
Tile settings: a **Show** group of switches for the playlist name, live
|
||||||
|
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||||
|
its item tags, plus tidy titles.
|
||||||
- **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"
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ modules/<id>/
|
|||||||
"minSize": { "w": 2, "h": 2 },
|
"minSize": { "w": 2, "h": 2 },
|
||||||
"defaultSize": { "w": 4, "h": 3 },
|
"defaultSize": { "w": 4, "h": 3 },
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "http://10.3.11.152:24480" },
|
"prodcomUrl": { "type": "string", "label": "ProdCom URL", "default": "" },
|
||||||
"apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" }
|
"apiKey": { "type": "password", "label": "API key (if PSK auth enabled)", "default": "" }
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"port": 24500,
|
"port": 24500,
|
||||||
"adminPasscode": "",
|
"adminPasscode": "",
|
||||||
"repo": "michaelmcgary2008/ProdDash",
|
"forge": "http://chronicles-ab.waterschurch.org:3000",
|
||||||
|
"repo": "michael/ProdDash",
|
||||||
"branch": "main"
|
"branch": "main"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript",
|
"description": "Live ProdCom transcript",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -26,9 +26,10 @@
|
|||||||
"type": "endpoint",
|
"type": "endpoint",
|
||||||
"label": "ProdCom server",
|
"label": "ProdCom server",
|
||||||
"default": {
|
"default": {
|
||||||
"host": "10.3.11.152",
|
"host": "",
|
||||||
"port": 24480
|
"port": 24480
|
||||||
}
|
},
|
||||||
|
"help": "The ProdCom server at THIS location — every campus has its own. Blank until you set it, so a new install never reads another campus's comms."
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ module.exports = {
|
|||||||
streams,
|
streams,
|
||||||
health() {
|
health() {
|
||||||
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
|
if (status.state === 'ok') return { status: 'ok', message: `Live — ${channels.length} channel${channels.length === 1 ? '' : 's'}, ${entries.size} entries` };
|
||||||
|
// Nothing to connect to is a setting that is missing, not a link that
|
||||||
|
// is coming up — it reads red, like the other modules say it.
|
||||||
|
if (!target) return { status: 'error', message: status.message };
|
||||||
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
|
return { status: status.state === 'error' ? 'error' : 'connecting', message: status.message };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
in /admin; the tile only renders. */
|
in /admin; the tile only renders. */
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
|
/* "now" is the live item on its own, filling the tile; "full" is that plus
|
||||||
|
the rest of the playlist. The picker presets `view`; a tile placed before
|
||||||
|
this module had a picker has neither, and gets the full card. */
|
||||||
|
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
|
||||||
|
const nowOnly = view === 'now';
|
||||||
|
|
||||||
let state = null;
|
let state = null;
|
||||||
let feedOffline = false;
|
let feedOffline = false;
|
||||||
let stream = null;
|
let stream = null;
|
||||||
@@ -33,11 +39,13 @@ export default function create({ root, moduleApi }) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const screenEl = root.querySelector('.nn-screen');
|
const screenEl = root.querySelector('.nn-screen');
|
||||||
|
if (nowOnly) screenEl.classList.add('nn-only-now');
|
||||||
const topRow = root.querySelector('.nn-top');
|
const topRow = root.querySelector('.nn-top');
|
||||||
const playlistNameEl = root.querySelector('.nn-playlist');
|
const playlistNameEl = root.querySelector('.nn-playlist');
|
||||||
const liveEl = root.querySelector('.nn-live');
|
const liveEl = root.querySelector('.nn-live');
|
||||||
const liveTextEl = root.querySelector('.nn-live-text');
|
const liveTextEl = root.querySelector('.nn-live-text');
|
||||||
const nowEl = root.querySelector('.nn-now');
|
const nowEl = root.querySelector('.nn-now');
|
||||||
|
const nowLabelEl = root.querySelector('.nn-label');
|
||||||
const nowTitleBox = root.querySelector('.nn-title-box');
|
const nowTitleBox = root.querySelector('.nn-title-box');
|
||||||
let nowTitleEl = root.querySelector('.nn-title');
|
let nowTitleEl = root.querySelector('.nn-title');
|
||||||
const nowMetaEl = root.querySelector('.nn-meta');
|
const nowMetaEl = root.querySelector('.nn-meta');
|
||||||
@@ -47,9 +55,11 @@ export default function create({ root, moduleApi }) {
|
|||||||
const nextListEl = root.querySelector('.nn-next-list');
|
const nextListEl = root.querySelector('.nn-next-list');
|
||||||
const nextMoreEl = root.querySelector('.nn-next-more');
|
const nextMoreEl = root.querySelector('.nn-next-more');
|
||||||
|
|
||||||
|
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
|
||||||
function prefs() {
|
function prefs() {
|
||||||
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles
|
return moduleApi.instanceSettings;
|
||||||
}
|
}
|
||||||
|
const on = (key) => prefs()[key] !== false;
|
||||||
|
|
||||||
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
|
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
|
||||||
|
|
||||||
@@ -65,7 +75,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayName(raw) {
|
function displayName(raw) {
|
||||||
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim();
|
return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemTypeTag(type) {
|
function itemTypeTag(type) {
|
||||||
@@ -307,7 +317,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
const total = state.currentSlideTotal || activeItem?.slideCount || 0;
|
const total = state.currentSlideTotal || activeItem?.slideCount || 0;
|
||||||
const current = state.currentSlide || 0;
|
const current = state.currentSlide || 0;
|
||||||
if (total > 0 && current > 0) {
|
if (!on('showSlideCount')) {
|
||||||
|
nowMetaEl.textContent = '';
|
||||||
|
} else if (total > 0 && current > 0) {
|
||||||
const remaining = Math.max(0, total - current);
|
const remaining = Math.max(0, total - current);
|
||||||
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
|
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
|
||||||
} else if (total > 0) {
|
} else if (total > 0) {
|
||||||
@@ -318,7 +330,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
nowMetaEl.textContent = '';
|
nowMetaEl.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prefs().showProgress && total > 0) {
|
if (on('showProgress') && total > 0) {
|
||||||
progressEl.hidden = false;
|
progressEl.hidden = false;
|
||||||
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
|
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
|
||||||
progressFillEl.style.width = `${pct}%`;
|
progressFillEl.style.width = `${pct}%`;
|
||||||
@@ -349,6 +361,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
nameEl.textContent = displayName(item.name) || '(untitled)';
|
nameEl.textContent = displayName(item.name) || '(untitled)';
|
||||||
row.appendChild(nameEl);
|
row.appendChild(nameEl);
|
||||||
|
|
||||||
|
if (!on('showTags')) {
|
||||||
|
nextListEl.appendChild(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
|
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
|
||||||
const chip = document.createElement('div');
|
const chip = document.createElement('div');
|
||||||
chip.className = 'nn-count-chip';
|
chip.className = 'nn-count-chip';
|
||||||
@@ -366,7 +382,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
nextListEl.appendChild(row);
|
nextListEl.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextLabelEl.hidden = upcoming.length === 0;
|
nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel');
|
||||||
nextMoreEl.hidden = true;
|
nextMoreEl.hidden = true;
|
||||||
markNextOverflow();
|
markNextOverflow();
|
||||||
requestAnimationFrame(markNextOverflow);
|
requestAnimationFrame(markNextOverflow);
|
||||||
@@ -374,12 +390,22 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
screenEl.classList.toggle('is-offline', feedOffline);
|
screenEl.classList.toggle('is-offline', feedOffline);
|
||||||
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled;
|
// Trouble always shows, whatever is switched off: a tile that has stopped
|
||||||
topRow.classList.toggle('hidden-row', !showTop);
|
// updating must never look like one with nothing live.
|
||||||
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : '';
|
const trouble = feedOffline || !state?.reachable || !state?.enabled;
|
||||||
|
const wantPlaylist = on('showPlaylist');
|
||||||
|
const wantLive = on('showLive') || trouble;
|
||||||
|
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
|
||||||
|
liveEl.hidden = !wantLive;
|
||||||
|
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
|
||||||
|
// the row open and pushes the live dot off the right edge.
|
||||||
|
playlistNameEl.hidden = !wantPlaylist;
|
||||||
|
playlistNameEl.textContent = state?.playlistName || '';
|
||||||
|
nowLabelEl.hidden = !on('showNowLabel');
|
||||||
|
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
|
||||||
renderLive();
|
renderLive();
|
||||||
renderNow();
|
renderNow();
|
||||||
renderNext();
|
if (!nowOnly && on('showNext')) renderNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── sizing: --unit scales the whole card to the tile ───────────── */
|
/* ── sizing: --unit scales the whole card to the tile ───────────── */
|
||||||
@@ -387,6 +413,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
function applyUnit() {
|
function applyUnit() {
|
||||||
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
|
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
|
||||||
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
|
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
|
||||||
|
// On a Now tile the top row floats over the gradient, so at the shortest
|
||||||
|
// sizes it would sit on the title. Below the height it needs, it goes —
|
||||||
|
// the header dot and the title itself still report trouble.
|
||||||
|
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── live feed ──────────────────────────────────────────────────── */
|
/* ── live feed ──────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-now-next",
|
"id": "propresenter-now-next",
|
||||||
"name": "ProPresenter",
|
"name": "ProPresenter",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "What is live in ProPresenter and what comes next",
|
"description": "What is live in ProPresenter and what comes next",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||||
@@ -20,13 +20,22 @@
|
|||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"label": "API password (only if set in ProPresenter)",
|
"label": "Password",
|
||||||
"default": ""
|
"default": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true },
|
"cleanTitles": { "type": "switch", "label": "Tidy titles", "default": true },
|
||||||
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true },
|
"showPlaylist": { "type": "switch", "label": "Playlist", "default": true, "group": "Show" },
|
||||||
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true }
|
"showLive": { "type": "switch", "label": "Live status", "default": true, "group": "Show" },
|
||||||
|
"showNowLabel": { "type": "switch", "label": "Now label", "default": true, "group": "Show" },
|
||||||
|
"showSlideCount": { "type": "switch", "label": "Slide count", "default": true, "group": "Show" },
|
||||||
|
"showProgress": { "type": "switch", "label": "Progress bar", "default": true, "group": "Show" },
|
||||||
|
"showNext": { "type": "switch", "label": "Next list", "default": true, "group": "Show" },
|
||||||
|
"showNextLabel": { "type": "switch", "label": "Next label", "default": true, "group": "Show" },
|
||||||
|
"showTags": { "type": "switch", "label": "Item tags", "default": true, "group": "Show" }
|
||||||
|
},
|
||||||
|
"instanceGroups": {
|
||||||
|
"Show": { "collapsed": false, "columns": 2 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,43 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two ways to place this module. "Now / Next" is the full card — what is
|
||||||
|
* live, then the rest of the playlist. "Now" is the same live item alone,
|
||||||
|
* edge to edge with the gradient filling the tile: a wall display that says
|
||||||
|
* one thing. The Now entry offers only the settings it can act on, so its
|
||||||
|
* gear carries no Next switches.
|
||||||
|
*/
|
||||||
|
tiles() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'full',
|
||||||
|
name: 'Now / Next',
|
||||||
|
description: 'What is live, and the rest of the playlist',
|
||||||
|
defaultSize: { w: 4, h: 5 },
|
||||||
|
minSize: { w: 3, h: 3 },
|
||||||
|
settings: { view: 'full' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'now',
|
||||||
|
name: 'Now',
|
||||||
|
description: 'Only what is live, filling the tile',
|
||||||
|
defaultSize: { w: 4, h: 2 },
|
||||||
|
minSize: { w: 2, h: 1 },
|
||||||
|
settings: { view: 'now' },
|
||||||
|
instanceSchema: {
|
||||||
|
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
|
||||||
|
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
|
||||||
|
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
|
||||||
|
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
|
||||||
|
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
|
||||||
|
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
|
||||||
|
},
|
||||||
|
instanceGroups: { Show: { collapsed: false, columns: 2 } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
routes() {
|
routes() {
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
|
|||||||
@@ -21,6 +21,51 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
|
||||||
|
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
|
||||||
|
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
|
||||||
|
|
||||||
|
/* ── the "Now" tile ──
|
||||||
|
Only what is live, edge to edge: the gradient moves off the card and onto
|
||||||
|
the whole tile, the card loses its border, radius and box, and the title
|
||||||
|
sits in the middle of everything. Nothing is inset, so a wall of these
|
||||||
|
reads as panels of colour rather than cards on a background. */
|
||||||
|
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
|
||||||
|
gap: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
160deg,
|
||||||
|
color-mix(in srgb, var(--accent) 14%, transparent),
|
||||||
|
color-mix(in srgb, var(--bg) 70%, transparent)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
|
||||||
|
flex: 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* The live dot and playlist ride over the gradient rather than above it. */
|
||||||
|
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
|
||||||
|
position: absolute;
|
||||||
|
inset: var(--nn-pad) var(--nn-pad) auto;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
|
||||||
|
/* The overlay floats, so it takes no room — this keeps the title clear of it
|
||||||
|
and still centred, by holding back as much at the bottom as at the top. */
|
||||||
|
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
|
||||||
|
padding: calc(var(--unit) * 1.4) 0;
|
||||||
|
}
|
||||||
|
/* Too short for the overlay: the title gets the whole panel to itself. */
|
||||||
|
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
|
||||||
|
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── top row ── */
|
/* ── top row ── */
|
||||||
|
|
||||||
[data-module="propresenter-now-next"] .nn-top {
|
[data-module="propresenter-now-next"] .nn-top {
|
||||||
@@ -59,6 +104,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
|
||||||
|
|
||||||
[data-module="propresenter-now-next"] .nn-live {
|
[data-module="propresenter-now-next"] .nn-live {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -71,6 +117,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
|
||||||
|
|
||||||
[data-module="propresenter-now-next"] .nn-live-dot {
|
[data-module="propresenter-now-next"] .nn-live-dot {
|
||||||
width: calc(var(--unit) * 0.6);
|
width: calc(var(--unit) * 0.6);
|
||||||
@@ -122,6 +169,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
|
||||||
|
|
||||||
[data-module="propresenter-now-next"] .nn-title-box {
|
[data-module="propresenter-now-next"] .nn-title-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
+16
-5
@@ -141,13 +141,17 @@ const sendTarget = (() => {
|
|||||||
/**
|
/**
|
||||||
* Where a send goes, given the configured channels in admin order: the
|
* Where a send goes, given the configured channels in admin order: the
|
||||||
* selection while it is still configured and its transcript is on the
|
* selection while it is still configured and its transcript is on the
|
||||||
* page; otherwise the first configured channel.
|
* page, else the first configured channel that IS on this page — with one
|
||||||
|
* transcript up, that one — and only if the page shows none, the first
|
||||||
|
* configured channel. A send must never quietly go to a channel the
|
||||||
|
* operator cannot see.
|
||||||
*/
|
*/
|
||||||
resolve(configured) {
|
resolve(configured) {
|
||||||
const list = Array.isArray(configured) ? configured : [];
|
const list = Array.isArray(configured) ? configured : [];
|
||||||
if (!list.length) return '';
|
if (!list.length) return '';
|
||||||
if (id && list.includes(id) && this.hasTranscript(id)) return id;
|
if (id && list.includes(id) && this.hasTranscript(id)) return id;
|
||||||
return list[0];
|
const onPage = list.find((c) => this.hasTranscript(c));
|
||||||
|
return onPage || list[0];
|
||||||
},
|
},
|
||||||
on(fn) {
|
on(fn) {
|
||||||
bus.addEventListener('change', fn);
|
bus.addEventListener('change', fn);
|
||||||
@@ -1100,7 +1104,7 @@ function createSendTile({ root, moduleApi, mode }) {
|
|||||||
if (stopped || !state) return;
|
if (stopped || !state) return;
|
||||||
if (token) {
|
if (token) {
|
||||||
if (composeEls) {
|
if (composeEls) {
|
||||||
composeEls.line.textContent = sendingLine();
|
if (composeEls.line) composeEls.line.textContent = sendingLine();
|
||||||
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
||||||
}
|
}
|
||||||
} else renderGate();
|
} else renderGate();
|
||||||
@@ -1177,14 +1181,21 @@ function createSendTile({ root, moduleApi, mode }) {
|
|||||||
let composeEls = null;
|
let composeEls = null;
|
||||||
function buildCompose() {
|
function buildCompose() {
|
||||||
compose.textContent = '';
|
compose.textContent = '';
|
||||||
|
// Send message names the channel in the box's own placeholder and locks
|
||||||
|
// from the title bar, so a line above the field would only say both
|
||||||
|
// again. Quick replies has no placeholder — there it is the only place
|
||||||
|
// the target is named, so it stays.
|
||||||
|
let line = null;
|
||||||
|
if (mode !== 'send') {
|
||||||
const head = el('div', 'sl-compose-head');
|
const head = el('div', 'sl-compose-head');
|
||||||
const line = el('span', 'sl-sending', sendingLine());
|
line = el('span', 'sl-sending', sendingLine());
|
||||||
const lockLink = el('button', 'sl-lock-link', 'Lock');
|
const lockLink = el('button', 'sl-lock-link', 'Lock');
|
||||||
lockLink.type = 'button';
|
lockLink.type = 'button';
|
||||||
lockLink.title = 'Lock now';
|
lockLink.title = 'Lock now';
|
||||||
lockLink.addEventListener('click', () => lock('Locked'));
|
lockLink.addEventListener('click', () => lock('Locked'));
|
||||||
head.append(line, lockLink);
|
head.append(line, lockLink);
|
||||||
compose.appendChild(head);
|
compose.appendChild(head);
|
||||||
|
}
|
||||||
|
|
||||||
const foot = el('div', 'sl-compose-foot');
|
const foot = el('div', 'sl-compose-foot');
|
||||||
const msg = el('div', 'sl-msg');
|
const msg = el('div', 'sl-msg');
|
||||||
@@ -1462,7 +1473,7 @@ function createSendTile({ root, moduleApi, mode }) {
|
|||||||
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
|
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
|
||||||
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
|
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
|
||||||
if (composeEls) {
|
if (composeEls) {
|
||||||
composeEls.line.textContent = sendingLine();
|
if (composeEls.line) composeEls.line.textContent = sendingLine();
|
||||||
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
||||||
}
|
}
|
||||||
} else renderGate();
|
} else renderGate();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "slack",
|
"id": "slack",
|
||||||
"name": "Slack",
|
"name": "Slack",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
||||||
},
|
},
|
||||||
"Channels": {
|
"Channels": {
|
||||||
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
||||||
},
|
},
|
||||||
"Sending": {
|
"Sending": {
|
||||||
"toggle": "sendEnabled",
|
"toggle": "sendEnabled",
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"default": [],
|
"default": [],
|
||||||
"group": "Channels",
|
"group": "Channels",
|
||||||
"optionsRoute": "/channels",
|
"optionsRoute": "/channels",
|
||||||
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first."
|
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
|
||||||
},
|
},
|
||||||
"pollSeconds": {
|
"pollSeconds": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Edit [config.json](config.json):
|
|||||||
|
|
||||||
| Key | Meaning |
|
| Key | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `prodcomUrl` | Where the ProdCom API lives (default `http://10.3.11.152:24480`) |
|
| `prodcomUrl` | Where the ProdCom API lives — set it per site, e.g. `http://<prodcom-host>:24480` |
|
||||||
| `port` | Port this listener serves on (default `24481`) |
|
| `port` | Port this listener serves on (default `24481`) |
|
||||||
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
|
| `apiKey` | Only needed if PSK auth is enabled in ProdCom settings |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"prodcomUrl": "http://10.3.11.152:24480",
|
"prodcomUrl": "",
|
||||||
"port": 24481,
|
"port": 24481,
|
||||||
"apiKey": ""
|
"apiKey": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,15 @@ try {
|
|||||||
console.warn('config.json not found or invalid, using defaults/env vars');
|
console.warn('config.json not found or invalid, using defaults/env vars');
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || 'http://10.3.11.152:24480';
|
const PRODCOM_URL = process.env.PRODCOM_URL || cfg.prodcomUrl || '';
|
||||||
const PORT = Number(process.env.PORT || cfg.port || 24481);
|
const PORT = Number(process.env.PORT || cfg.port || 24481);
|
||||||
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
|
const API_KEY = process.env.PRODCOM_API_KEY || cfg.apiKey || '';
|
||||||
|
|
||||||
|
if (!PRODCOM_URL) {
|
||||||
|
console.error('No ProdCom API configured. Set prodcomUrl in config.json (or the PRODCOM_URL');
|
||||||
|
console.error('environment variable) to this site\'s ProdCom, e.g. http://<prodcom-host>:24480');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
const target = new URL(PRODCOM_URL);
|
const target = new URL(PRODCOM_URL);
|
||||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
* proddash.json in the data directory (this machine's overrides), or env vars:
|
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||||
* PORT (default: value in proddash.json, else 24500)
|
* PORT (default: value in proddash.json, else 24500)
|
||||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
||||||
|
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
|
||||||
|
* URL such as http://10.3.8.90:3000)
|
||||||
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -764,15 +766,53 @@ function parseRepoSetting(value, fallbackBranch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
const REPO = parseRepoSetting(process.env.PRODDASH_REPO || shellConfig.repo, shellConfig.branch)
|
||||||
|| parseRepoSetting('michaelmcgary2008/ProdDash', 'main');
|
|| parseRepoSetting('michael/ProdDash', 'main');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which server the code comes from. GitHub unless `forge` names another —
|
||||||
|
* give it a Gitea (or Forgejo) base URL, "http://10.3.8.90:3000", and updates
|
||||||
|
* and module installs come from there instead. The two hosts differ only in
|
||||||
|
* how an archive URL is spelled: the tarball itself is the same, pax header
|
||||||
|
* and commit sha included, so everything downstream is untouched.
|
||||||
|
*/
|
||||||
|
function parseForge(value) {
|
||||||
|
const s = String(value || '').trim().replace(/\/+$/, '');
|
||||||
|
if (!s || s.toLowerCase() === 'github') {
|
||||||
|
return { kind: 'github', base: 'https://github.com', archiveBase: 'https://codeload.github.com' };
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(s)) return null;
|
||||||
|
// A GitHub URL still means codeload for archives.
|
||||||
|
if (/^https?:\/\/(www\.)?github\.com$/i.test(s)) {
|
||||||
|
return { kind: 'github', base: s, archiveBase: 'https://codeload.github.com' };
|
||||||
|
}
|
||||||
|
return { kind: 'gitea', base: s, archiveBase: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORGE = parseForge(process.env.PRODDASH_FORGE || shellConfig.forge) || parseForge('github');
|
||||||
/** Where branch archives come from; tests point this at a local mock. */
|
/** Where branch archives come from; tests point this at a local mock. */
|
||||||
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || 'https://codeload.github.com').replace(/\/+$/, '');
|
const ARCHIVE_BASE = String(process.env.PRODDASH_ARCHIVE_BASE || FORGE.archiveBase).replace(/\/+$/, '');
|
||||||
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
const REMOTE_TTL_MS = 10 * 60 * 1000;
|
||||||
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
|
const REMOTE_CHECK_MS = 6 * 60 * 60 * 1000;
|
||||||
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
const MAX_ARCHIVE_BYTES = 80 * 1024 * 1024;
|
||||||
|
|
||||||
function tarballUrl() {
|
function tarballUrl() {
|
||||||
return `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${encodeURIComponent(REPO.branch)}`;
|
const branch = encodeURIComponent(REPO.branch);
|
||||||
|
return FORGE.kind === 'gitea'
|
||||||
|
? `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/archive/${branch}.tar.gz`
|
||||||
|
: `${ARCHIVE_BASE}/${REPO.owner}/${REPO.repo}/tar.gz/${branch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page a human should read to see what changed between two commits. */
|
||||||
|
function forgeCompareUrl(from, to) {
|
||||||
|
return `${FORGE.base}/${REPO.owner}/${REPO.repo}/compare/${from}...${to}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The commit list for the branch we follow. */
|
||||||
|
function forgeCommitsUrl() {
|
||||||
|
const branch = encodeURIComponent(REPO.branch);
|
||||||
|
return FORGE.kind === 'gitea'
|
||||||
|
? `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/branch/${branch}`
|
||||||
|
: `${FORGE.base}/${REPO.owner}/${REPO.repo}/commits/${branch}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function httpError(status, message) {
|
function httpError(status, message) {
|
||||||
@@ -929,11 +969,11 @@ function shellStatus() {
|
|||||||
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
|
else if (local.branch !== REPO.branch) blocker = `this checkout is on branch "${local.branch}", not "${REPO.branch}"`;
|
||||||
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
|
else if (local.dirty) blocker = 'this checkout has local changes — commit or discard them first';
|
||||||
}
|
}
|
||||||
const gh = `https://github.com/${REPO.owner}/${REPO.repo}`;
|
|
||||||
return {
|
return {
|
||||||
version: SHELL_VERSION,
|
version: SHELL_VERSION,
|
||||||
repo: `${REPO.owner}/${REPO.repo}`,
|
repo: `${REPO.owner}/${REPO.repo}`,
|
||||||
branch: REPO.branch,
|
branch: REPO.branch,
|
||||||
|
forge: FORGE.base,
|
||||||
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
|
local: { isGit: local.isGit, sha: local.sha, branch: local.branch, dirty: local.dirty },
|
||||||
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
|
remote: { version: remote.version, sha: remote.sha, checkedAt: remote.checkedAt, error: remote.error },
|
||||||
updateAvailable: versionBehind || commitsBehind,
|
updateAvailable: versionBehind || commitsBehind,
|
||||||
@@ -941,8 +981,8 @@ function shellStatus() {
|
|||||||
method: local.isGit ? 'git' : 'archive',
|
method: local.isGit ? 'git' : 'archive',
|
||||||
updateBlocker: blocker,
|
updateBlocker: blocker,
|
||||||
changesUrl: local.sha && remote.sha
|
changesUrl: local.sha && remote.sha
|
||||||
? `${gh}/compare/${local.sha.slice(0, 12)}...${remote.sha.slice(0, 12)}`
|
? forgeCompareUrl(local.sha.slice(0, 12), remote.sha.slice(0, 12))
|
||||||
: `${gh}/commits/${REPO.branch}`,
|
: forgeCommitsUrl(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1693,7 +1733,7 @@ server.on('listening', () => {
|
|||||||
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
|
? ' Modules : ' + ids.map((id) => id + (isEnabled(id) ? '' : ' (disabled)') + (manifests.get(id).incompatible ? ' (needs newer ProdDash)' : '')).join(', ')
|
||||||
: ' Modules : none installed');
|
: ' Modules : none installed');
|
||||||
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
|
console.log(' Settings : ' + DATA_DIR + (DATA_DIR === REPO_CONFIG_DIR ? ' (inside the app folder)' : ''));
|
||||||
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch);
|
console.log(' Repo : ' + REPO.owner + '/' + REPO.repo + '@' + REPO.branch + ' (' + FORGE.base + ')');
|
||||||
});
|
});
|
||||||
|
|
||||||
// A restart hands the port over from the exiting ProdDash: wait for it.
|
// A restart hands the port over from the exiting ProdDash: wait for it.
|
||||||
|
|||||||
Reference in New Issue
Block a user