Web UI: install/reconfigure addons, default-on install, visual redesign (v2.17.0)
Web UI now installs by default during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in, and can install/ reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates - the addons and Update action asked for by name. Privilege model: the web service itself still runs as $KIOSK_USER with zero ambient sudo. A new narrow, allow-listed root helper (menus/addon_webui.sh's webui_write_helper_script) is the only way it ever gains privilege, reachable only via a single-path passwordless sudo rule generated and validated with `visudo -c -f` before being installed, and it re-checks its own fixed action allow-list before dispatching anything. Each allow-listed action is the exact same interactive action_* function the terminal menu already uses, driven by piping the right answers on stdin - the same technique this project's own bash tests already use, so no prompt/mutation refactor of any addon file was needed. webui/lib/actions.js's stdin sequences were cross-validated against the real bash functions (not just read), which caught two real bugs (Squeezelite and Asterisk Intercom both silently lost their "decline reconfigure" path). Long-running installs stream live output via Server-Sent Events (webui/lib/jobs.js), one action at a time. Full visual redesign: a sidebar shell (Sites/Display/Lockout/Addons/ Update) replacing the single scrolling page, light+dark themes via prefers-color-scheme, no external font/CDN dependency. Actually driving the redesigned UI in a headless browser (not just reading the code) caught a real bug: refreshing an addon's pill/button after a successful install used to rebuild the whole card, racing (and usually losing to) the success status/log that job had just written. Fixed to update pill/buttons in place. Uninstall-via-web is deliberately still not offered, for any addon. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
This commit is contained in:
+371
-13
@@ -1,10 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
||||
// user-controlled value (site URL/name/username) is set via .value or
|
||||
// .textContent, never innerHTML, so nothing typed into a site name or
|
||||
// URL can execute as markup - the one new attack surface a browser-based
|
||||
// config UI has that the terminal menus never did.
|
||||
// user-controlled value (site URL/name/username, addon form fields) is
|
||||
// set via .value or .textContent, never innerHTML, so nothing typed
|
||||
// into a form can execute as markup - the one new attack surface a
|
||||
// browser-based config UI has that the terminal menus never did.
|
||||
|
||||
const msgEl = document.getElementById('msg');
|
||||
let currentConfig = null;
|
||||
@@ -17,6 +17,25 @@ function showMessage(text, isError) {
|
||||
showMessage._t = setTimeout(() => { msgEl.hidden = true; }, 5000);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Sidebar navigation */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.nav-item').forEach((b) => b.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(`page-${btn.dataset.page}`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('brand-sub').textContent = location.host || 'this kiosk';
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Config API (Sites / Display / Lockout) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
async function apiGet() {
|
||||
const res = await fetch('/api/config');
|
||||
if (!res.ok) throw new Error('Failed to load configuration');
|
||||
@@ -95,7 +114,6 @@ function collectTabs() {
|
||||
tab.username = row.querySelector('.site-auth-username').value;
|
||||
const newPass = row.querySelector('.site-auth-password').value;
|
||||
if (newPass) tab.password = newPass;
|
||||
// else: omit `password` entirely - server keeps the existing one.
|
||||
} else {
|
||||
tab.username = '';
|
||||
tab.password = '';
|
||||
@@ -104,10 +122,6 @@ function collectTabs() {
|
||||
});
|
||||
}
|
||||
|
||||
// A "Save" per row would be simpler individually, but sites.sh's own
|
||||
// save_config always rewrites the whole tabs array too - this mirrors
|
||||
// that, saving all sites (and the home-page selection they feed into)
|
||||
// together whenever anything in the Sites section changes.
|
||||
async function saveSites() {
|
||||
const tabs = collectTabs();
|
||||
if (tabs.some((t) => !t.url)) {
|
||||
@@ -227,10 +241,6 @@ lockoutForm.addEventListener('submit', async (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Populate forms from a config snapshot */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
function populateAll(config) {
|
||||
displayForm.elements.swipeMode.value = config.swipeMode;
|
||||
displayForm.elements.allowNavigation.value = config.allowNavigation;
|
||||
@@ -251,6 +261,352 @@ function populateAll(config) {
|
||||
refreshLockoutFieldVisibility();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Shared: run a privileged action and stream its log via SSE */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
// jobPanelEls = { panel, status, log }. Returns a promise resolving to
|
||||
// {status, exitCode} once the job finishes (or rejects on a request-level
|
||||
// error before a job even started, e.g. validation).
|
||||
async function runAction(actionName, fields, jobPanelEls) {
|
||||
const res = await fetch(`/api/actions/${actionName}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields || {}),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || 'Could not start action');
|
||||
|
||||
jobPanelEls.panel.classList.add('open');
|
||||
jobPanelEls.log.textContent = '';
|
||||
setJobStatus(jobPanelEls.status, 'running');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const source = new EventSource(`/api/actions/jobs/${body.jobId}/stream`);
|
||||
source.addEventListener('log', (ev) => {
|
||||
jobPanelEls.log.textContent += JSON.parse(ev.data);
|
||||
jobPanelEls.log.scrollTop = jobPanelEls.log.scrollHeight;
|
||||
});
|
||||
source.addEventListener('done', (ev) => {
|
||||
const result = JSON.parse(ev.data);
|
||||
setJobStatus(jobPanelEls.status, result.status);
|
||||
source.close();
|
||||
resolve(result);
|
||||
});
|
||||
source.onerror = () => {
|
||||
source.close();
|
||||
reject(new Error('Lost connection to the log stream'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setJobStatus(el, status) {
|
||||
el.className = `job-status ${status}`;
|
||||
if (status === 'running') {
|
||||
el.innerHTML = '';
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'spinner';
|
||||
el.appendChild(spinner);
|
||||
el.appendChild(document.createTextNode('Running'));
|
||||
} else {
|
||||
el.textContent = status === 'success' ? 'Success' : 'Failed';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Addons */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const addonsList = document.getElementById('addons-list');
|
||||
const addonCardTemplate = document.getElementById('addon-card-template');
|
||||
|
||||
const ICONS = {
|
||||
printer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V3h12v6M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="7"/></svg>',
|
||||
music: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>',
|
||||
speaker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><circle cx="12" cy="14" r="4"/><circle cx="12" cy="6" r="1"/></svg>',
|
||||
phone: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .3 2 .6 2.9a2 2 0 0 1-.5 2.1L8 9.9a16 16 0 0 0 6 6l1.2-1.2a2 2 0 0 1 2.1-.5c.9.3 1.9.5 2.9.6a2 2 0 0 1 1.8 2Z"/></svg>',
|
||||
};
|
||||
|
||||
function pillHtml(state) {
|
||||
if (state === true) return '<span class="pill installed">Installed</span>';
|
||||
if (state === false) return '<span class="pill not-installed">Not installed</span>';
|
||||
return '<span class="pill unknown">Unknown</span>';
|
||||
}
|
||||
|
||||
let addonStatus = {};
|
||||
|
||||
async function loadAddonStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/addons/status');
|
||||
if (res.ok) addonStatus = await res.json();
|
||||
} catch (e) {
|
||||
// leave addonStatus as-is (pills show "Unknown"); not fatal to the page
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by addon, so a single card can be refreshed in place after its
|
||||
// own job finishes (see refreshAddonCard) without touching the other
|
||||
// three, and - critically - without recreating the job-panel/log the
|
||||
// user is currently looking at. An earlier version called the full
|
||||
// renderAddons() rebuild after every successful job "to update the
|
||||
// pill"; that raced (and usually lost to) the same success/log state it
|
||||
// had just written a moment earlier, since rebuilding the whole list
|
||||
// replaces the job-panel node with a fresh empty one. Caught by an
|
||||
// actual headless-browser run, not just reading the code - the log
|
||||
// looked fine reading it, but watching it in Chromium showed the
|
||||
// "Success" state flash and vanish.
|
||||
const ADDON_RECIPES = {};
|
||||
|
||||
function buildAddonCard(recipe) {
|
||||
ADDON_RECIPES[recipe.key] = recipe;
|
||||
const node = addonCardTemplate.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.addon = recipe.key;
|
||||
fillAddonCard(node, recipe);
|
||||
return node;
|
||||
}
|
||||
|
||||
// (Re)fills everything in a card EXCEPT the job-panel/log, which is
|
||||
// left exactly as it is - so refreshing a card's install-state after a
|
||||
// job finishes doesn't erase the result the user just watched stream in.
|
||||
function fillAddonCard(node, { key, title, desc, icon, buildForm }) {
|
||||
node.querySelector('.addon-icon').innerHTML = ICONS[icon];
|
||||
node.querySelector('.addon-name').textContent = title;
|
||||
node.querySelector('.addon-desc').textContent = desc;
|
||||
node.querySelector('.pill').outerHTML = pillHtml(addonStatus[key]);
|
||||
|
||||
const actionsEl = node.querySelector('.addon-actions');
|
||||
const formEl = node.querySelector('.addon-form');
|
||||
actionsEl.textContent = '';
|
||||
formEl.textContent = '';
|
||||
formEl.className = 'addon-form';
|
||||
|
||||
const jobPanelEls = {
|
||||
panel: node.querySelector('.job-panel'),
|
||||
status: node.querySelector('.job-status'),
|
||||
log: node.querySelector('.job-log'),
|
||||
};
|
||||
buildForm({ node, actionsEl, formEl, jobPanelEls, installed: addonStatus[key] === true });
|
||||
}
|
||||
|
||||
// Called after one addon's own job finishes - refreshes just that
|
||||
// card's pill/buttons/form (e.g. "Install" -> "Reconfigure") in place.
|
||||
async function refreshAddonCard(key) {
|
||||
await loadAddonStatus();
|
||||
const node = addonsList.querySelector(`[data-addon="${key}"]`);
|
||||
if (node) fillAddonCard(node, ADDON_RECIPES[key]);
|
||||
}
|
||||
|
||||
function addSubmitAction(formEl, jobPanelEls, actionName, collectFields, onDone) {
|
||||
formEl.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = formEl.querySelector('button[type=submit]');
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const result = await runAction(actionName, collectFields(), jobPanelEls);
|
||||
if (result.status === 'success') {
|
||||
showMessage('Done');
|
||||
if (onDone) await onDone();
|
||||
} else {
|
||||
showMessage('Action failed - see the log below', true);
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAddons() {
|
||||
addonsList.textContent = '';
|
||||
|
||||
// CUPS: no fields at all - the button itself is the whole form.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'cups', title: 'CUPS Printing', desc: 'Network printer sharing', icon: 'printer',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = installed ? 'Reconfigure for network access' : 'Install CUPS Printing';
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const action = installed ? 'reconfigure_cups' : 'install_cups';
|
||||
const result = await runAction(action, {}, jobPanelEls);
|
||||
if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('cups'); }
|
||||
else showMessage('Action failed - see the log below', true);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
actionsEl.appendChild(btn);
|
||||
},
|
||||
}));
|
||||
|
||||
// LMS Server: fresh install has no fields; once installed, an
|
||||
// optional port-reconfigure field.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'lms', title: 'LMS Server', desc: 'Lyrion / Logitech Media Server', icon: 'music',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
if (!installed) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Install LMS Server';
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await runAction('install_lms', { alreadyInstalled: false }, jobPanelEls);
|
||||
if (result.status === 'success') { showMessage('Done'); await refreshAddonCard('lms'); }
|
||||
else showMessage('Action failed - see the log below', true);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
actionsEl.appendChild(btn);
|
||||
return;
|
||||
}
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'secondary';
|
||||
toggleBtn.textContent = 'Reconfigure port';
|
||||
toggleBtn.addEventListener('click', () => formEl.classList.toggle('open'));
|
||||
actionsEl.appendChild(toggleBtn);
|
||||
|
||||
const portLabel = document.createElement('label');
|
||||
portLabel.innerHTML = 'New HTTP port';
|
||||
const portInput = document.createElement('input');
|
||||
portInput.type = 'number'; portInput.min = '1'; portInput.max = '65535'; portInput.value = '9000';
|
||||
portLabel.appendChild(portInput);
|
||||
formEl.appendChild(portLabel);
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit';
|
||||
submitBtn.textContent = 'Apply new port';
|
||||
formEl.appendChild(submitBtn);
|
||||
|
||||
addSubmitAction(formEl, jobPanelEls, 'install_lms', () => ({
|
||||
alreadyInstalled: true, reconfigurePort: true, newPort: parseInt(portInput.value, 10),
|
||||
}), () => refreshAddonCard('lms'));
|
||||
},
|
||||
}));
|
||||
|
||||
// Squeezelite: player name + LMS server, both for install and
|
||||
// reconfigure - the form is the same either way.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'squeezelite', title: 'Squeezelite Player', desc: 'Turns this kiosk into an LMS-connected speaker', icon: 'speaker',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
formEl.classList.add('open');
|
||||
|
||||
const nameLabel = document.createElement('label');
|
||||
nameLabel.textContent = 'Player name';
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.type = 'text'; nameInput.value = 'Kiosk'; nameInput.placeholder = 'Kiosk';
|
||||
nameLabel.appendChild(nameInput);
|
||||
formEl.appendChild(nameLabel);
|
||||
|
||||
const serverLabel = document.createElement('label');
|
||||
serverLabel.innerHTML = 'LMS server <span class="hint">(IP:PORT, blank for auto-discovery)</span>';
|
||||
const serverInput = document.createElement('input');
|
||||
serverInput.type = 'text'; serverInput.placeholder = '192.168.1.100:3483';
|
||||
serverLabel.appendChild(serverInput);
|
||||
formEl.appendChild(serverLabel);
|
||||
|
||||
const rebootHint = document.createElement('p');
|
||||
rebootHint.className = 'hint';
|
||||
rebootHint.textContent = 'A reboot is required after install/reconfigure before Squeezelite starts.';
|
||||
formEl.appendChild(rebootHint);
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit';
|
||||
submitBtn.textContent = installed ? 'Reconfigure Squeezelite' : 'Install Squeezelite';
|
||||
formEl.appendChild(submitBtn);
|
||||
|
||||
addSubmitAction(formEl, jobPanelEls, 'install_squeezelite', () => ({
|
||||
alreadyInstalled: installed, reconfigure: true,
|
||||
playerName: nameInput.value.trim(), lmsServer: serverInput.value.trim(),
|
||||
}), () => refreshAddonCard('squeezelite'));
|
||||
},
|
||||
}));
|
||||
|
||||
// Asterisk Intercom: server/extension/password/options, always shown.
|
||||
addonsList.appendChild(buildAddonCard({
|
||||
key: 'asterisk_intercom', title: 'Asterisk Intercom', desc: 'SIP extension client (Baresip)', icon: 'phone',
|
||||
buildForm({ actionsEl, formEl, jobPanelEls, installed }) {
|
||||
formEl.classList.add('open');
|
||||
|
||||
const mk = (label, type, opts) => {
|
||||
const l = document.createElement('label');
|
||||
l.textContent = label;
|
||||
const i = document.createElement('input');
|
||||
i.type = type;
|
||||
Object.assign(i, opts || {});
|
||||
l.appendChild(i);
|
||||
formEl.appendChild(l);
|
||||
return i;
|
||||
};
|
||||
const ip = mk('Server IP or hostname', 'text', { placeholder: '10.0.0.5' });
|
||||
const port = mk('Server port', 'number', { placeholder: '5060', min: '1', max: '65535' });
|
||||
const ext = mk('Extension number', 'text', { placeholder: '201' });
|
||||
const pass = mk('SIP password', 'password', { autocomplete: 'new-password' });
|
||||
|
||||
const autoAnswerLabel = document.createElement('label');
|
||||
autoAnswerLabel.className = 'checkbox';
|
||||
const autoAnswer = document.createElement('input');
|
||||
autoAnswer.type = 'checkbox';
|
||||
autoAnswerLabel.appendChild(autoAnswer);
|
||||
autoAnswerLabel.appendChild(document.createTextNode('Auto-answer incoming calls (intercom mode)'));
|
||||
formEl.appendChild(autoAnswerLabel);
|
||||
|
||||
const tlsLabel = document.createElement('label');
|
||||
tlsLabel.className = 'checkbox';
|
||||
const useTls = document.createElement('input');
|
||||
useTls.type = 'checkbox';
|
||||
tlsLabel.appendChild(useTls);
|
||||
tlsLabel.appendChild(document.createTextNode('Use TLS encryption'));
|
||||
formEl.appendChild(tlsLabel);
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit';
|
||||
submitBtn.textContent = installed ? 'Reconfigure Asterisk Intercom' : 'Connect to Asterisk server';
|
||||
formEl.appendChild(submitBtn);
|
||||
|
||||
addSubmitAction(formEl, jobPanelEls, 'configure_asterisk_intercom', () => ({
|
||||
alreadyInstalled: installed, reconfigure: true,
|
||||
serverIp: ip.value.trim(), serverPort: port.value ? parseInt(port.value, 10) : undefined,
|
||||
extension: ext.value.trim(), password: pass.value,
|
||||
autoAnswer: autoAnswer.checked, useTls: useTls.checked,
|
||||
}), () => refreshAddonCard('asterisk_intercom'));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Update */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
document.getElementById('run-upgrade').addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await runAction('upgrade', {}, {
|
||||
panel: document.getElementById('job-panel-upgrade'),
|
||||
status: document.getElementById('job-status-upgrade'),
|
||||
log: document.getElementById('job-log-upgrade'),
|
||||
});
|
||||
showMessage(result.status === 'success' ? 'Update finished' : 'Update failed - see the log below', result.status !== 'success');
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Init */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
currentConfig = await apiGet();
|
||||
@@ -259,6 +615,8 @@ async function init() {
|
||||
} catch (e) {
|
||||
showMessage(e.message, true);
|
||||
}
|
||||
await loadAddonStatus();
|
||||
renderAddons();
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
+193
-92
@@ -7,100 +7,177 @@
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Kiosk Web UI</h1>
|
||||
<p class="subtitle">Sites, display behavior, and lockout - saved directly to this kiosk's config.json.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="msg" class="banner" hidden></section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Sites & Page Timing</h2>
|
||||
<div id="sites-list"></div>
|
||||
<div class="button-row">
|
||||
<button type="button" id="add-site" class="secondary">Add a page</button>
|
||||
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Display & Interaction</h2>
|
||||
<form id="display-form">
|
||||
<label>
|
||||
Touch gesture mode
|
||||
<select name="swipeMode">
|
||||
<option value="dual">Dual-direction (recommended for touchscreens)</option>
|
||||
<option value="standard">Standard</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Link navigation security
|
||||
<select name="allowNavigation">
|
||||
<option value="restricted">Restricted - only the loaded URL</option>
|
||||
<option value="same-origin">Same-origin - links within the same domain (recommended)</option>
|
||||
<option value="open">Open - any link</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Home page</legend>
|
||||
<label>
|
||||
Home page
|
||||
<select name="homeTabIndex" id="home-tab-select">
|
||||
<option value="-1">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Inactivity timeout (minutes)
|
||||
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Save Display & Interaction</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Password Protection & Lockout</h2>
|
||||
<form id="lockout-form">
|
||||
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
|
||||
|
||||
<div id="lockout-fields">
|
||||
<label>
|
||||
<span id="password-label">Set lockout password</span>
|
||||
<input type="password" name="newLockoutPassword" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Inactivity lockout timeout (minutes, 0 = boot/wake only)
|
||||
<input type="number" name="lockoutTimeoutMinutes" min="0" max="1440" step="1">
|
||||
</label>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="daily-lock-enabled"> Lock at a specific time daily
|
||||
</label>
|
||||
<label>
|
||||
Daily lock time
|
||||
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label>
|
||||
<div class="shell">
|
||||
<nav class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<strong>Kiosk Web UI</strong>
|
||||
<span id="brand-sub">this kiosk</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save Password Protection & Lockout</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<div class="nav">
|
||||
<button class="nav-item active" data-page="sites">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
|
||||
Sites & Timing
|
||||
</button>
|
||||
<button class="nav-item" data-page="display">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="14" rx="2"/><path d="M8 21h8M12 18v3"/></svg>
|
||||
Display
|
||||
</button>
|
||||
<button class="nav-item" data-page="lockout">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
|
||||
Lockout
|
||||
</button>
|
||||
<button class="nav-item" data-page="addons">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
|
||||
Addons
|
||||
</button>
|
||||
<button class="nav-item" data-page="update">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5"/></svg>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">No login of its own - front this with your own reverse proxy + Authelia if it needs to be reachable beyond a trusted LAN.</div>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
<div id="msg" class="banner" hidden></div>
|
||||
|
||||
<!-- Sites & Page Timing -->
|
||||
<section class="page active" id="page-sites">
|
||||
<div class="page-header">
|
||||
<h1>Sites & Page Timing</h1>
|
||||
<p>The pages this kiosk rotates through, and how long each stays on screen.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div id="sites-list"></div>
|
||||
<div class="button-row">
|
||||
<button type="button" id="add-site" class="secondary">Add a page</button>
|
||||
<button type="button" id="save-sites">Save Sites & Page Timing</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display & Interaction -->
|
||||
<section class="page" id="page-display">
|
||||
<div class="page-header">
|
||||
<h1>Display & Interaction</h1>
|
||||
<p>Touch gestures, link navigation, on-screen buttons, and the home page.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<form id="display-form">
|
||||
<label>
|
||||
Touch gesture mode
|
||||
<select name="swipeMode">
|
||||
<option value="dual">Dual-direction (recommended for touchscreens)</option>
|
||||
<option value="standard">Standard</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Link navigation security
|
||||
<select name="allowNavigation">
|
||||
<option value="restricted">Restricted - only the loaded URL</option>
|
||||
<option value="same-origin">Same-origin - links within the same domain (recommended)</option>
|
||||
<option value="open">Open - any link</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" name="enablePauseButton"> Pause button</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enableKeyboardButton"> On-screen keyboard button</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enableNavButton"> Navigation/help button</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Home page</legend>
|
||||
<label>
|
||||
Home page
|
||||
<select name="homeTabIndex" id="home-tab-select">
|
||||
<option value="-1">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Inactivity timeout (minutes)
|
||||
<input type="number" name="inactivityTimeoutMinutes" min="1" max="240" step="1">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="button-row"><button type="submit">Save Display & Interaction</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Password Protection & Lockout -->
|
||||
<section class="page" id="page-lockout">
|
||||
<div class="page-header">
|
||||
<h1>Password Protection & Lockout</h1>
|
||||
<p>Blank the screen after inactivity and require a password to unlock.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<form id="lockout-form">
|
||||
<label class="checkbox"><input type="checkbox" name="enablePasswordProtection" id="lockout-enabled"> Enable password protection</label>
|
||||
|
||||
<div id="lockout-fields">
|
||||
<label>
|
||||
<span id="password-label">Set lockout password</span>
|
||||
<input type="password" name="newLockoutPassword" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input type="password" id="lockout-password-confirm" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Inactivity lockout timeout (minutes, 0 = boot/wake only)
|
||||
<input type="number" name="lockoutTimeoutMinutes" min="0" max="1440" step="1">
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" id="daily-lock-enabled"> Lock at a specific time daily</label>
|
||||
<label>
|
||||
Daily lock time
|
||||
<input type="time" name="lockoutAtTime" id="lockout-at-time" disabled>
|
||||
</label>
|
||||
|
||||
<label class="checkbox"><input type="checkbox" name="requirePasswordOnBoot"> Require password on system boot</label>
|
||||
</div>
|
||||
|
||||
<div class="button-row"><button type="submit">Save Password Protection & Lockout</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Addons -->
|
||||
<section class="page" id="page-addons">
|
||||
<div class="page-header">
|
||||
<h1>Addons</h1>
|
||||
<p>Install and configure the same addons the terminal menu offers. Uninstall isn't available here yet.</p>
|
||||
</div>
|
||||
<div id="addons-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- Update -->
|
||||
<section class="page" id="page-update">
|
||||
<div class="page-header">
|
||||
<h1>Update</h1>
|
||||
<p>Pull the latest code from git and re-apply setup (packages, app files, hardware config).</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="button-row"><button type="button" id="run-upgrade">Check for and apply updates</button></div>
|
||||
<div class="job-panel" id="job-panel-upgrade">
|
||||
<div class="job-panel-header">
|
||||
<span>Update</span>
|
||||
<span class="job-status" id="job-status-upgrade"></span>
|
||||
</div>
|
||||
<pre class="job-log" id="job-log-upgrade"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<template id="site-row-template">
|
||||
<div class="site-row card-inset">
|
||||
@@ -134,6 +211,30 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="addon-card-template">
|
||||
<div class="card addon-card">
|
||||
<div class="addon-card-top">
|
||||
<div class="addon-title">
|
||||
<div class="addon-icon"></div>
|
||||
<div>
|
||||
<div class="addon-name"></div>
|
||||
<div class="addon-desc"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill unknown">checking…</span>
|
||||
</div>
|
||||
<div class="button-row addon-actions"></div>
|
||||
<form class="addon-form"></form>
|
||||
<div class="job-panel">
|
||||
<div class="job-panel-header">
|
||||
<span>Log</span>
|
||||
<span class="job-status"></span>
|
||||
</div>
|
||||
<pre class="job-log"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+302
-89
@@ -1,85 +1,222 @@
|
||||
/* Design tokens - light by default, dark via prefers-color-scheme.
|
||||
No external font/CDN dependency (self-hosted admin tool shouldn't
|
||||
phone out to Google Fonts) - a well-tuned system-ui stack plus real
|
||||
spacing/depth/motion is what actually reads as "modern", not the
|
||||
typeface. */
|
||||
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--panel-inset: #1e222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e6e9ef;
|
||||
--text-dim: #9aa3b2;
|
||||
--accent: #4f8cff;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", ui-sans-serif, Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
|
||||
--bg: #f5f6f8;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-sunken: #eef0f3;
|
||||
--border: #e2e5ea;
|
||||
--border-strong: #cbd0d8;
|
||||
--text: #14161a;
|
||||
--text-dim: #5c6370;
|
||||
--text-faint: #8b929e;
|
||||
--accent: #3b6ff0;
|
||||
--accent-hover: #2f5cd6;
|
||||
--accent-text: #ffffff;
|
||||
--danger: #e5566a;
|
||||
--success: #3fb87f;
|
||||
--error: #e5566a;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--accent-soft: #e8effe;
|
||||
--success: #1c8a5b;
|
||||
--success-soft: #e3f6ec;
|
||||
--warning: #a9660a;
|
||||
--warning-soft: #fdf1de;
|
||||
--danger: #d13a3a;
|
||||
--danger-soft: #fbe8e8;
|
||||
--shadow-sm: 0 1px 2px rgba(20, 22, 26, 0.06);
|
||||
--shadow-md: 0 4px 16px rgba(20, 22, 26, 0.08);
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--sidebar-w: 232px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #101216;
|
||||
--bg-elevated: #17191f;
|
||||
--bg-sunken: #0c0d10;
|
||||
--border: #262a33;
|
||||
--border-strong: #363c48;
|
||||
--text: #eceef2;
|
||||
--text-dim: #9aa1ad;
|
||||
--text-faint: #6b7280;
|
||||
--accent: #5b8cff;
|
||||
--accent-hover: #7ba0ff;
|
||||
--accent-text: #0a0e18;
|
||||
--accent-soft: #17233f;
|
||||
--success: #3ecf8e;
|
||||
--success-soft: #103527;
|
||||
--warning: #e2a53f;
|
||||
--warning-soft: #3a2c11;
|
||||
--danger: #f0605f;
|
||||
--danger-soft: #3a1616;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
::selection { background: var(--accent-soft); }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
font-family: var(--font);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 2rem 1.5rem 1rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
a { color: var(--accent); }
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Shell: fixed sidebar + main content */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
|
||||
.subtitle { margin: 0; color: var(--text-dim); font-size: 0.9rem; }
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem 3rem;
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-elevated);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 20px 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 22px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-mark svg { width: 17px; height: 17px; }
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand-text strong { font-size: 14px; }
|
||||
.brand-text span { font-size: 11.5px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; opacity: 0.85; }
|
||||
.nav-item:hover { background: var(--bg-sunken); color: var(--text); }
|
||||
.nav-item.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.nav-item.active svg { opacity: 1; }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 32px 40px 60px;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-header h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; }
|
||||
.page-header p { margin: 0; color: var(--text-dim); font-size: 13.5px; }
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Cards, forms, buttons */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.card h2 { margin: 0 0 14px; font-size: 14.5px; font-weight: 600; }
|
||||
|
||||
.card-inset {
|
||||
background: var(--panel-inset);
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
form { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
label.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
label.checkbox input { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
|
||||
label.checkbox input { width: 1.05rem; height: 1.05rem; }
|
||||
|
||||
.hint { color: var(--text-dim); font-size: 0.8rem; font-weight: normal; }
|
||||
.hint { color: var(--text-faint); font-size: 11.5px; font-weight: normal; }
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
@@ -87,96 +224,172 @@ input[type="number"],
|
||||
input[type="time"],
|
||||
select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
padding: 8px 10px;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
legend { padding: 0 0.4rem; color: var(--text-dim); font-size: 0.85rem; }
|
||||
legend { padding: 0 6px; color: var(--text-dim); font-size: 12px; font-weight: 600; }
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 8px;
|
||||
padding: 8px 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
align-self: flex-start;
|
||||
transition: background-color 0.12s ease, transform 0.05s ease;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
button.secondary:hover { background: var(--bg-sunken); }
|
||||
|
||||
button.danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
button.danger:hover { background: var(--danger-soft); }
|
||||
|
||||
button:hover { filter: brightness(1.1); }
|
||||
.button-row { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.75rem;
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Addon cards, status pills, job log panel */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.addon-card { display: flex; flex-direction: column; gap: 12px; }
|
||||
.addon-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.addon-title { display: flex; align-items: center; gap: 10px; }
|
||||
.addon-icon {
|
||||
width: 34px; height: 34px; border-radius: 9px;
|
||||
background: var(--bg-sunken); display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-dim); flex-shrink: 0;
|
||||
}
|
||||
.addon-icon svg { width: 18px; height: 18px; }
|
||||
.addon-name { font-weight: 600; font-size: 14px; }
|
||||
.addon-desc { color: var(--text-faint); font-size: 12px; margin-top: 1px; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 9px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
|
||||
}
|
||||
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; }
|
||||
.pill.installed { background: var(--success-soft); color: var(--success); }
|
||||
.pill.installed::before { background: var(--success); }
|
||||
.pill.not-installed { background: var(--bg-sunken); color: var(--text-faint); }
|
||||
.pill.not-installed::before { background: var(--text-faint); }
|
||||
.pill.unknown { background: var(--warning-soft); color: var(--warning); }
|
||||
.pill.unknown::before { background: var(--warning); }
|
||||
|
||||
.addon-form { display: none; }
|
||||
.addon-form.open { display: flex; padding-top: 4px; border-top: 1px solid var(--border); margin-top: 4px; }
|
||||
|
||||
.job-panel {
|
||||
display: none;
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.job-panel.open { display: block; }
|
||||
.job-panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
}
|
||||
.job-status { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.02em; }
|
||||
.job-status.running { color: var(--accent); }
|
||||
.job-status.success { color: var(--success); }
|
||||
.job-status.failed { color: var(--danger); }
|
||||
.spinner {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--accent-soft); border-top-color: var(--accent);
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.job-log {
|
||||
margin: 0; padding: 12px 14px;
|
||||
font-family: var(--font-mono); font-size: 12px; line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
max-height: 320px; overflow-y: auto;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Site rows (Sites & Page Timing) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.site-row-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.site-row-grid { grid-template-columns: 1fr; }
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 640px) { .site-row-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.site-auth {
|
||||
margin-top: 0.75rem;
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.site-auth summary { cursor: pointer; color: var(--text-dim); font-size: 12px; font-weight: 600; }
|
||||
.site-auth[open] summary { margin-bottom: 10px; }
|
||||
.auth-state { color: var(--text-faint); font-weight: normal; }
|
||||
|
||||
.site-auth summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.site-auth[open] summary { margin-bottom: 0.6rem; }
|
||||
|
||||
.auth-state { color: var(--text-dim); }
|
||||
|
||||
.remove-site { margin-top: 0.75rem; }
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Toast banner */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.banner {
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
position: sticky;
|
||||
top: 0.75rem;
|
||||
z-index: 10;
|
||||
border-radius: var(--radius);
|
||||
padding: 11px 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
max-width: 360px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 50;
|
||||
}
|
||||
.banner.success { background: var(--success-soft); border: 1px solid var(--success); color: var(--success); }
|
||||
.banner.error { background: var(--danger-soft); border: 1px solid var(--danger); color: var(--danger); }
|
||||
|
||||
.banner.success { background: rgba(63, 184, 127, 0.15); border: 1px solid var(--success); color: var(--success); }
|
||||
.banner.error { background: rgba(229, 86, 106, 0.15); border: 1px solid var(--error); color: var(--error); }
|
||||
@media (max-width: 800px) {
|
||||
.shell { flex-direction: column; }
|
||||
.sidebar { width: 100%; height: auto; position: static; flex-direction: row; align-items: center; padding: 12px; overflow-x: auto; }
|
||||
.brand { padding: 0 10px 0 0; }
|
||||
.nav { flex-direction: row; }
|
||||
.sidebar-footer { display: none; }
|
||||
.main { padding: 20px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user