Admin: group ⓘ, group toggles in the title row, status readouts, Theme section
configGroups[name].help now renders as an ⓘ beside the group title (.group-info / .group-info-btn / .group-info-pop): a real labelled button — click pins a popover with the text, hover and focus peek at it, Escape or a click elsewhere closes it, and inside a collapsed group's <summary> it never folds the group. The paragraph above the fields is gone; per-field help stays one small line under its control. configGroups[name].toggle names a switch/boolean field of the group: it moves into the title row, right-aligned (.field-group-toggle), as the group's on/off. While off the body dims to 0.45 (.field-group.is-off) but every field stays editable; the field is not repeated in the body, is saved exactly as before and still drives showWhen. Works with collapsed (the switch never folds the row) and columns. A toggle naming a non-boolean field is ignored. The module card's status line is a .module-status readout: 1px --border, a background a shade lighter than the card, monospace, small radius and padding, with ok / connecting / error as the text (and border) colour. New Theme section near the top: five swatches (.theme-swatch with a miniature bg/panel/accent preview), the current one marked, click POSTs /api/admin/theme and applies at once, errors show on a status line; other admin pages' changes arrive live over /api/events, and the login card already wears the server's theme. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
f79e8abd92
commit
4dde529162
+175
-4
@@ -94,6 +94,35 @@ body { overflow: auto; }
|
|||||||
}
|
}
|
||||||
.module-health.error { color: var(--danger); }
|
.module-health.error { color: var(--danger); }
|
||||||
|
|
||||||
|
/* a module card's own status line ("polling timers on 10.3.11.20:1025",
|
||||||
|
"no ProPresenter host configured"): a readout, so it sits in a bordered
|
||||||
|
monospace box a shade lighter than the card; ok / connecting / error
|
||||||
|
show as the text (and border) colour, the box itself never changes */
|
||||||
|
.module-status {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.module-status:empty { display: none; }
|
||||||
|
.module-status.ok { color: var(--text); }
|
||||||
|
.module-status.connecting {
|
||||||
|
color: var(--warn);
|
||||||
|
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
|
||||||
|
}
|
||||||
|
.module-status.error {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
.module-mount-error {
|
.module-mount-error {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -121,6 +150,10 @@ body { overflow: auto; }
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
.field-group-title {
|
.field-group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
position: relative; /* the ⓘ popover hangs off this row */
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -128,8 +161,82 @@ body { overflow: auto; }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
.field-group-name { min-width: 0; }
|
||||||
.field-group .field:last-child { margin-bottom: 0; }
|
.field-group .field:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* group help (configGroups[name].help): an ⓘ beside the title. Hover or
|
||||||
|
focus peeks at the text; a click pins it open until the next click. */
|
||||||
|
.group-info { display: inline-flex; align-items: center; }
|
||||||
|
.group-info-btn {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: italic 700 10px/1 ui-serif, Georgia, "Times New Roman", serif;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.group-info-btn:hover,
|
||||||
|
.group-info.is-open .group-info-btn { color: var(--text); border-color: var(--text); }
|
||||||
|
.group-info-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.group-info-pop {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 5;
|
||||||
|
max-width: min(440px, 100%);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: color-mix(in srgb, var(--text) 6%, var(--panel));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
white-space: normal;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
/* keeps the popover open while the pointer crosses the gap down to it */
|
||||||
|
.group-info-pop::before { content: ""; position: absolute; left: 0; right: 0; top: -8px; height: 8px; }
|
||||||
|
.group-info:hover .group-info-pop,
|
||||||
|
.group-info-btn:focus-visible + .group-info-pop,
|
||||||
|
.group-info.is-open .group-info-pop { display: block; }
|
||||||
|
|
||||||
|
/* group toggle (configGroups[name].toggle): the named switch sits at the
|
||||||
|
right of the title row as the group's on/off; the body dims while it is
|
||||||
|
off but stays editable */
|
||||||
|
.field-group-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.field-group-toggle .field {
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.field-group-toggle .field.switch-field { flex-direction: row-reverse; gap: 8px; }
|
||||||
|
.field-group-toggle .field.switch-field .switch-label { font-size: 12px; }
|
||||||
|
.field-group-toggle .field.check { flex-direction: row-reverse; gap: 8px; }
|
||||||
|
.field-group.is-off > .field-group-body { opacity: 0.45; transition: opacity 0.15s; }
|
||||||
|
.field-group.has-toggle > .field-group-body { transition: opacity 0.15s; }
|
||||||
|
|
||||||
/* collapsible groups (manifest configGroups[name].collapsed) */
|
/* collapsible groups (manifest configGroups[name].collapsed) */
|
||||||
details.field-group > summary.field-group-title {
|
details.field-group > summary.field-group-title {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -160,18 +267,16 @@ details.field-group > summary.field-group-title:hover { color: var(--text); }
|
|||||||
gap: 4px 16px;
|
gap: 4px 16px;
|
||||||
}
|
}
|
||||||
.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; }
|
.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; } }
|
@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } }
|
||||||
|
|
||||||
.field-group-help,
|
/* one line of guidance under a control (field `help`) */
|
||||||
.field-help {
|
.field-help {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.field-group-help { margin: -4px 0 8px; }
|
|
||||||
.field-help { margin-top: 2px; }
|
|
||||||
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
|
||||||
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
|
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
|
||||||
@@ -312,3 +417,69 @@ a.btn { text-decoration: none; }
|
|||||||
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
.catalog-title { display: flex; align-items: center; gap: 8px; }
|
||||||
.catalog-row .module-desc { margin: 4px 0 2px; }
|
.catalog-row .module-desc { margin: 4px 0 2px; }
|
||||||
.catalog-row .module-health { margin-top: 2px; }
|
.catalog-row .module-health { margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── theme ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
|
||||||
|
accent as a status dot) and the name; the current one wears the accent */
|
||||||
|
.theme-swatches {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.theme-swatch {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.theme-swatch:hover { border-color: var(--muted); }
|
||||||
|
.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.theme-swatch.is-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.theme-preview {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--sw-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--sw-panel) 60%, var(--sw-accent));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.theme-preview::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px; right: 8px; top: 10px; bottom: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--sw-panel);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--sw-panel) 75%, var(--sw-accent));
|
||||||
|
}
|
||||||
|
.theme-preview::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 14px; top: 16px;
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--sw-accent);
|
||||||
|
box-shadow: 0 0 6px var(--sw-accent);
|
||||||
|
}
|
||||||
|
.theme-name { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.theme-swatch.is-current .theme-name::after {
|
||||||
|
content: "current";
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
#theme-status { margin-top: 4px; }
|
||||||
|
|||||||
+246
-11
@@ -22,6 +22,9 @@ const updateStatus = document.getElementById('update-status');
|
|||||||
const updateBtn = document.getElementById('update-btn');
|
const updateBtn = document.getElementById('update-btn');
|
||||||
const updateChanges = document.getElementById('update-changes');
|
const updateChanges = document.getElementById('update-changes');
|
||||||
const shellVersionEl = document.getElementById('shell-version');
|
const shellVersionEl = document.getElementById('shell-version');
|
||||||
|
const themeSection = document.getElementById('theme-section');
|
||||||
|
const themeSwatches = document.getElementById('theme-swatches');
|
||||||
|
const themeStatus = document.getElementById('theme-status');
|
||||||
|
|
||||||
/** The shell version the page loaded with — the update flow watches it change. */
|
/** The shell version the page loaded with — the update flow watches it change. */
|
||||||
let shellVersion = '';
|
let shellVersion = '';
|
||||||
@@ -58,6 +61,7 @@ function showLogin() {
|
|||||||
availableSection.hidden = true;
|
availableSection.hidden = true;
|
||||||
layoutsSection.hidden = true;
|
layoutsSection.hidden = true;
|
||||||
updateCard.hidden = true;
|
updateCard.hidden = true;
|
||||||
|
themeSection.hidden = true;
|
||||||
passcodeInput.focus();
|
passcodeInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +281,99 @@ function groupHasValues(fields, mod) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── group title row: the ⓘ and the group toggle ────────────────────── */
|
||||||
|
|
||||||
|
let groupInfoSeq = 0;
|
||||||
|
|
||||||
|
function closeGroupInfos(except) {
|
||||||
|
for (const open of document.querySelectorAll('.group-info.is-open')) {
|
||||||
|
if (open === except) continue;
|
||||||
|
open.classList.remove('is-open');
|
||||||
|
open.querySelector('.group-info-btn')?.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* configGroups[name].help as an ⓘ beside the group title: click toggles a
|
||||||
|
* small popover with the text (hover and keyboard focus show it too), so a
|
||||||
|
* group can carry a paragraph without pushing its fields down the page. A
|
||||||
|
* real button, labelled, so it works from the keyboard; inside a <summary>
|
||||||
|
* it must never fold the group.
|
||||||
|
*/
|
||||||
|
function groupInfo(groupName, text) {
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'group-info';
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'group-info-btn';
|
||||||
|
btn.textContent = 'i';
|
||||||
|
btn.setAttribute('aria-label', `About ${groupName}`);
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
const pop = document.createElement('span');
|
||||||
|
pop.className = 'group-info-pop';
|
||||||
|
pop.id = `group-info-${++groupInfoSeq}`;
|
||||||
|
pop.setAttribute('role', 'note');
|
||||||
|
pop.textContent = text;
|
||||||
|
btn.setAttribute('aria-describedby', pop.id);
|
||||||
|
wrap.append(btn, pop);
|
||||||
|
const setOpen = (open) => {
|
||||||
|
wrap.classList.toggle('is-open', open);
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
};
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault(); // a click in a <summary> would otherwise fold the group
|
||||||
|
e.stopPropagation();
|
||||||
|
const open = !wrap.classList.contains('is-open');
|
||||||
|
closeGroupInfos(wrap);
|
||||||
|
setOpen(open);
|
||||||
|
});
|
||||||
|
pop.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
wrap.addEventListener('keydown', (e) => {
|
||||||
|
// Enter / Space activate the button (a click); they must not reach a
|
||||||
|
// <summary> above as well. Escape closes and hands focus back.
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
|
||||||
|
if (e.key === 'Escape' && wrap.classList.contains('is-open')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
btn.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A click anywhere else, or Escape, closes an open popover.
|
||||||
|
document.addEventListener('pointerdown', (e) => {
|
||||||
|
if (!(e.target instanceof Element) || !e.target.closest('.group-info')) closeGroupInfos();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeGroupInfos();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* configGroups[name].toggle: the named switch/boolean field is the group's
|
||||||
|
* on/off. It moves into the title row, right-aligned, and the body dims
|
||||||
|
* while it is off (fields stay editable). The field itself is untouched —
|
||||||
|
* it is still read on save and still drives showWhen dependents.
|
||||||
|
*/
|
||||||
|
function mountGroupToggle(group, field, control) {
|
||||||
|
const slot = document.createElement('span');
|
||||||
|
slot.className = 'field-group-toggle';
|
||||||
|
slot.appendChild(field);
|
||||||
|
// Inside a <summary>, flipping the switch must not fold the group.
|
||||||
|
slot.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
slot.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') e.stopPropagation();
|
||||||
|
});
|
||||||
|
group.title.appendChild(slot);
|
||||||
|
group.el.classList.add('has-toggle');
|
||||||
|
const sync = () => group.el.classList.toggle('is-off', !control.checked);
|
||||||
|
control.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
function buildModuleCard(mod) {
|
function buildModuleCard(mod) {
|
||||||
const card = document.createElement('section');
|
const card = document.createElement('section');
|
||||||
card.className = 'admin-card';
|
card.className = 'admin-card';
|
||||||
@@ -351,8 +448,11 @@ function buildModuleCard(mod) {
|
|||||||
desc.textContent = mod.description;
|
desc.textContent = mod.description;
|
||||||
card.appendChild(desc);
|
card.appendChild(desc);
|
||||||
|
|
||||||
|
// The module's own words about its upstream ("polling timers on
|
||||||
|
// 10.3.11.20:1025") — a readout, so it sits in a bordered monospace box
|
||||||
|
// and the state (ok / connecting / error) is its text colour.
|
||||||
const healthEl = document.createElement('div');
|
const healthEl = document.createElement('div');
|
||||||
healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
healthEl.className = 'module-status ' + healthClass(mod);
|
||||||
healthEl.textContent = healthText(mod);
|
healthEl.textContent = healthText(mod);
|
||||||
card.appendChild(healthEl);
|
card.appendChild(healthEl);
|
||||||
|
|
||||||
@@ -395,7 +495,9 @@ function buildModuleCard(mod) {
|
|||||||
const collapsibles = []; // { el, meta, fields } groups that fold
|
const collapsibles = []; // { el, meta, fields } groups that fold
|
||||||
// Fields render in schema order; a `group` starts a labeled subsection
|
// Fields render in schema order; a `group` starts a labeled subsection
|
||||||
// and consecutive same-group fields share it. The manifest's optional
|
// and consecutive same-group fields share it. The manifest's optional
|
||||||
// `configGroups[name]` adds { collapsed, columns, help } for that group.
|
// `configGroups[name]` adds { collapsed, columns, help, toggle } for
|
||||||
|
// that group: help is an ⓘ beside the title, toggle names the switch
|
||||||
|
// that is the group's on/off and lives in the title row.
|
||||||
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
|
const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {};
|
||||||
let groupName = null;
|
let groupName = null;
|
||||||
let container = form;
|
let container = form;
|
||||||
@@ -412,7 +514,11 @@ function buildModuleCard(mod) {
|
|||||||
group.className = 'field-group';
|
group.className = 'field-group';
|
||||||
const title = document.createElement(folds ? 'summary' : 'div');
|
const title = document.createElement(folds ? 'summary' : 'div');
|
||||||
title.className = 'field-group-title';
|
title.className = 'field-group-title';
|
||||||
title.textContent = g;
|
const titleName = document.createElement('span');
|
||||||
|
titleName.className = 'field-group-name';
|
||||||
|
titleName.textContent = g;
|
||||||
|
title.appendChild(titleName);
|
||||||
|
if (meta.help) title.appendChild(groupInfo(g, String(meta.help)));
|
||||||
group.appendChild(title);
|
group.appendChild(title);
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'field-group-body';
|
body.className = 'field-group-body';
|
||||||
@@ -421,16 +527,16 @@ function buildModuleCard(mod) {
|
|||||||
body.classList.add('is-columns');
|
body.classList.add('is-columns');
|
||||||
body.style.setProperty('--cols', String(cols));
|
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);
|
group.appendChild(body);
|
||||||
form.appendChild(group);
|
form.appendChild(group);
|
||||||
container = body;
|
container = body;
|
||||||
currentGroup = { el: group, meta, fields: [] };
|
currentGroup = {
|
||||||
|
el: group,
|
||||||
|
title,
|
||||||
|
meta,
|
||||||
|
fields: [],
|
||||||
|
toggleKey: typeof meta.toggle === 'string' ? meta.toggle : '',
|
||||||
|
};
|
||||||
if (folds) collapsibles.push(currentGroup);
|
if (folds) collapsibles.push(currentGroup);
|
||||||
} else {
|
} else {
|
||||||
container = form;
|
container = form;
|
||||||
@@ -444,8 +550,14 @@ function buildModuleCard(mod) {
|
|||||||
else if (refetch) reloaders.push(() => refetch());
|
else if (refetch) reloaders.push(() => refetch());
|
||||||
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
|
||||||
if (currentGroup) currentGroup.fields.push({ key, spec });
|
if (currentGroup) currentGroup.fields.push({ key, spec });
|
||||||
|
if (currentGroup && control && key === currentGroup.toggleKey) {
|
||||||
|
// The group's on/off: title row, not the body (never both). A
|
||||||
|
// toggle naming a non-boolean field is ignored and renders normally.
|
||||||
|
mountGroupToggle(currentGroup, field, control);
|
||||||
|
} else {
|
||||||
container.appendChild(field);
|
container.appendChild(field);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// A field with showWhen:"K" is visible only while K's toggle is on.
|
// A field with showWhen:"K" is visible only while K's toggle is on.
|
||||||
for (const dep of dependents) {
|
for (const dep of dependents) {
|
||||||
const ctl = controls.get(dep.when);
|
const ctl = controls.get(dep.when);
|
||||||
@@ -531,7 +643,7 @@ async function refreshStatus() {
|
|||||||
if (!els) continue;
|
if (!els) continue;
|
||||||
els.dot.className = 'dot ' + healthClass(mod);
|
els.dot.className = 'dot ' + healthClass(mod);
|
||||||
els.healthEl.textContent = healthText(mod);
|
els.healthEl.textContent = healthText(mod);
|
||||||
els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : '');
|
els.healthEl.className = 'module-status ' + healthClass(mod);
|
||||||
if (!mod.incompatible) {
|
if (!mod.incompatible) {
|
||||||
els.mountError.hidden = !mod.mountError;
|
els.mountError.hidden = !mod.mountError;
|
||||||
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : '';
|
||||||
@@ -640,10 +752,133 @@ async function loadState() {
|
|||||||
for (const mod of state.modules || []) {
|
for (const mod of state.modules || []) {
|
||||||
modulesList.appendChild(buildModuleCard(mod));
|
modulesList.appendChild(buildModuleCard(mod));
|
||||||
}
|
}
|
||||||
|
loadTheme();
|
||||||
loadLayouts();
|
loadLayouts();
|
||||||
loadCatalog(false);
|
loadCatalog(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── theme ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* The dashboard palette is a server-wide choice. The server lists the
|
||||||
|
presets (GET /api/theme — public, dashboards need it too) and the admin
|
||||||
|
page shows them as swatches; picking one POSTs it, applies it here at
|
||||||
|
once, and the server's `theme` shell event switches every open dashboard
|
||||||
|
— and any other admin page — live. The palettes themselves are
|
||||||
|
html[data-theme="…"] variable sets in the shell's style.css, which this
|
||||||
|
page shares, so the admin page follows its own choice. */
|
||||||
|
|
||||||
|
const LS_THEME = 'proddash:theme';
|
||||||
|
let themeList = [];
|
||||||
|
let currentTheme = '';
|
||||||
|
|
||||||
|
function applyTheme(id) {
|
||||||
|
const theme = String(id || '');
|
||||||
|
if (!/^[a-z][a-z0-9-]{0,31}$/.test(theme)) return;
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
try { localStorage.setItem(LS_THEME, theme); } catch { /* private mode — fine */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last-known theme first (shared with the dashboard, same origin), so
|
||||||
|
// the page doesn't flash the default palette before the server answers.
|
||||||
|
try { applyTheme(localStorage.getItem(LS_THEME)); } catch { /* fine */ }
|
||||||
|
|
||||||
|
function themeName(id) {
|
||||||
|
return themeList.find((t) => t.id === id)?.name || id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThemes() {
|
||||||
|
themeSwatches.innerHTML = '';
|
||||||
|
for (const t of themeList) {
|
||||||
|
const current = t.id === currentTheme;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'theme-swatch' + (current ? ' is-current' : '');
|
||||||
|
btn.setAttribute('role', 'radio');
|
||||||
|
btn.setAttribute('aria-checked', current ? 'true' : 'false');
|
||||||
|
btn.title = current ? `${t.name} — the current theme` : `Switch every dashboard to ${t.name}`;
|
||||||
|
const preview = document.createElement('span');
|
||||||
|
preview.className = 'theme-preview';
|
||||||
|
preview.style.setProperty('--sw-bg', String(t.colors?.bg || '#000'));
|
||||||
|
preview.style.setProperty('--sw-panel', String(t.colors?.panel || '#222'));
|
||||||
|
preview.style.setProperty('--sw-accent', String(t.colors?.accent || '#fff'));
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'theme-name';
|
||||||
|
name.textContent = t.name;
|
||||||
|
btn.append(preview, name);
|
||||||
|
btn.addEventListener('click', () => chooseTheme(t.id));
|
||||||
|
themeSwatches.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the server's theme and list. Applied to this page either way (the
|
||||||
|
* endpoint is public, so even the login card wears the right palette); the
|
||||||
|
* swatch section itself is only revealed for an unlocked page.
|
||||||
|
*/
|
||||||
|
async function loadTheme(reveal = true) {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await api('/api/theme');
|
||||||
|
} catch {
|
||||||
|
if (reveal) themeSection.hidden = true; // a server without themes — nothing to offer
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
themeList = Array.isArray(body.themes) ? body.themes.filter((t) => t && t.id && t.name) : [];
|
||||||
|
currentTheme = String(body.theme || '');
|
||||||
|
applyTheme(currentTheme);
|
||||||
|
renderThemes();
|
||||||
|
if (reveal) themeSection.hidden = !themeList.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTheme(false);
|
||||||
|
|
||||||
|
async function chooseTheme(id) {
|
||||||
|
if (id === currentTheme) return;
|
||||||
|
const previous = currentTheme;
|
||||||
|
currentTheme = id;
|
||||||
|
applyTheme(id); // at once, here — the server's event confirms it everywhere else
|
||||||
|
renderThemes();
|
||||||
|
themeStatus.textContent = '';
|
||||||
|
themeStatus.className = 'admin-status';
|
||||||
|
try {
|
||||||
|
await api('/api/admin/theme', { method: 'POST', body: JSON.stringify({ theme: id }) });
|
||||||
|
themeStatus.textContent = `${themeName(id)} — every open dashboard switched.`;
|
||||||
|
themeStatus.className = 'admin-status ok';
|
||||||
|
} catch (e) {
|
||||||
|
currentTheme = previous;
|
||||||
|
applyTheme(previous);
|
||||||
|
renderThemes();
|
||||||
|
themeStatus.textContent = e.message;
|
||||||
|
themeStatus.className = 'admin-status error';
|
||||||
|
if (e.authRequired) showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow a theme change made elsewhere (another admin page) live.
|
||||||
|
let themeEvents = null;
|
||||||
|
let themeEventsRetry = null;
|
||||||
|
|
||||||
|
function subscribeThemeEvents() {
|
||||||
|
try { themeEvents?.close(); } catch { /* not open */ }
|
||||||
|
themeEvents = new EventSource('/api/events');
|
||||||
|
themeEvents.addEventListener('theme', (e) => {
|
||||||
|
let theme = '';
|
||||||
|
try { theme = String(JSON.parse(e.data).theme || ''); } catch { return; }
|
||||||
|
if (!theme || theme === currentTheme) return;
|
||||||
|
currentTheme = theme;
|
||||||
|
applyTheme(theme);
|
||||||
|
renderThemes();
|
||||||
|
});
|
||||||
|
themeEvents.onerror = () => {
|
||||||
|
if (themeEvents.readyState === EventSource.CLOSED) {
|
||||||
|
clearTimeout(themeEventsRetry);
|
||||||
|
themeEventsRetry = setTimeout(subscribeThemeEvents, 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeThemeEvents();
|
||||||
|
|
||||||
/* ── the repo: available modules, module updates, shell update ──────── */
|
/* ── the repo: available modules, module updates, shell update ──────── */
|
||||||
|
|
||||||
function fmtTime(ms) {
|
function fmtTime(ms) {
|
||||||
|
|||||||
@@ -39,6 +39,15 @@
|
|||||||
<div class="admin-status" id="update-status"></div>
|
<div class="admin-status" id="update-status"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="theme-section" hidden>
|
||||||
|
<h1 class="admin-h1">Theme</h1>
|
||||||
|
<p class="admin-copy">The palette every dashboard on the network uses — switched live, no reload. Modules
|
||||||
|
draw with the shell's colours, so they follow; a colour chosen on a tile itself (a PCO Plan header, say)
|
||||||
|
stays as set.</p>
|
||||||
|
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||||
|
<div class="admin-status" id="theme-status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="modules-section" hidden>
|
<section id="modules-section" hidden>
|
||||||
<h1 class="admin-h1">Modules</h1>
|
<h1 class="admin-h1">Modules</h1>
|
||||||
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
<p class="admin-copy">Server-wide settings, shared by every dashboard. Saving re-initialises the
|
||||||
|
|||||||
Reference in New Issue
Block a user