Admin: collapsible / multi-column config groups, help text, grouped and dependent selects
Ports the timers lane's admin schema additions unchanged (field `group`,
`showWhen`, type `switch`, `optionsRoute`) so the two branches merge
cleanly, and extends them for modules whose settings are richer than a
handful of fields:
- manifest `configGroups[name]` = { collapsed: true | false | "whenSet",
columns: 2–4, help } — a credentials block that folds once it holds a
saved secret, a show/hide checklist laid out as a compact grid
- `help` on any field: one line of guidance under the control
- select options may carry `group` → rendered as <optgroup>s (a folder
tree browsable inside the select)
- `optionsDependsOn: "<key>"` refetches a select's options with that
field's current value as a query parameter, now and on every change
- a save re-runs the card's dynamic option fetches, so a list that needed
the just-saved credentials fills in without a page reload; a blank
"— choose —" is no longer added when the module offers its own blank
option
Documented in docs/MODULE-GUIDE.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
50caf02023
commit
106ce0c1ce
+59
-5
@@ -64,13 +64,67 @@ shell code changes, ever.
|
|||||||
Both schemas map field names to specs:
|
Both schemas map field names to specs:
|
||||||
|
|
||||||
```
|
```
|
||||||
{ "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint",
|
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "password" | "endpoint",
|
||||||
"label": "Shown next to the field",
|
"label": "Shown next to the field",
|
||||||
"default": <value>,
|
"default": <value>,
|
||||||
"options": [ { "value": "a", "label": "A" }, … ] // "select" only
|
"group": "Section heading", // optional; see below
|
||||||
|
"showWhen": "<other boolean/switch key>", // optional; see below
|
||||||
|
"help": "One line shown under the control", // optional, admin config only
|
||||||
|
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
|
||||||
|
"optionsRoute": "/devices", // "select" only, admin config only
|
||||||
|
"optionsDependsOn": "<other key>" // with optionsRoute; see below
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`boolean` renders as a checkbox; `switch` is the same true/false value shown
|
||||||
|
as a toggle (use it for a feature on/off that other fields depend on).
|
||||||
|
|
||||||
|
`group` gathers consecutive fields under a labeled subsection in the admin
|
||||||
|
page, so a module with several distinct concerns (e.g. a ProPresenter
|
||||||
|
connection and an LTC audio input) reads as clearly separated blocks. Fields
|
||||||
|
with no `group` render loose, before/after the grouped ones in schema order.
|
||||||
|
|
||||||
|
A manifest-level `configGroups` map can dress a group up further, keyed by
|
||||||
|
the group name:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"configGroups": {
|
||||||
|
"Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" },
|
||||||
|
"Show / hide": { "collapsed": true, "columns": 3 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `collapsed`: `true` (starts folded), `false` (starts open but can fold),
|
||||||
|
or `"whenSet"` — folded once the group already holds values (a saved
|
||||||
|
password, or anything other than the schema default), open while it is
|
||||||
|
still empty. Omit it and the group is a plain, always-open block.
|
||||||
|
- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a
|
||||||
|
long checklist of booleans compact.
|
||||||
|
- `help`: a line of guidance at the top of the group.
|
||||||
|
|
||||||
|
`help` on a field puts one line of guidance under its control — allowed
|
||||||
|
placeholders, where a value comes from. Keep labels short and put the
|
||||||
|
explanation there.
|
||||||
|
|
||||||
|
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
|
||||||
|
`<key>` is on — e.g. the audio device and channel appear only once the LTC
|
||||||
|
listener switch is enabled. The hidden field still keeps its stored value.
|
||||||
|
|
||||||
|
A `select` whose choices are only knowable at runtime (audio devices, serial
|
||||||
|
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
|
||||||
|
refresh of — static `options`: the admin page GETs
|
||||||
|
`/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value",
|
||||||
|
"label" }, … ] }` from one of the module's own server routes. The saved value
|
||||||
|
stays selectable even when the route is down or the option has vanished. (See
|
||||||
|
`propresenter-timers`' "Audio device" for a worked example.)
|
||||||
|
|
||||||
|
An option may carry a `group`: options sharing one render under a single
|
||||||
|
`<optgroup>`, so a tree (Planning Center folders, device classes) is
|
||||||
|
browsable inside the select. `optionsDependsOn: "<key>"` makes the admin
|
||||||
|
page fetch the options with that other field's current value appended as a
|
||||||
|
query parameter (`?<key>=<value>`), now and again whenever it changes — e.g.
|
||||||
|
a plan list that follows the chosen service type. (Both in `pco-plan`.)
|
||||||
|
|
||||||
`password` fields are admin-config only in practice: their values are stored
|
`password` fields are admin-config only in practice: their values are stored
|
||||||
in the server's `modules.json` (in the per-machine data directory, see the
|
in the server's `modules.json` (in the per-machine data directory, see the
|
||||||
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
|
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
|
||||||
@@ -468,9 +522,9 @@ client-side failures (a tile that fails to start says so in the tile).
|
|||||||
Center is a cloud API, so the module reads its base URL from the
|
Center is a cloud API, so the module reads its base URL from the
|
||||||
environment instead of admin config: run ProdDash with
|
environment instead of admin config: run ProdDash with
|
||||||
`PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any
|
`PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any
|
||||||
Application ID + Secret in `/admin`, and pick "Sunday Service" in
|
Application ID + Secret under PCO Plan in `/admin`, save, then pick
|
||||||
`/modules/pco-plan/setup.html`. Its item titles match `pp-mock.js`, so
|
"Sunday Service" from the Service type list. Its item titles match
|
||||||
running both mocks exercises the ProPresenter matching.
|
`pp-mock.js`, so running both mocks exercises the ProPresenter matching.
|
||||||
|
|
||||||
**The resilience checklist** — every module must pass this before it's done:
|
**The resilience checklist** — every module must pass this before it's done:
|
||||||
|
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>PCO Plan — Planning Center setup</title>
|
|
||||||
<link rel="stylesheet" href="/style.css">
|
|
||||||
<style>
|
|
||||||
/* The module's own setup page: browse Planning Center folders and pick the
|
|
||||||
service type to follow. Saves through the shell's admin API, so the
|
|
||||||
choice lands in config/modules.json like any other admin setting. */
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 24px 16px 48px;
|
|
||||||
background: var(--bg, #0b0e12);
|
|
||||||
color: var(--text, #e6edf3);
|
|
||||||
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
.ps-page { max-width: 760px; margin: 0 auto; }
|
|
||||||
.ps-top { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
||||||
.ps-top h1 { margin: 0; font-size: 20px; }
|
|
||||||
.ps-top a { color: var(--accent, #2ee59a); text-decoration: none; font-weight: 600; }
|
|
||||||
.ps-lead { color: var(--muted, #8b98a5); margin: 6px 0 18px; }
|
|
||||||
.ps-card {
|
|
||||||
background: var(--panel, #131920);
|
|
||||||
border: 1px solid var(--border, #232b35);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.ps-card h2 { margin: 0 0 10px; font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted, #8b98a5); }
|
|
||||||
.ps-current { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
||||||
.ps-current strong { font-size: 15px; }
|
|
||||||
.ps-crumbs { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 13px; }
|
|
||||||
.ps-crumbs button {
|
|
||||||
background: none; border: none; color: var(--accent, #2ee59a); font: inherit; font-weight: 600; cursor: pointer; padding: 2px 4px; border-radius: 4px;
|
|
||||||
}
|
|
||||||
.ps-crumbs button:hover { background: color-mix(in srgb, var(--accent, #2ee59a) 14%, transparent); }
|
|
||||||
.ps-crumbs button[disabled] { color: var(--text, #e6edf3); cursor: default; background: none; }
|
|
||||||
.ps-crumbs .sep { color: var(--muted, #8b98a5); }
|
|
||||||
.ps-rows { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.ps-row {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 9px 10px; border-radius: 8px; border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
.ps-row:hover { background: color-mix(in srgb, var(--text, #e6edf3) 4%, transparent); }
|
|
||||||
.ps-row.is-folder { cursor: pointer; }
|
|
||||||
.ps-row.is-selected { border-color: color-mix(in srgb, var(--accent, #2ee59a) 50%, transparent); background: color-mix(in srgb, var(--accent, #2ee59a) 10%, transparent); }
|
|
||||||
.ps-icon { flex: none; width: 22px; text-align: center; color: var(--muted, #8b98a5); }
|
|
||||||
.ps-name { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.ps-meta { color: var(--muted, #8b98a5); font-size: 12px; white-space: nowrap; }
|
|
||||||
.ps-btn {
|
|
||||||
background: none; border: 1px solid var(--border, #232b35); color: var(--text, #e6edf3);
|
|
||||||
font: inherit; font-weight: 600; font-size: 13px; padding: 5px 12px; border-radius: 999px; cursor: pointer; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.ps-btn:hover { border-color: var(--accent, #2ee59a); color: var(--accent, #2ee59a); }
|
|
||||||
.ps-btn.primary { background: var(--accent, #2ee59a); border-color: var(--accent, #2ee59a); color: var(--bg, #0b0e12); }
|
|
||||||
.ps-btn.primary:hover { color: var(--bg, #0b0e12); filter: brightness(1.08); }
|
|
||||||
.ps-btn[disabled] { opacity: 0.5; cursor: default; }
|
|
||||||
.ps-preview { margin-top: 6px; padding: 8px 10px 8px 42px; color: var(--muted, #8b98a5); font-size: 13px; }
|
|
||||||
.ps-preview strong { color: var(--text, #e6edf3); }
|
|
||||||
.ps-status { margin-top: 10px; font-size: 13px; color: var(--muted, #8b98a5); min-height: 1.3em; }
|
|
||||||
.ps-status.ok { color: var(--accent, #2ee59a); }
|
|
||||||
.ps-status.error { color: var(--danger, #f4433c); }
|
|
||||||
.ps-empty { color: var(--muted, #8b98a5); padding: 10px; }
|
|
||||||
.ps-login { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
|
|
||||||
.ps-login input {
|
|
||||||
background: var(--bg, #0b0e12); border: 1px solid var(--border, #232b35); color: var(--text, #e6edf3);
|
|
||||||
font: inherit; padding: 6px 10px; border-radius: 8px; width: 200px;
|
|
||||||
}
|
|
||||||
.ps-hint { color: var(--muted, #8b98a5); font-size: 12px; margin-top: 8px; }
|
|
||||||
.ps-hint code { background: color-mix(in srgb, var(--text, #e6edf3) 8%, transparent); padding: 1px 5px; border-radius: 4px; }
|
|
||||||
[hidden] { display: none !important; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="ps-page">
|
|
||||||
<div class="ps-top">
|
|
||||||
<h1>PCO Plan · Planning Center setup</h1>
|
|
||||||
<a href="/admin">← Admin</a>
|
|
||||||
<a href="/">Dashboard</a>
|
|
||||||
</div>
|
|
||||||
<p class="ps-lead">Browse your Planning Center Services folders and choose the service type whose plans the PCO Plan tiles should follow.</p>
|
|
||||||
|
|
||||||
<section class="ps-card">
|
|
||||||
<h2>Currently following</h2>
|
|
||||||
<div class="ps-current" id="current">Loading…</div>
|
|
||||||
<div class="ps-status" id="current-status"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="ps-card">
|
|
||||||
<h2>Service types</h2>
|
|
||||||
<div class="ps-crumbs" id="crumbs"></div>
|
|
||||||
<div class="ps-rows" id="rows"><div class="ps-empty">Loading folders from Planning Center…</div></div>
|
|
||||||
<div class="ps-status" id="browse-status"></div>
|
|
||||||
<div class="ps-login" id="login" hidden>
|
|
||||||
<span>Admin passcode:</span>
|
|
||||||
<input type="password" id="passcode" autocomplete="current-password">
|
|
||||||
<button class="ps-btn primary" id="login-btn">Unlock</button>
|
|
||||||
<span class="ps-status" id="login-status"></span>
|
|
||||||
</div>
|
|
||||||
<div class="ps-hint">Credentials (Application ID + Secret from <code>api.planningcenteronline.com</code>, or an OAuth token) are entered under PCO Plan in <a href="/admin" style="color:var(--accent)">Admin</a>. Saving here re-initialises the module; open tiles pick up the new plan on their own.</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
const MODULE = 'pco-plan';
|
|
||||||
const els = {
|
|
||||||
current: document.getElementById('current'),
|
|
||||||
currentStatus: document.getElementById('current-status'),
|
|
||||||
crumbs: document.getElementById('crumbs'),
|
|
||||||
rows: document.getElementById('rows'),
|
|
||||||
browseStatus: document.getElementById('browse-status'),
|
|
||||||
login: document.getElementById('login'),
|
|
||||||
passcode: document.getElementById('passcode'),
|
|
||||||
loginBtn: document.getElementById('login-btn'),
|
|
||||||
loginStatus: document.getElementById('login-status'),
|
|
||||||
};
|
|
||||||
|
|
||||||
let tree = null; // { folders: Map, serviceTypes: [], selected }
|
|
||||||
let path = []; // folder ids from root to the open folder
|
|
||||||
let pendingSave = null; // service type waiting for the passcode
|
|
||||||
|
|
||||||
async function api(url, opts = {}) {
|
|
||||||
const res = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) } });
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = new Error(body.error || `Request failed (${res.status})`);
|
|
||||||
err.status = res.status;
|
|
||||||
err.authRequired = Boolean(body.authRequired);
|
|
||||||
err.unconfigured = Boolean(body.unconfigured);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(el, text, kind) {
|
|
||||||
el.textContent = text || '';
|
|
||||||
el.className = 'ps-status' + (kind ? ' ' + kind : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — current selection — */
|
|
||||||
|
|
||||||
async function loadCurrent() {
|
|
||||||
try {
|
|
||||||
const { state } = await api(`/api/modules/${MODULE}/state`);
|
|
||||||
els.current.innerHTML = '';
|
|
||||||
if (!state.configured) {
|
|
||||||
els.current.textContent = 'No Planning Center credentials yet — add them in Admin first.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!state.serviceTypeSet) {
|
|
||||||
els.current.textContent = 'No service type selected.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const strong = document.createElement('strong');
|
|
||||||
strong.textContent = state.serviceType.name || `Service type ${state.serviceType.id}`;
|
|
||||||
els.current.appendChild(strong);
|
|
||||||
const meta = document.createElement('span');
|
|
||||||
meta.className = 'ps-meta';
|
|
||||||
if (state.plan) {
|
|
||||||
meta.textContent = `Plan: ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''} · ${state.items.length} items`;
|
|
||||||
} else if (state.reachable) {
|
|
||||||
meta.textContent = 'No upcoming or recent plan found';
|
|
||||||
} else {
|
|
||||||
meta.textContent = state.lastError || 'Reading…';
|
|
||||||
}
|
|
||||||
els.current.appendChild(meta);
|
|
||||||
setStatus(els.currentStatus, state.reachable ? '' : state.lastError, state.reachable ? '' : 'error');
|
|
||||||
} catch (e) {
|
|
||||||
els.current.textContent = `Module not running: ${e.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — browsing — */
|
|
||||||
|
|
||||||
async function loadTree() {
|
|
||||||
setStatus(els.browseStatus, 'Loading…');
|
|
||||||
try {
|
|
||||||
const body = await api(`/api/modules/${MODULE}/browse`);
|
|
||||||
const folders = new Map(body.folders.map((f) => [f.id, f]));
|
|
||||||
// A parent that still is not known is treated as root.
|
|
||||||
for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) f.parentId = '';
|
|
||||||
for (const st of body.serviceTypes) if (st.parentId && !folders.has(st.parentId)) st.parentId = '';
|
|
||||||
tree = { folders, serviceTypes: body.serviceTypes, selected: body.selected };
|
|
||||||
// Open the folder that holds the current selection.
|
|
||||||
path = [];
|
|
||||||
const sel = body.serviceTypes.find((st) => st.id === body.selected);
|
|
||||||
if (sel) {
|
|
||||||
let pid = sel.parentId;
|
|
||||||
while (pid && folders.has(pid)) { path.unshift(pid); pid = folders.get(pid).parentId; }
|
|
||||||
}
|
|
||||||
setStatus(els.browseStatus, `${body.serviceTypes.length} service types in ${folders.size} folders (${body.method === 'oauth' ? 'OAuth token' : 'personal access token'})`);
|
|
||||||
renderTree();
|
|
||||||
} catch (e) {
|
|
||||||
els.rows.innerHTML = '';
|
|
||||||
const msg = document.createElement('div');
|
|
||||||
msg.className = 'ps-empty';
|
|
||||||
msg.textContent = e.unconfigured
|
|
||||||
? 'Add Planning Center credentials for PCO Plan in Admin, then reload this page.'
|
|
||||||
: (e.status === 401 ? 'Planning Center rejected the credentials — check the Application ID and Secret in Admin.' : e.message);
|
|
||||||
els.rows.appendChild(msg);
|
|
||||||
setStatus(els.browseStatus, '', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCrumbs() {
|
|
||||||
els.crumbs.innerHTML = '';
|
|
||||||
const add = (label, depth) => {
|
|
||||||
if (els.crumbs.children.length) {
|
|
||||||
const sep = document.createElement('span');
|
|
||||||
sep.className = 'sep';
|
|
||||||
sep.textContent = '›';
|
|
||||||
els.crumbs.appendChild(sep);
|
|
||||||
}
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.textContent = label;
|
|
||||||
b.disabled = depth === path.length;
|
|
||||||
b.addEventListener('click', () => { path = path.slice(0, depth); renderTree(); });
|
|
||||||
els.crumbs.appendChild(b);
|
|
||||||
};
|
|
||||||
add('All folders', 0);
|
|
||||||
path.forEach((id, i) => add(tree.folders.get(id)?.name || 'Folder', i + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTree() {
|
|
||||||
if (!tree) return;
|
|
||||||
renderCrumbs();
|
|
||||||
els.rows.innerHTML = '';
|
|
||||||
const here = path.length ? path[path.length - 1] : '';
|
|
||||||
const folders = Array.from(tree.folders.values()).filter((f) => (f.parentId || '') === here)
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
const types = tree.serviceTypes.filter((st) => (st.parentId || '') === here)
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
for (const f of folders) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'ps-row is-folder';
|
|
||||||
row.innerHTML = '<span class="ps-icon">📁</span>';
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'ps-name';
|
|
||||||
name.textContent = f.name;
|
|
||||||
const count = document.createElement('span');
|
|
||||||
count.className = 'ps-meta';
|
|
||||||
const n = countTypes(f.id);
|
|
||||||
count.textContent = n === 1 ? '1 service type' : `${n} service types`;
|
|
||||||
row.append(name, count);
|
|
||||||
row.addEventListener('click', () => { path = [...path, f.id]; renderTree(); });
|
|
||||||
els.rows.appendChild(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const st of types) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'ps-row' + (st.id === tree.selected ? ' is-selected' : '');
|
|
||||||
row.innerHTML = '<span class="ps-icon">▤</span>';
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'ps-name';
|
|
||||||
name.textContent = st.name;
|
|
||||||
const meta = document.createElement('span');
|
|
||||||
meta.className = 'ps-meta';
|
|
||||||
meta.textContent = st.frequency ? st.frequency : '';
|
|
||||||
const preview = document.createElement('button');
|
|
||||||
preview.className = 'ps-btn';
|
|
||||||
preview.textContent = 'Preview plan';
|
|
||||||
const pick = document.createElement('button');
|
|
||||||
pick.className = 'ps-btn primary';
|
|
||||||
pick.textContent = st.id === tree.selected ? 'Selected' : 'Use this';
|
|
||||||
pick.disabled = st.id === tree.selected;
|
|
||||||
row.append(name, meta, preview, pick);
|
|
||||||
els.rows.appendChild(row);
|
|
||||||
|
|
||||||
const previewEl = document.createElement('div');
|
|
||||||
previewEl.className = 'ps-preview';
|
|
||||||
previewEl.hidden = true;
|
|
||||||
els.rows.appendChild(previewEl);
|
|
||||||
|
|
||||||
preview.addEventListener('click', async () => {
|
|
||||||
previewEl.hidden = false;
|
|
||||||
previewEl.textContent = 'Looking up the plan…';
|
|
||||||
try {
|
|
||||||
const { plan } = await api(`/api/modules/${MODULE}/preview?serviceTypeId=${encodeURIComponent(st.id)}`);
|
|
||||||
if (!plan) {
|
|
||||||
previewEl.textContent = 'No upcoming or recent plan for this service type.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
previewEl.innerHTML = '';
|
|
||||||
const strong = document.createElement('strong');
|
|
||||||
strong.textContent = `${plan.dates}${plan.title ? ` — ${plan.title}` : ''}`;
|
|
||||||
previewEl.appendChild(strong);
|
|
||||||
previewEl.appendChild(document.createTextNode(
|
|
||||||
`${plan.seriesTitle ? ` · ${plan.seriesTitle}` : ''} · ${plan.itemsCount} items · ${Math.round(plan.totalLength / 60)} min`
|
|
||||||
));
|
|
||||||
} catch (e) {
|
|
||||||
previewEl.textContent = e.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pick.addEventListener('click', () => save(st));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!folders.length && !types.length) {
|
|
||||||
const empty = document.createElement('div');
|
|
||||||
empty.className = 'ps-empty';
|
|
||||||
empty.textContent = 'This folder is empty.';
|
|
||||||
els.rows.appendChild(empty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function countTypes(folderId) {
|
|
||||||
let n = 0;
|
|
||||||
const stack = [folderId];
|
|
||||||
const seen = new Set();
|
|
||||||
while (stack.length) {
|
|
||||||
const id = stack.pop();
|
|
||||||
if (seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
n += tree.serviceTypes.filter((st) => st.parentId === id).length;
|
|
||||||
for (const f of tree.folders.values()) if (f.parentId === id) stack.push(f.id);
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* — saving through the shell's admin API — */
|
|
||||||
|
|
||||||
async function save(st) {
|
|
||||||
setStatus(els.browseStatus, `Saving ${st.name}…`);
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/modules/${MODULE}/config`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ config: { serviceTypeId: st.id } }),
|
|
||||||
});
|
|
||||||
tree.selected = st.id;
|
|
||||||
pendingSave = null;
|
|
||||||
els.login.hidden = true;
|
|
||||||
setStatus(els.browseStatus, `Now following ${st.name}. The module re-initialised; tiles update on their own.`, 'ok');
|
|
||||||
renderTree();
|
|
||||||
// The module re-reads the plan right after re-init; give it a moment.
|
|
||||||
setTimeout(loadCurrent, 1500);
|
|
||||||
setTimeout(loadCurrent, 5000);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.authRequired) {
|
|
||||||
pendingSave = st;
|
|
||||||
els.login.hidden = false;
|
|
||||||
els.passcode.focus();
|
|
||||||
setStatus(els.browseStatus, 'Enter the admin passcode to save.', 'error');
|
|
||||||
} else {
|
|
||||||
setStatus(els.browseStatus, e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login() {
|
|
||||||
setStatus(els.loginStatus, '');
|
|
||||||
try {
|
|
||||||
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: els.passcode.value }) });
|
|
||||||
els.passcode.value = '';
|
|
||||||
els.login.hidden = true;
|
|
||||||
if (pendingSave) save(pendingSave);
|
|
||||||
} catch (e) {
|
|
||||||
setStatus(els.loginStatus, e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
els.loginBtn.addEventListener('click', login);
|
|
||||||
els.passcode.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
|
|
||||||
|
|
||||||
loadCurrent();
|
|
||||||
loadTree();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -113,6 +113,83 @@ body { overflow: auto; }
|
|||||||
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
|
||||||
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* labeled config subsections (manifest field `group`) */
|
||||||
|
.field-group {
|
||||||
|
margin: 14px 0 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
.field-group-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* collapsible groups (manifest configGroups[name].collapsed) */
|
||||||
|
details.field-group > summary.field-group-title {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
details.field-group > summary.field-group-title::-webkit-details-marker { display: none; }
|
||||||
|
details.field-group > summary.field-group-title::before {
|
||||||
|
content: "";
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 5px solid var(--muted);
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); }
|
||||||
|
details.field-group[open] > summary.field-group-title { margin-bottom: 10px; }
|
||||||
|
details.field-group > summary.field-group-title:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* multi-column groups (configGroups[name].columns): a neat checklist grid */
|
||||||
|
.field-group-body.is-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
|
||||||
|
gap: 4px 16px;
|
||||||
|
}
|
||||||
|
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
|
||||||
|
.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; }
|
||||||
|
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
|
||||||
|
|
||||||
|
.field-group-help,
|
||||||
|
.field-help {
|
||||||
|
display: block;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.field-group-help { margin: -4px 0 8px; }
|
||||||
|
.field-help { margin-top: 2px; }
|
||||||
|
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
|
||||||
|
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
|
||||||
|
own `display:flex` (an author rule) otherwise outranks the [hidden] UA
|
||||||
|
default and the field stays visible. */
|
||||||
|
.module-form .field[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* a boolean rendered as a toggle (manifest type `switch`) */
|
||||||
|
.field.switch-field {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.field.switch-field .switch-label { font-size: 13px; }
|
||||||
|
|
||||||
.module-actions {
|
.module-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+191
-10
@@ -83,19 +83,43 @@ function healthText(mod) {
|
|||||||
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
|
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldFor(key, spec, value, passwordSet) {
|
function fieldFor(key, spec, value, passwordSet, moduleId) {
|
||||||
const type = spec?.type || 'string';
|
const type = spec?.type || 'string';
|
||||||
const label = spec?.label || key;
|
const label = spec?.label || key;
|
||||||
const field = document.createElement('label');
|
const field = document.createElement('label');
|
||||||
field.className = 'field' + (type === 'boolean' ? ' check' : '');
|
field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : '');
|
||||||
let read;
|
let read;
|
||||||
|
// For boolean/switch fields, the control other fields may depend on
|
||||||
|
// (showWhen) — returned so the caller can wire visibility.
|
||||||
|
let control = null;
|
||||||
|
// For selects: the element (optionsDependsOn sources) and, with an
|
||||||
|
// optionsRoute, a function that (re)loads the options.
|
||||||
|
let selectEl = null;
|
||||||
|
let refetch = null;
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'switch') {
|
||||||
|
// Same toggle as the module enable switch, inline with its label.
|
||||||
|
const toggle = document.createElement('span');
|
||||||
|
toggle.className = 'switch';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.checked = Boolean(value);
|
||||||
|
const track = document.createElement('span');
|
||||||
|
track.className = 'track';
|
||||||
|
toggle.append(input, track);
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'switch-label';
|
||||||
|
text.textContent = label;
|
||||||
|
field.append(toggle, text);
|
||||||
|
read = () => input.checked;
|
||||||
|
control = input;
|
||||||
|
} else if (type === 'boolean') {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'checkbox';
|
input.type = 'checkbox';
|
||||||
input.checked = Boolean(value);
|
input.checked = Boolean(value);
|
||||||
field.append(input, document.createTextNode(label));
|
field.append(input, document.createTextNode(label));
|
||||||
read = () => input.checked;
|
read = () => input.checked;
|
||||||
|
control = input;
|
||||||
} else if (type === 'endpoint') {
|
} else if (type === 'endpoint') {
|
||||||
// One consistent "Host / IP : Port" control for every module's upstream.
|
// One consistent "Host / IP : Port" control for every module's upstream.
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
@@ -122,19 +146,69 @@ function fieldFor(key, spec, value, passwordSet) {
|
|||||||
row.append(host, sep, port);
|
row.append(host, sep, port);
|
||||||
field.append(span, row);
|
field.append(span, row);
|
||||||
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
|
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
|
||||||
} else if (type === 'select' && Array.isArray(spec.options)) {
|
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
for (const opt of spec.options) {
|
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
|
||||||
|
const fill = (options) => {
|
||||||
|
// Keep what the admin has picked but not yet saved across a refill
|
||||||
|
// (a dependent select refetching), else the saved value.
|
||||||
|
const saved = String(value ?? '');
|
||||||
|
const wanted = select.options.length ? select.value : saved;
|
||||||
|
select.innerHTML = '';
|
||||||
|
// The saved value must stay selectable even when it isn't among the
|
||||||
|
// live options (device unplugged, module route down).
|
||||||
|
if (saved && !options.some((opt) => optValue(opt) === saved)) {
|
||||||
|
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
|
||||||
|
}
|
||||||
|
// Nothing saved yet → a blank "choose" entry, unless the module already
|
||||||
|
// offers a blank option of its own (an "Automatic" default, say).
|
||||||
|
if (!saved && !options.some((opt) => optValue(opt) === '')) {
|
||||||
|
options = [{ value: '', label: '— choose —' }, ...options];
|
||||||
|
}
|
||||||
|
// Options may carry a `group` (a folder path, a device class): options
|
||||||
|
// sharing one render under a single <optgroup>, in first-seen order.
|
||||||
|
const groups = new Map();
|
||||||
|
for (const opt of options) {
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = String(typeof opt === 'object' ? opt.value : opt);
|
o.value = optValue(opt);
|
||||||
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||||
|
const g = typeof opt === 'object' && opt.group ? String(opt.group) : '';
|
||||||
|
if (!g) {
|
||||||
select.appendChild(o);
|
select.appendChild(o);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!groups.has(g)) {
|
||||||
|
const og = document.createElement('optgroup');
|
||||||
|
og.label = g;
|
||||||
|
groups.set(g, og);
|
||||||
|
select.appendChild(og);
|
||||||
|
}
|
||||||
|
groups.get(g).appendChild(o);
|
||||||
|
}
|
||||||
|
select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved;
|
||||||
|
};
|
||||||
|
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||||
|
if (spec.optionsRoute && moduleId) {
|
||||||
|
// Options discovered at runtime (audio devices, ports, sources…):
|
||||||
|
// the module serves them from one of its own routes. With
|
||||||
|
// optionsDependsOn the caller triggers the fetch (and re-fetches when
|
||||||
|
// that other field changes), passing its value as a query parameter.
|
||||||
|
refetch = (query) => {
|
||||||
|
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
|
||||||
|
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((body) => {
|
||||||
|
if (Array.isArray(body?.options)) fill(body.options);
|
||||||
|
})
|
||||||
|
.catch(() => { /* keep the static/saved options */ });
|
||||||
|
};
|
||||||
|
if (!spec.optionsDependsOn) refetch();
|
||||||
}
|
}
|
||||||
select.value = String(value ?? '');
|
|
||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
read = () => select.value;
|
read = () => select.value;
|
||||||
|
selectEl = select;
|
||||||
} else {
|
} else {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
@@ -149,7 +223,32 @@ function fieldFor(key, spec, value, passwordSet) {
|
|||||||
field.append(span, input);
|
field.append(span, input);
|
||||||
read = () => (type === 'number' ? Number(input.value) : input.value);
|
read = () => (type === 'number' ? Number(input.value) : input.value);
|
||||||
}
|
}
|
||||||
return { field, read };
|
if (spec?.help) {
|
||||||
|
// One line of guidance under the control (allowed placeholders, where a
|
||||||
|
// value comes from…) — the label stays short.
|
||||||
|
const help = document.createElement('small');
|
||||||
|
help.className = 'field-help';
|
||||||
|
help.textContent = spec.help;
|
||||||
|
field.appendChild(help);
|
||||||
|
}
|
||||||
|
return { field, read, control, selectEl, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does a config group have anything entered? Drives `collapsed: "whenSet"`:
|
||||||
|
* a credentials block stays folded once it holds a saved secret or a value
|
||||||
|
* other than the schema default, and opens by itself while still empty.
|
||||||
|
*/
|
||||||
|
function groupHasValues(fields, mod) {
|
||||||
|
return fields.some(({ key, spec }) => {
|
||||||
|
const type = spec?.type || 'string';
|
||||||
|
if (type === 'password') return Boolean(mod.passwordSet[key]);
|
||||||
|
if (type === 'boolean' || type === 'switch') return false;
|
||||||
|
const v = mod.config[key];
|
||||||
|
if (type === 'endpoint') return Boolean(v?.host);
|
||||||
|
if (v === undefined || v === null || String(v) === '') return false;
|
||||||
|
return !('default' in (spec || {})) || String(v) !== String(spec.default);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildModuleCard(mod) {
|
function buildModuleCard(mod) {
|
||||||
@@ -226,10 +325,88 @@ function buildModuleCard(mod) {
|
|||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'module-form';
|
form.className = 'module-form';
|
||||||
const readers = new Map();
|
const readers = new Map();
|
||||||
|
const controls = new Map(); // key → boolean/switch input (for showWhen)
|
||||||
|
const selects = new Map(); // key → select element (optionsDependsOn sources)
|
||||||
|
const dependents = []; // { field, when } to show/hide by a control's state
|
||||||
|
const optionDeps = []; // { refetch, on } selects whose options follow another field
|
||||||
|
const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list)
|
||||||
|
const collapsibles = []; // { el, meta, fields } groups that fold
|
||||||
|
// Fields render in schema order; a `group` starts a labeled subsection
|
||||||
|
// and consecutive same-group fields share it. The manifest's optional
|
||||||
|
// `configGroups[name]` adds { collapsed, columns, help } for that group.
|
||||||
|
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
|
||||||
|
let groupName = null;
|
||||||
|
let container = form;
|
||||||
|
let currentGroup = null;
|
||||||
for (const [key, spec] of schemaKeys) {
|
for (const [key, spec] of schemaKeys) {
|
||||||
const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]);
|
const g = spec?.group || '';
|
||||||
|
if (g !== groupName) {
|
||||||
|
groupName = g;
|
||||||
|
currentGroup = null;
|
||||||
|
if (g) {
|
||||||
|
const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {};
|
||||||
|
const folds = meta.collapsed !== undefined;
|
||||||
|
const group = document.createElement(folds ? 'details' : 'div');
|
||||||
|
group.className = 'field-group';
|
||||||
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
|
title.className = 'field-group-title';
|
||||||
|
title.textContent = g;
|
||||||
|
group.appendChild(title);
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'field-group-body';
|
||||||
|
const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0);
|
||||||
|
if (cols > 1) {
|
||||||
|
body.classList.add('is-columns');
|
||||||
|
body.style.setProperty('--cols', String(cols));
|
||||||
|
}
|
||||||
|
if (meta.help) {
|
||||||
|
const help = document.createElement('div');
|
||||||
|
help.className = 'field-group-help';
|
||||||
|
help.textContent = meta.help;
|
||||||
|
body.appendChild(help);
|
||||||
|
}
|
||||||
|
group.appendChild(body);
|
||||||
|
form.appendChild(group);
|
||||||
|
container = body;
|
||||||
|
currentGroup = { el: group, meta, fields: [] };
|
||||||
|
if (folds) collapsibles.push(currentGroup);
|
||||||
|
} else {
|
||||||
|
container = form;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { field, read, control, selectEl, refetch } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id);
|
||||||
readers.set(key, read);
|
readers.set(key, read);
|
||||||
form.appendChild(field);
|
if (control) controls.set(key, control);
|
||||||
|
if (selectEl) selects.set(key, selectEl);
|
||||||
|
if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn });
|
||||||
|
else if (refetch) reloaders.push(() => refetch());
|
||||||
|
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
||||||
|
if (currentGroup) currentGroup.fields.push({ key, spec });
|
||||||
|
container.appendChild(field);
|
||||||
|
}
|
||||||
|
// A field with showWhen:"K" is visible only while K's toggle is on.
|
||||||
|
for (const dep of dependents) {
|
||||||
|
const ctl = controls.get(dep.when);
|
||||||
|
if (!ctl) continue;
|
||||||
|
const sync = () => { dep.field.hidden = !ctl.checked; };
|
||||||
|
ctl.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
// A select with optionsDependsOn:"K" loads its options with K's current
|
||||||
|
// value as a query parameter, now and whenever K changes (a plan list
|
||||||
|
// that follows the chosen service type).
|
||||||
|
for (const dep of optionDeps) {
|
||||||
|
const src = selects.get(dep.on) || controls.get(dep.on);
|
||||||
|
const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) });
|
||||||
|
if (src) src.addEventListener('change', run);
|
||||||
|
reloaders.push(run);
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
// Collapsible groups: `collapsed: true` starts folded, `false` open,
|
||||||
|
// "whenSet" folded only once the group already holds values.
|
||||||
|
for (const grp of collapsibles) {
|
||||||
|
const c = grp.meta.collapsed;
|
||||||
|
grp.el.open = !(c === true || (c === 'whenSet' && groupHasValues(grp.fields, mod)));
|
||||||
}
|
}
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'module-actions';
|
actions.className = 'module-actions';
|
||||||
@@ -256,6 +433,10 @@ function buildModuleCard(mod) {
|
|||||||
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.';
|
status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.';
|
||||||
status.className = 'admin-status ok';
|
status.className = 'admin-status ok';
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
// What the module can list may have changed (credentials just
|
||||||
|
// saved → the upstream is reachable now): reload the dynamic selects.
|
||||||
|
// The re-init is quick but not instant; give it a beat.
|
||||||
|
setTimeout(() => { for (const reload of reloaders) reload(); }, 800);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = e.message;
|
status.textContent = e.message;
|
||||||
status.className = 'admin-status error';
|
status.className = 'admin-status error';
|
||||||
|
|||||||
@@ -485,6 +485,7 @@ function adminModuleView(id) {
|
|||||||
hasServer: Boolean(man.server),
|
hasServer: Boolean(man.server),
|
||||||
enabled: isEnabled(id),
|
enabled: isEnabled(id),
|
||||||
configSchema: man.configSchema || {},
|
configSchema: man.configSchema || {},
|
||||||
|
configGroups: man.configGroups && typeof man.configGroups === 'object' ? man.configGroups : {},
|
||||||
config: values,
|
config: values,
|
||||||
passwordSet,
|
passwordSet,
|
||||||
mountError: entry?.error || '',
|
mountError: entry?.error || '',
|
||||||
@@ -505,7 +506,7 @@ function applyConfigPatch(id, patch) {
|
|||||||
value = current[key] || '';
|
value = current[key] || '';
|
||||||
}
|
}
|
||||||
if (type === 'number') value = Number(value) || 0;
|
if (type === 'number') value = Number(value) || 0;
|
||||||
else if (type === 'boolean') value = Boolean(value);
|
else if (type === 'boolean' || type === 'switch') value = Boolean(value);
|
||||||
else if (type === 'endpoint') {
|
else if (type === 'endpoint') {
|
||||||
const src = value && typeof value === 'object' ? value : {};
|
const src = value && typeof value === 'object' ? value : {};
|
||||||
value = {
|
value = {
|
||||||
|
|||||||
Reference in New Issue
Block a user