Files
Bjorn/web/js/core/quickpanel.js
Fabien POLLY eb20b168a6 Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences.
- Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling.
- Included helper methods for querying the database and sending JSON responses.
- Integrated model metadata extraction for visualization purposes.
2026-02-18 22:36:10 +01:00

687 lines
24 KiB
JavaScript

/**
* QuickPanel — WiFi & Bluetooth management panel.
*
* Replicates the monolithic global.js QuickPanel as a standalone ES module.
* Slide-down panel with two tabs (WiFi / Bluetooth), scan controls,
* auto-scan toggles, known-network management, and Bluetooth pairing.
*/
import { $, $$, el, toast, empty } from './dom.js';
import { api } from './api.js';
import { t } from './i18n.js';
/* ---------- API endpoints ---------- */
const API = {
scanWifi: '/scan_wifi',
getKnownWifi: '/get_known_wifi',
connectKnown: '/connect_known_wifi',
connectWifi: '/connect_wifi',
updatePriority: '/update_wifi_priority',
deleteKnown: '/delete_known_wifi',
importPotfiles: '/import_potfiles',
scanBluetooth: '/scan_bluetooth',
pairBluetooth: '/pair_bluetooth',
trustBluetooth: '/trust_bluetooth',
connectBluetooth: '/connect_bluetooth',
disconnectBluetooth: '/disconnect_bluetooth',
forgetBluetooth: '/forget_bluetooth',
};
/* ---------- Constants ---------- */
const AUTOSCAN_INTERVAL = 15_000; // 15 s
const LS_WIFI_AUTO = 'qp_wifi_auto';
const LS_BT_AUTO = 'qp_bt_auto';
/* ---------- Module state ---------- */
let panel; // #quickpanel element
let wifiList; // container for wifi scan results
let knownList; // container for known networks
let btList; // container for bluetooth results
let wifiTab; // wifi tab content wrapper
let btTab; // bluetooth tab content wrapper
let tabBtns; // [wifiTabBtn, btTabBtn]
let wifiAutoTimer = null;
let btAutoTimer = null;
let activeTab = 'wifi';
let scanning = { wifi: false, bt: false };
/* =================================================================
Helpers
================================================================= */
/** Persist and read auto-scan preference. */
function getAutoScan(key) {
try { return localStorage.getItem(key) === '1'; } catch { return false; }
}
function setAutoScan(key, on) {
try { localStorage.setItem(key, on ? '1' : '0'); } catch { /* storage full */ }
}
/** Signal strength to bar count (1-4). */
function signalBars(dbm) {
if (dbm > -50) return 4;
if (dbm > -65) return 3;
if (dbm > -75) return 2;
return 1;
}
/** Build a `<span class="sig">` with four bar elements. */
function sigEl(dbm) {
const count = signalBars(dbm);
const bars = [];
for (let i = 1; i <= 4; i++) {
const bar = el('i');
bar.style.height = `${4 + i * 3}px`;
if (i <= count) bar.className = 'on';
bars.push(bar);
}
return el('span', { class: 'sig' }, bars);
}
/** Security type to badge class suffix. */
function secClass(sec) {
if (!sec) return 'sec-open';
const s = sec.toUpperCase();
if (s.includes('WPA')) return 'sec-wpa';
if (s.includes('WEP')) return 'sec-wep';
if (s === 'OPEN' || s === '' || s === 'NONE') return 'sec-open';
return 'sec-wpa'; // default to wpa for unknown secured types
}
/** Security badge element. */
function secBadge(sec) {
const label = sec || 'Open';
return el('span', { class: `badge ${secClass(sec)}` }, [label]);
}
/** State dot element (paired / connected indicator). */
function stateDot(on) {
return el('span', { class: `state-dot ${on ? 'state-on' : 'state-off'}` });
}
/** Create a small auto-scan toggle with a switch. */
function autoScanToggle(key, onChange) {
const isOn = getAutoScan(key);
const sw = el('span', { class: `switch${isOn ? ' on' : ''}`, role: 'switch', 'aria-checked': String(isOn), tabindex: '0' });
const label = el('span', { style: 'font-size:12px;color:var(--muted);user-select:none' }, [t('quick.autoScan')]);
const wrap = el('label', { style: 'display:inline-flex;align-items:center;gap:8px;cursor:pointer' }, [label, sw]);
function toggle() {
const next = !sw.classList.contains('on');
sw.classList.toggle('on', next);
sw.setAttribute('aria-checked', String(next));
setAutoScan(key, next);
onChange(next);
}
sw.addEventListener('click', toggle);
sw.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
return { wrap, isOn };
}
/* =================================================================
System Dialog (WiFi password prompt)
================================================================= */
function openSysDialog(title, fields, onSubmit) {
const backdrop = $('#sysDialogBackdrop');
if (!backdrop) return;
empty(backdrop);
const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true', style: 'padding:20px;max-width:400px;width:90vw;border-radius:16px;background:var(--grad-quickpanel,#0a1116);border:1px solid var(--c-border-strong)' });
const heading = el('h3', { style: 'margin:0 0 16px;color:var(--ink)' }, [title]);
modal.appendChild(heading);
const form = el('form', { style: 'display:flex;flex-direction:column;gap:12px' });
const inputs = {};
for (const f of fields) {
const input = el('input', {
class: 'input',
type: f.type || 'text',
placeholder: f.placeholder || '',
autocomplete: f.autocomplete || 'off',
style: 'width:100%;padding:10px 12px;border-radius:8px;border:1px solid var(--c-border-strong);background:var(--c-panel);color:var(--ink);font-size:14px',
});
if (f.value) input.value = f.value;
if (f.readonly) input.readOnly = true;
inputs[f.name] = input;
const label = el('label', { style: 'display:flex;flex-direction:column;gap:4px' }, [
el('span', { style: 'font-size:12px;color:var(--muted)' }, [f.label]),
input,
]);
form.appendChild(label);
}
const btnRow = el('div', { style: 'display:flex;gap:8px;justify-content:flex-end;margin-top:8px' });
const cancelBtn = el('button', { class: 'btn', type: 'button' }, [t('common.cancel')]);
const submitBtn = el('button', { class: 'btn', type: 'submit', style: 'background:var(--acid);color:var(--ink-invert,#001014)' }, [t('common.connect')]);
btnRow.appendChild(cancelBtn);
btnRow.appendChild(submitBtn);
form.appendChild(btnRow);
modal.appendChild(form);
backdrop.appendChild(modal);
backdrop.style.display = 'flex';
backdrop.classList.add('show');
function closeDlg() {
backdrop.style.display = 'none';
backdrop.classList.remove('show');
empty(backdrop);
}
cancelBtn.addEventListener('click', closeDlg);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) closeDlg();
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const values = {};
for (const [name, inp] of Object.entries(inputs)) values[name] = inp.value;
closeDlg();
onSubmit(values);
});
// Focus first editable input
const firstInput = Object.values(inputs).find(i => !i.readOnly);
if (firstInput) requestAnimationFrame(() => firstInput.focus());
}
function closeSysDialog() {
const backdrop = $('#sysDialogBackdrop');
if (!backdrop) return;
backdrop.style.display = 'none';
backdrop.classList.remove('show');
empty(backdrop);
}
/* =================================================================
WiFi — scan, connect, known networks
================================================================= */
async function scanWifi() {
if (scanning.wifi) return;
scanning.wifi = true;
try {
const data = await api.get(API.scanWifi);
renderWifiResults(data);
} catch (err) {
toast(t('quick.btScanFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
} finally {
scanning.wifi = false;
}
}
function renderWifiResults(data) {
if (!wifiList) return;
empty(wifiList);
const networks = Array.isArray(data) ? data : (data?.networks || data?.results || []);
if (!networks.length) {
wifiList.appendChild(el('div', { style: 'padding:12px;color:var(--muted);text-align:center' }, [t('common.noData')]));
return;
}
// Sort by signal descending
networks.sort((a, b) => (b.signal ?? -100) - (a.signal ?? -100));
for (const net of networks) {
const ssid = net.ssid || net.SSID || '(Hidden)';
const signal = net.signal ?? net.level ?? -80;
const sec = net.security || net.encryption || '';
const row = el('div', { class: 'qprow', style: 'grid-template-columns:1fr auto auto auto;align-items:center' }, [
el('span', { style: 'font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, [ssid]),
sigEl(signal),
secBadge(sec),
el('button', { class: 'btn', onclick: () => promptWifiConnect(ssid, sec), style: 'font-size:12px;padding:4px 10px' }, [t('common.connect')]),
]);
wifiList.appendChild(row);
}
}
function promptWifiConnect(ssid, sec) {
const isOpen = !sec || sec.toUpperCase() === 'OPEN' || sec.toUpperCase() === 'NONE' || sec === '';
if (isOpen) {
connectWifi(ssid, '');
return;
}
openSysDialog(t('quick.connectWifi'), [
{ name: 'ssid', label: t('network.title'), value: ssid, readonly: true },
{ name: 'password', label: t('creds.password'), type: 'password', placeholder: t('creds.password'), autocomplete: 'current-password' },
], (vals) => {
connectWifi(vals.ssid, vals.password);
});
}
async function connectWifi(ssid, password) {
try {
toast(t('quick.connectingTo', { ssid }), 2000, 'info');
await api.post(API.connectWifi, { ssid, password });
toast(t('quick.connectedTo', { ssid }), 3000, 'success');
} catch (err) {
toast(t('quick.connectionFailed') + ': ' + (err.message || t('common.unknown')), 3500, 'error');
}
}
/* ---------- Known networks ---------- */
async function loadKnownWifi() {
if (!knownList) return;
empty(knownList);
knownList.appendChild(el('div', { style: 'padding:8px;color:var(--muted);text-align:center' }, [t('common.loading')]));
try {
const data = await api.get(API.getKnownWifi);
renderKnownNetworks(data);
} catch (err) {
empty(knownList);
toast(t('quick.loadKnownFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
}
}
function renderKnownNetworks(data) {
if (!knownList) return;
empty(knownList);
let networks = [];
if (Array.isArray(data)) {
networks = data;
} else if (data && typeof data === 'object') {
networks = data.networks || data.known || data.data || data.results || [];
// If data is a single-key object wrapping an array, unwrap it
if (!networks.length) {
const keys = Object.keys(data);
if (keys.length === 1 && Array.isArray(data[keys[0]])) {
networks = data[keys[0]];
}
}
}
console.debug('[QuickPanel] Known networks data:', data, '-> parsed:', networks.length, 'items');
if (!networks.length) {
knownList.appendChild(el('div', { style: 'padding:12px;color:var(--muted);text-align:center' }, [t('common.noData')]));
return;
}
for (let i = 0; i < networks.length; i++) {
const net = networks[i];
const ssid = net.ssid || net.SSID || '(Unknown)';
const priority = net.priority ?? i;
const moveUpBtn = el('button', { class: 'btn', style: 'font-size:11px;padding:2px 6px', onclick: () => updatePriority(ssid, priority + 1), title: t('common.ascending') }, ['\u2191']);
const moveDownBtn = el('button', { class: 'btn', style: 'font-size:11px;padding:2px 6px', onclick: () => updatePriority(ssid, Math.max(0, priority - 1)), title: t('common.descending') }, ['\u2193']);
const connectBtn = el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => connectKnownWifi(ssid) }, [t('common.connect')]);
const deleteBtn = el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px;color:var(--danger,#ff3b3b)', onclick: () => deleteKnown(ssid) }, [t('common.delete')]);
const actions = el('div', { style: 'display:flex;gap:4px;align-items:center;flex-wrap:wrap' }, [moveUpBtn, moveDownBtn, connectBtn, deleteBtn]);
const row = el('div', { class: 'qprow', style: 'grid-template-columns:1fr auto;align-items:center' }, [
el('div', { style: 'display:flex;flex-direction:column;gap:2px;overflow:hidden' }, [
el('span', { style: 'font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, [ssid]),
el('span', { style: 'font-size:11px;color:var(--muted)' }, ['Priority: ' + priority]),
]),
actions,
]);
knownList.appendChild(row);
}
}
async function connectKnownWifi(ssid) {
try {
toast(t('quick.connectingTo', { ssid }), 2000, 'info');
await api.post(API.connectKnown, { ssid });
toast(t('quick.connectedTo', { ssid }), 3000, 'success');
} catch (err) {
toast(t('quick.connectionFailed') + ': ' + (err.message || t('common.unknown')), 3500, 'error');
}
}
async function updatePriority(ssid, priority) {
try {
await api.post(API.updatePriority, { ssid, priority });
toast(t('quick.priorityUpdated'), 2000, 'success');
loadKnownWifi(); // refresh list
} catch (err) {
toast(t('quick.priorityUpdateFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
}
}
async function deleteKnown(ssid) {
openSysDialog(t('common.delete'), [
{ name: 'ssid', label: t('quick.forgetNetworkPrompt'), value: ssid, readonly: true },
], async (vals) => {
try {
await api.post(API.deleteKnown, { ssid: vals.ssid });
toast('Network removed', 2000, 'success');
loadKnownWifi();
} catch (err) {
toast('Delete failed: ' + (err.message || 'Unknown error'), 3000, 'error');
}
});
}
async function importPotfiles() {
try {
toast(t('quick.importingPotfiles'), 2000, 'info');
const res = await api.post(API.importPotfiles);
const count = res?.imported ?? res?.count ?? '?';
toast(t('quick.importedCount', { count }), 3000, 'success');
} catch (err) {
toast(t('studio.importFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
}
}
/* =================================================================
Bluetooth — scan, pair, trust, connect, disconnect, forget
================================================================= */
async function scanBluetooth() {
if (scanning.bt) return;
scanning.bt = true;
try {
const data = await api.get(API.scanBluetooth);
renderBtResults(data);
} catch (err) {
toast(t('quick.btScanFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
} finally {
scanning.bt = false;
}
}
function renderBtResults(data) {
if (!btList) return;
empty(btList);
const devices = Array.isArray(data) ? data : (data?.devices || data?.results || []);
if (!devices.length) {
btList.appendChild(el('div', { style: 'padding:12px;color:var(--muted);text-align:center' }, [t('common.noData')]));
return;
}
for (const dev of devices) {
const name = dev.name || dev.Name || '(Unknown)';
const mac = dev.mac || dev.address || dev.MAC || '';
const type = dev.type || dev.Type || '';
const paired = !!(dev.paired || dev.Paired);
const connected = !!(dev.connected || dev.Connected);
// Action buttons vary by device state
const actions = [];
if (!paired) {
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('pair', mac, name) }, [t('quick.pair')]));
} else {
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('trust', mac, name) }, [t('quick.trust')]));
if (connected) {
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('disconnect', mac, name) }, [t('common.disconnect')]));
} else {
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('connect', mac, name) }, [t('common.connect')]));
}
actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px;color:var(--danger,#ff3b3b)', onclick: () => btForget(mac, name) }, [t('common.remove')]));
}
const row = el('div', { class: 'qprow btlist' }, [
el('div', { class: 'bt-device' }, [
stateDot(connected),
el('span', { style: 'font-weight:600' }, [name]),
el('span', { class: 'bt-type' }, [type]),
el('span', { style: 'font-size:11px;color:var(--muted)' }, [mac]),
]),
el('div', { style: 'display:flex;gap:4px;align-items:center;flex-wrap:wrap' }, actions),
]);
btList.appendChild(row);
}
}
async function btAction(action, mac, name) {
const endpoints = {
pair: API.pairBluetooth,
trust: API.trustBluetooth,
connect: API.connectBluetooth,
disconnect: API.disconnectBluetooth,
};
const url = endpoints[action];
if (!url) return;
const label = action.charAt(0).toUpperCase() + action.slice(1);
try {
toast(t('quick.btActioning', { action, name }), 2000, 'info');
await api.post(url, { address: mac, mac });
toast(t('quick.btActionDone', { action, name }), 3000, 'success');
// Refresh after state change
scanBluetooth();
} catch (err) {
toast(t('quick.btActionFailed', { action }) + ': ' + (err.message || t('common.unknown')), 3500, 'error');
}
}
function btForget(mac, name) {
openSysDialog(t('quick.forgetDevice'), [
{ name: 'mac', label: t('quick.forgetDevicePrompt', { name }), value: mac, readonly: true },
], async (vals) => {
try {
await api.post(API.forgetBluetooth, { address: vals.mac, mac: vals.mac });
toast(t('quick.btForgotten', { name }), 2000, 'success');
scanBluetooth();
} catch (err) {
toast(t('common.deleteFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error');
}
});
}
/* =================================================================
Auto-scan timers
================================================================= */
function startWifiAutoScan() {
stopWifiAutoScan();
wifiAutoTimer = setInterval(() => {
if (panel && panel.classList.contains('open') && activeTab === 'wifi') scanWifi();
}, AUTOSCAN_INTERVAL);
// Immediate first scan
scanWifi();
}
function stopWifiAutoScan() {
if (wifiAutoTimer) { clearInterval(wifiAutoTimer); wifiAutoTimer = null; }
}
function startBtAutoScan() {
stopBtAutoScan();
btAutoTimer = setInterval(() => {
if (panel && panel.classList.contains('open') && activeTab === 'bt') scanBluetooth();
}, AUTOSCAN_INTERVAL);
scanBluetooth();
}
function stopBtAutoScan() {
if (btAutoTimer) { clearInterval(btAutoTimer); btAutoTimer = null; }
}
/* =================================================================
Tab switching
================================================================= */
function switchTab(tab) {
activeTab = tab;
if (tabBtns) {
tabBtns.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
}
if (wifiTab) wifiTab.style.display = (tab === 'wifi') ? '' : 'none';
if (btTab) btTab.style.display = (tab === 'bt') ? '' : 'none';
}
/* =================================================================
Panel open / close / toggle
================================================================= */
export function open() {
if (!panel) return;
panel.classList.add('open');
panel.setAttribute('aria-hidden', 'false');
// Load known networks on open (always useful to have them)
loadKnownWifi();
// Start auto-scans if enabled
if (getAutoScan(LS_WIFI_AUTO)) startWifiAutoScan();
if (getAutoScan(LS_BT_AUTO)) startBtAutoScan();
}
export function close() {
if (!panel) return;
panel.classList.remove('open');
panel.setAttribute('aria-hidden', 'true');
// Stop auto-scans while closed to save resources
stopWifiAutoScan();
stopBtAutoScan();
// Close any open system dialog
closeSysDialog();
}
export function toggle() {
if (!panel) return;
if (panel.classList.contains('open')) close();
else open();
}
/* =================================================================
Build panel content (init)
================================================================= */
export function init() {
panel = $('#quickpanel');
if (!panel) {
console.warn('[QuickPanel] #quickpanel not found in DOM');
return;
}
/* ---- Header ---- */
const closeBtn = el('button', { class: 'qp-close', 'aria-label': t('quick.close'), onclick: close }, ['\u2715']);
const header = el('div', { class: 'qp-header', style: 'padding:20px 16px 8px' }, [
el('div', { class: 'qp-head-left' }, [
el('strong', { style: 'font-size:16px' }, [t('nav.shortcuts')]),
el('span', { style: 'font-size:11px;color:var(--muted)' }, [t('quick.subtitle')]),
]),
closeBtn,
]);
/* ---- Tab bar ---- */
const wifiTabBtn = el('div', { class: 'tab active', 'data-tab': 'wifi', onclick: () => switchTab('wifi') }, [t('dash.wifi')]);
const btTabBtn = el('div', { class: 'tab', 'data-tab': 'bt', onclick: () => switchTab('bt') }, [t('dash.bluetooth')]);
tabBtns = [wifiTabBtn, btTabBtn];
const tabBar = el('div', { class: 'tabs-container', style: 'margin:0 16px 12px' }, [wifiTabBtn, btTabBtn]);
/* ---- WiFi tab content ---- */
wifiList = el('div', { class: 'wifilist', style: 'max-height:40vh;overflow-y:auto;padding:0 16px' });
knownList = el('div', { class: 'knownlist', style: 'max-height:30vh;overflow-y:auto;padding:0 16px' });
const wifiScanBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: scanWifi }, [t('common.refresh')]);
const knownBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: loadKnownWifi }, [t('quick.knownNetworks')]);
const potfileBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: importPotfiles }, [t('quick.importPotfiles')]);
const wifiAutoCtrl = autoScanToggle(LS_WIFI_AUTO, (on) => {
if (on && panel.classList.contains('open')) startWifiAutoScan();
else stopWifiAutoScan();
});
const wifiToolbar = el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 16px 8px' }, [
wifiScanBtn, knownBtn, potfileBtn,
el('span', { style: 'flex:1' }),
wifiAutoCtrl.wrap,
]);
const knownHeader = el('div', { style: 'padding:8px 16px 4px;font-weight:700;font-size:13px;color:var(--muted)' }, [t('quick.knownNetworks')]);
wifiTab = el('div', { 'data-panel': 'wifi' }, [wifiToolbar, wifiList, knownHeader, knownList]);
/* ---- Bluetooth tab content ---- */
btList = el('div', { class: 'btlist', style: 'max-height:50vh;overflow-y:auto;padding:0 16px' });
const btScanBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: scanBluetooth }, [t('common.refresh')]);
const btAutoCtrl = autoScanToggle(LS_BT_AUTO, (on) => {
if (on && panel.classList.contains('open')) startBtAutoScan();
else stopBtAutoScan();
});
const btToolbar = el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 16px 8px' }, [
btScanBtn,
el('span', { style: 'flex:1' }),
btAutoCtrl.wrap,
]);
btTab = el('div', { 'data-panel': 'bt', style: 'display:none' }, [btToolbar, btList]);
/* ---- Assemble into panel (after the grip) ---- */
panel.appendChild(header);
panel.appendChild(tabBar);
panel.appendChild(wifiTab);
panel.appendChild(btTab);
/* ---- Global keyboard shortcuts ---- */
document.addEventListener('keydown', onKeyDown);
/* ---- Click outside to close ---- */
document.addEventListener('pointerdown', onOutsideClick);
/* ---- Wire topbar trigger button ---- */
const openBtn = $('#openQuick');
if (openBtn) openBtn.addEventListener('click', toggle);
}
/* =================================================================
Event handlers
================================================================= */
function onKeyDown(e) {
// Ctrl+\ to toggle
if (e.ctrlKey && e.key === '\\') {
e.preventDefault();
toggle();
return;
}
// Escape to close
if (e.key === 'Escape' && panel && panel.classList.contains('open')) {
// If a system dialog is open, close that first
const dlg = $('#sysDialogBackdrop');
if (dlg && (dlg.style.display === 'flex' || dlg.classList.contains('show'))) {
closeSysDialog();
return;
}
close();
}
}
function onOutsideClick(e) {
if (!panel || !panel.classList.contains('open')) return;
// Ignore clicks inside the panel itself
if (panel.contains(e.target)) return;
// Ignore clicks on the trigger button
const openBtn = $('#openQuick');
if (openBtn && openBtn.contains(e.target)) return;
// Ignore clicks on the system dialog backdrop
const dlg = $('#sysDialogBackdrop');
if (dlg && dlg.contains(e.target)) return;
close();
}