Add Web UI addon: browser-based config editor (v2.16.0)
New Addons -> Web UI: a small Node/Express app (webui/) installed as a systemd service running as $KIOSK_USER, giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout - the three Core Settings menus that are pure config.json read/write with no privileged system mutation involved. Runs with no sudo at all, since config.json is already owned by $KIOSK_USER. webui/lib/config.js re-implements lib/config.sh's exact schema and merge-on-save contract in JS (kiosk-app/main.js already reads the same config.json directly in JS, so this isn't a new pattern), so it can never silently clobber fields it doesn't track - the same bug previously fixed in lib/config.sh's own history. No login of its own by design: Authelia runs elsewhere, and the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected - Authelia integration is explicitly out of scope for this repo. Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only, since a network-facing process shouldn't be handed sudo-level system mutation without a lot more thought than this pass gives it. Wired into Complete Uninstall (webui_do_uninstall) and Clone Settings (addon-presence detection) the same way every other addon is. This is the single-kiosk piece of the web-based GUI this repo's "Modular Management" notes have mentioned for a while - a central multi-kiosk fleet dashboard is an intentional follow-up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
'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.
|
||||
|
||||
const msgEl = document.getElementById('msg');
|
||||
let currentConfig = null;
|
||||
|
||||
function showMessage(text, isError) {
|
||||
msgEl.textContent = text;
|
||||
msgEl.hidden = false;
|
||||
msgEl.className = 'banner ' + (isError ? 'error' : 'success');
|
||||
clearTimeout(showMessage._t);
|
||||
showMessage._t = setTimeout(() => { msgEl.hidden = true; }, 5000);
|
||||
}
|
||||
|
||||
async function apiGet() {
|
||||
const res = await fetch('/api/config');
|
||||
if (!res.ok) throw new Error('Failed to load configuration');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPut(patch) {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Save failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Sites & Page Timing */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const sitesList = document.getElementById('sites-list');
|
||||
const siteRowTemplate = document.getElementById('site-row-template');
|
||||
|
||||
function renderSites(tabs) {
|
||||
sitesList.textContent = '';
|
||||
tabs.forEach((tab, idx) => sitesList.appendChild(buildSiteRow(tab, idx)));
|
||||
if (tabs.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'hint';
|
||||
p.textContent = 'No pages configured yet.';
|
||||
sitesList.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSiteRow(tab, idx) {
|
||||
const node = siteRowTemplate.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.index = String(idx);
|
||||
|
||||
node.querySelector('.site-url').value = tab.url || '';
|
||||
node.querySelector('.site-name').value = tab.name || '';
|
||||
node.querySelector('.site-duration').value = tab.duration ?? 180;
|
||||
|
||||
const authEnable = node.querySelector('.site-auth-enable');
|
||||
const authUser = node.querySelector('.site-auth-username');
|
||||
const authState = node.querySelector('.auth-state');
|
||||
const hasAuth = !!(tab.username || tab.hasPassword);
|
||||
authEnable.checked = hasAuth;
|
||||
authUser.value = tab.username || '';
|
||||
authState.textContent = hasAuth ? '(enabled)' : '(disabled)';
|
||||
|
||||
node.querySelector('.remove-site').addEventListener('click', () => {
|
||||
node.remove();
|
||||
if (!sitesList.querySelector('.site-row')) renderSites([]);
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
document.getElementById('add-site').addEventListener('click', () => {
|
||||
if (sitesList.querySelector('.hint')) sitesList.textContent = '';
|
||||
sitesList.appendChild(buildSiteRow({ url: '', name: '', duration: 180, username: '', hasPassword: false }, sitesList.children.length));
|
||||
updateHomeTabOptions(collectTabs());
|
||||
});
|
||||
document.getElementById('save-sites').addEventListener('click', saveSites);
|
||||
|
||||
function collectTabs() {
|
||||
return Array.from(sitesList.querySelectorAll('.site-row')).map((row) => {
|
||||
const tab = {
|
||||
url: row.querySelector('.site-url').value.trim(),
|
||||
name: row.querySelector('.site-name').value.trim(),
|
||||
duration: parseInt(row.querySelector('.site-duration').value, 10),
|
||||
};
|
||||
const authEnabled = row.querySelector('.site-auth-enable').checked;
|
||||
if (authEnabled) {
|
||||
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 = '';
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
showMessage('Every site needs a URL', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
currentConfig = await apiPut({ tabs });
|
||||
showMessage('Sites saved');
|
||||
renderSites(currentConfig.tabs);
|
||||
populateAll(currentConfig);
|
||||
} catch (e) {
|
||||
showMessage(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
sitesList.addEventListener('change', () => { updateHomeTabOptions(collectTabs()); });
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Display & Interaction */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const displayForm = document.getElementById('display-form');
|
||||
const homeTabSelect = document.getElementById('home-tab-select');
|
||||
|
||||
function updateHomeTabOptions(tabs) {
|
||||
const previous = homeTabSelect.value;
|
||||
homeTabSelect.textContent = '';
|
||||
const disabledOpt = document.createElement('option');
|
||||
disabledOpt.value = '-1';
|
||||
disabledOpt.textContent = 'Disabled';
|
||||
homeTabSelect.appendChild(disabledOpt);
|
||||
tabs.forEach((tab, idx) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(idx);
|
||||
opt.textContent = tab.name || tab.url || `Page ${idx + 1}`;
|
||||
homeTabSelect.appendChild(opt);
|
||||
});
|
||||
const stillValid = Array.from(homeTabSelect.options).some((o) => o.value === previous);
|
||||
homeTabSelect.value = stillValid ? previous : '-1';
|
||||
}
|
||||
|
||||
displayForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(displayForm);
|
||||
const patch = {
|
||||
swipeMode: fd.get('swipeMode'),
|
||||
allowNavigation: fd.get('allowNavigation'),
|
||||
enablePauseButton: fd.get('enablePauseButton') === 'on',
|
||||
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
|
||||
enableNavButton: fd.get('enableNavButton') === 'on',
|
||||
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
|
||||
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
|
||||
};
|
||||
try {
|
||||
currentConfig = await apiPut(patch);
|
||||
showMessage('Display & Interaction saved');
|
||||
populateAll(currentConfig);
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Password Protection & Lockout */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
const lockoutForm = document.getElementById('lockout-form');
|
||||
const lockoutEnabled = document.getElementById('lockout-enabled');
|
||||
const lockoutFields = document.getElementById('lockout-fields');
|
||||
const dailyLockEnabled = document.getElementById('daily-lock-enabled');
|
||||
const lockoutAtTime = document.getElementById('lockout-at-time');
|
||||
const passwordLabel = document.getElementById('password-label');
|
||||
|
||||
function refreshLockoutFieldVisibility() {
|
||||
lockoutFields.hidden = !lockoutEnabled.checked;
|
||||
}
|
||||
lockoutEnabled.addEventListener('change', refreshLockoutFieldVisibility);
|
||||
|
||||
dailyLockEnabled.addEventListener('change', () => {
|
||||
lockoutAtTime.disabled = !dailyLockEnabled.checked;
|
||||
if (!dailyLockEnabled.checked) lockoutAtTime.value = '';
|
||||
});
|
||||
|
||||
lockoutForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(lockoutForm);
|
||||
const enable = fd.get('enablePasswordProtection') === 'on';
|
||||
const newPassword = fd.get('newLockoutPassword') || '';
|
||||
const confirmPassword = document.getElementById('lockout-password-confirm').value;
|
||||
|
||||
if (newPassword && newPassword !== confirmPassword) {
|
||||
showMessage("Passwords don't match", true);
|
||||
return;
|
||||
}
|
||||
if (enable && !newPassword && !(currentConfig && currentConfig.hasLockoutPassword)) {
|
||||
showMessage('Set a lockout password before enabling password protection', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const patch = { enablePasswordProtection: enable };
|
||||
if (enable) {
|
||||
if (newPassword) patch.newLockoutPassword = newPassword;
|
||||
patch.lockoutTimeoutMinutes = parseInt(fd.get('lockoutTimeoutMinutes'), 10);
|
||||
patch.lockoutAtTime = dailyLockEnabled.checked ? fd.get('lockoutAtTime') : '';
|
||||
patch.requirePasswordOnBoot = fd.get('requirePasswordOnBoot') === 'on';
|
||||
}
|
||||
|
||||
try {
|
||||
currentConfig = await apiPut(patch);
|
||||
showMessage('Password Protection & Lockout saved');
|
||||
populateAll(currentConfig);
|
||||
lockoutForm.querySelector('[name=newLockoutPassword]').value = '';
|
||||
document.getElementById('lockout-password-confirm').value = '';
|
||||
} catch (err) {
|
||||
showMessage(err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Populate forms from a config snapshot */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
function populateAll(config) {
|
||||
displayForm.elements.swipeMode.value = config.swipeMode;
|
||||
displayForm.elements.allowNavigation.value = config.allowNavigation;
|
||||
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
|
||||
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
|
||||
displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
|
||||
updateHomeTabOptions(config.tabs);
|
||||
homeTabSelect.value = String(config.homeTabIndex);
|
||||
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
|
||||
|
||||
lockoutEnabled.checked = !!config.enablePasswordProtection;
|
||||
passwordLabel.textContent = config.hasLockoutPassword ? 'New password (leave blank to keep the current one)' : 'Set lockout password';
|
||||
lockoutForm.elements.lockoutTimeoutMinutes.value = config.lockoutTimeout;
|
||||
dailyLockEnabled.checked = !!config.lockoutAtTime;
|
||||
lockoutAtTime.disabled = !config.lockoutAtTime;
|
||||
lockoutAtTime.value = config.lockoutAtTime || '';
|
||||
lockoutForm.elements.requirePasswordOnBoot.checked = !!config.requirePasswordOnBoot;
|
||||
refreshLockoutFieldVisibility();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
currentConfig = await apiGet();
|
||||
renderSites(currentConfig.tabs);
|
||||
populateAll(currentConfig);
|
||||
} catch (e) {
|
||||
showMessage(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,139 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kiosk Web UI</title>
|
||||
<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>
|
||||
|
||||
<button type="submit">Save Password Protection & Lockout</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<template id="site-row-template">
|
||||
<div class="site-row card-inset">
|
||||
<div class="site-row-grid">
|
||||
<label>
|
||||
URL
|
||||
<input type="text" class="site-url" placeholder="example.com or https://example.com">
|
||||
</label>
|
||||
<label>
|
||||
Name (optional)
|
||||
<input type="text" class="site-name" placeholder="Shown instead of the URL">
|
||||
</label>
|
||||
<label>
|
||||
Duration (seconds; -1=hidden, 0=manual, >0=auto-rotate)
|
||||
<input type="number" class="site-duration" min="-1" max="86400" step="1" value="180">
|
||||
</label>
|
||||
</div>
|
||||
<details class="site-auth">
|
||||
<summary>HTTP Basic Auth <span class="auth-state"></span></summary>
|
||||
<label class="checkbox"><input type="checkbox" class="site-auth-enable"> Requires a username/password</label>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" class="site-auth-username">
|
||||
</label>
|
||||
<label>
|
||||
New password <span class="hint">(leave blank to keep the current one)</span>
|
||||
<input type="password" class="site-auth-password" autocomplete="new-password">
|
||||
</label>
|
||||
</details>
|
||||
<button type="button" class="remove-site danger">Remove page</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,182 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--panel-inset: #1e222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e6e9ef;
|
||||
--text-dim: #9aa3b2;
|
||||
--accent: #4f8cff;
|
||||
--accent-text: #ffffff;
|
||||
--danger: #e5566a;
|
||||
--success: #3fb87f;
|
||||
--error: #e5566a;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 2rem 1.5rem 1rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.card-inset {
|
||||
background: var(--panel-inset);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
label.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
label.checkbox input { width: 1.05rem; height: 1.05rem; }
|
||||
|
||||
.hint { color: var(--text-dim); font-size: 0.8rem; font-weight: normal; }
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="time"],
|
||||
select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
legend { padding: 0 0.4rem; color: var(--text-dim); font-size: 0.85rem; }
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
button:hover { filter: brightness(1.1); }
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
}
|
||||
|
||||
.site-auth {
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.banner {
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
position: sticky;
|
||||
top: 0.75rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.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); }
|
||||
Reference in New Issue
Block a user