mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-13 16:12:00 +00:00
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.
This commit is contained in:
721
web/js/app.js
Normal file
721
web/js/app.js
Normal file
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* app.js — SPA bootstrap.
|
||||
* Initializes core modules, registers routes, starts the router.
|
||||
* Wires shell UI: console, quickpanel, actions, settings, launcher, pollers.
|
||||
*/
|
||||
|
||||
import * as router from './core/router.js';
|
||||
import * as i18n from './core/i18n.js';
|
||||
import * as theme from './core/theme.js';
|
||||
import { api, Poller } from './core/api.js';
|
||||
import { $, el, setText, toast } from './core/dom.js';
|
||||
import * as consoleSSE from './core/console-sse.js';
|
||||
import * as quickpanel from './core/quickpanel.js';
|
||||
import * as actions from './core/actions.js';
|
||||
import * as settingsConfig from './core/settings-config.js';
|
||||
|
||||
/* =========================================
|
||||
* 1) Initialize core modules
|
||||
* ========================================= */
|
||||
|
||||
// Theme: apply saved CSS vars immediately (no flash)
|
||||
theme.init();
|
||||
|
||||
// i18n: load translations, then boot UI
|
||||
i18n.init().then(() => {
|
||||
bootUI();
|
||||
}).catch(err => {
|
||||
console.error('[App] i18n init failed:', err);
|
||||
bootUI(); // Boot anyway with fallback keys
|
||||
});
|
||||
|
||||
function bootUI() {
|
||||
// Runtime i18n wrappers for legacy hardcoded dialogs.
|
||||
if (!window.__bjornDialogsPatched) {
|
||||
const nativeConfirm = window.confirm.bind(window);
|
||||
const nativePrompt = window.prompt.bind(window);
|
||||
window.confirm = (msg) => nativeConfirm(i18n.trLoose(String(msg ?? '')));
|
||||
window.prompt = (msg, def = '') => nativePrompt(i18n.trLoose(String(msg ?? '')), def);
|
||||
window.__bjornDialogsPatched = true;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
* 2) Register all routes (lazy-loaded)
|
||||
* ========================================= */
|
||||
router.route('/dashboard', () => import('./pages/dashboard.js'));
|
||||
router.route('/netkb', () => import('./pages/netkb.js'));
|
||||
router.route('/network', () => import('./pages/network.js'));
|
||||
router.route('/credentials', () => import('./pages/credentials.js'));
|
||||
router.route('/vulnerabilities', () => import('./pages/vulnerabilities.js'));
|
||||
router.route('/attacks', () => import('./pages/attacks.js'));
|
||||
router.route('/scheduler', () => import('./pages/scheduler.js'));
|
||||
router.route('/database', () => import('./pages/database.js'));
|
||||
router.route('/files', () => import('./pages/files.js'));
|
||||
router.route('/loot', () => import('./pages/loot.js'));
|
||||
router.route('/actions', () => import('./pages/actions.js'));
|
||||
router.route('/actions-studio', () => import('./pages/actions-studio.js'));
|
||||
router.route('/backup', () => import('./pages/backup.js'));
|
||||
router.route('/web-enum', () => import('./pages/web-enum.js'));
|
||||
router.route('/zombieland', () => import('./pages/zombieland.js'));
|
||||
router.route('/ai-dashboard', () => import('./pages/rl-dashboard.js?t=' + Date.now()));
|
||||
router.route('/bjorn-debug', () => import('./pages/bjorn-debug.js'));
|
||||
router.route('/bjorn', () => import('./pages/bjorn.js'));
|
||||
|
||||
// 404 fallback
|
||||
router.setNotFound((container, path) => {
|
||||
container.appendChild(
|
||||
el('div', { class: 'not-found' }, [
|
||||
el('h2', {}, [i18n.t('common.notFound')]),
|
||||
el('p', {}, [`${i18n.t('common.notFound')}: ${path}`]),
|
||||
el('a', { href: '#/dashboard' }, [i18n.t('nav.dashboard')])
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
/* =========================================
|
||||
* 3) Mount language selector in topbar
|
||||
* ========================================= */
|
||||
const langContainer = $('#langSelect');
|
||||
if (langContainer) {
|
||||
i18n.mountLangSelector(langContainer);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
* 4) Initialize router (reads hash, loads first page)
|
||||
* ========================================= */
|
||||
const appContainer = $('#app');
|
||||
router.init(appContainer);
|
||||
window.addEventListener('i18n:changed', () => {
|
||||
i18n.updateDOM(document);
|
||||
router.reloadCurrent?.();
|
||||
});
|
||||
|
||||
/* =========================================
|
||||
* 5) Wire up topbar buttons
|
||||
* ========================================= */
|
||||
wireTopbar();
|
||||
|
||||
/* =========================================
|
||||
* 6) Start global pollers (status, character, say)
|
||||
* ========================================= */
|
||||
ensureBjornProgress();
|
||||
startGlobalPollers();
|
||||
|
||||
/* =========================================
|
||||
* 7) Wire page launcher overlay
|
||||
* ========================================= */
|
||||
wireLauncher();
|
||||
|
||||
/* =========================================
|
||||
* 8) Initialize shell modules
|
||||
* ========================================= */
|
||||
consoleSSE.init();
|
||||
quickpanel.init();
|
||||
actions.init();
|
||||
|
||||
/* =========================================
|
||||
* 9) Wire bottombar extras (liveview, footer fit)
|
||||
* ========================================= */
|
||||
wireLiveview();
|
||||
setupFooterFit();
|
||||
|
||||
/* =========================================
|
||||
* 10) Wire settings modal
|
||||
* ========================================= */
|
||||
wireSettingsModal();
|
||||
|
||||
/* =========================================
|
||||
* 11) Wire chip editor
|
||||
* ========================================= */
|
||||
wireChipEditor();
|
||||
|
||||
/* =========================================
|
||||
* 12) Global toast bridge
|
||||
* ========================================= */
|
||||
window.toast = (msg, ms = 2600) => toast(msg, ms);
|
||||
|
||||
console.info('[App] Bjorn SPA initialized');
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
* Global pollers — status bar updates
|
||||
* OPTIMIZED: Staggered timings to reduce CPU load
|
||||
* ========================================= */
|
||||
function ensureBjornProgress() {
|
||||
const host = document.querySelector('.status-left .status-text');
|
||||
if (!host) return;
|
||||
|
||||
if (document.getElementById('bjornProgress')) return; // déjà là
|
||||
|
||||
const progress = el('div', {
|
||||
id: 'bjornProgress',
|
||||
class: 'bjorn-progress',
|
||||
style: 'display:none;'
|
||||
}, [
|
||||
el('div', { class: 'bjorn-progress-bar' }),
|
||||
el('span', { class: 'bjorn-progress-text' })
|
||||
]);
|
||||
|
||||
host.appendChild(progress);
|
||||
}
|
||||
|
||||
function startGlobalPollers() {
|
||||
// Status (Toutes les 6s)
|
||||
const statusPoller = new Poller(async () => {
|
||||
try {
|
||||
const data = await api.get('/bjorn_status', { timeout: 5000, retries: 0 });
|
||||
|
||||
const statusEl = $('#bjornStatus');
|
||||
const status2El = $('#bjornStatus2');
|
||||
|
||||
const progressEl = $('#bjornProgress');
|
||||
const progressBar = progressEl?.querySelector('.bjorn-progress-bar');
|
||||
const progressText = progressEl?.querySelector('.bjorn-progress-text');
|
||||
|
||||
const imgEl = $('#bjornStatusImage');
|
||||
|
||||
if (statusEl && data.status) setText(statusEl, data.status);
|
||||
|
||||
if (status2El) {
|
||||
if (data.status2) {
|
||||
setText(status2El, data.status2);
|
||||
status2El.style.display = '';
|
||||
} else {
|
||||
status2El.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 🟢 PROGRESS — show only when actively running (1-100)
|
||||
if (progressEl) {
|
||||
const pct = Number(data.progress) || 0;
|
||||
if (pct > 0) {
|
||||
progressEl.style.display = '';
|
||||
progressBar.style.setProperty('--progress', `${pct}%`);
|
||||
progressText.textContent = `${pct}%`;
|
||||
} else {
|
||||
progressEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (imgEl && data.image_path) {
|
||||
imgEl.src = data.image_path + '?t=' + Date.now();
|
||||
}
|
||||
} catch (e) { }
|
||||
}, 6000);
|
||||
|
||||
// Character (Toutes les 10s - C'est suffisant pour une icône)
|
||||
const charPoller = new Poller(async () => {
|
||||
try {
|
||||
const imgEl = $('#bjorncharacter');
|
||||
if (!imgEl) return;
|
||||
const res = await fetch('/bjorn_character');
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
if (imgEl.src && imgEl.src.startsWith('blob:')) URL.revokeObjectURL(imgEl.src);
|
||||
imgEl.src = URL.createObjectURL(blob);
|
||||
} catch (e) { }
|
||||
}, 10000);
|
||||
|
||||
// Say (Toutes les 8s)
|
||||
const sayPoller = new Poller(async () => {
|
||||
try {
|
||||
const data = await api.get('/bjorn_say', { timeout: 5000, retries: 0 });
|
||||
const sayEl = $('#bjornSay');
|
||||
if (sayEl && data?.text) setText(sayEl, data.text);
|
||||
} catch (e) { }
|
||||
}, 8000);
|
||||
|
||||
statusPoller.start();
|
||||
charPoller.start();
|
||||
sayPoller.start();
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
* Topbar wiring
|
||||
* ========================================= */
|
||||
|
||||
function wireTopbar() {
|
||||
// Logo -> dashboard
|
||||
const logo = $('#logoBtn');
|
||||
if (logo) {
|
||||
logo.addEventListener('click', () => router.navigate('/dashboard'));
|
||||
logo.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); router.navigate('/dashboard'); }
|
||||
});
|
||||
}
|
||||
|
||||
// Settings button
|
||||
const settingsBtn = $('#openSettings');
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener('click', () => toggleSettings());
|
||||
}
|
||||
|
||||
// Launcher button
|
||||
const launcherBtn = $('#openLauncher');
|
||||
if (launcherBtn) {
|
||||
launcherBtn.addEventListener('click', () => toggleLauncher());
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
* Liveview dropdown (BÉTON EDITION)
|
||||
* Uses recursive setTimeout to prevent thread stacking
|
||||
* ========================================= */
|
||||
|
||||
function wireLiveview() {
|
||||
const character = $('#bjorncharacter');
|
||||
const center = $('.status-center');
|
||||
if (!character || !center) return;
|
||||
|
||||
const dropdown = el('div', { class: 'bjorn-dropdown' }, [
|
||||
el('img', { id: 'screenImage_Home', src: '/web/screen.png', alt: 'Bjorn', style: 'cursor:pointer;max-width:200px;border-radius:6px' })
|
||||
]);
|
||||
center.appendChild(dropdown);
|
||||
|
||||
const liveImg = $('#screenImage_Home', dropdown);
|
||||
let timer = null;
|
||||
const LIVE_DELAY = 4000; // On passe à 4s pour matcher display.py
|
||||
|
||||
function updateLive() {
|
||||
if (dropdown.style.display !== 'block') return; // Stop si caché
|
||||
|
||||
const n = new Image();
|
||||
n.onload = () => {
|
||||
liveImg.src = n.src;
|
||||
// On ne planifie la suivante QUE quand celle-ci est affichée
|
||||
timer = setTimeout(updateLive, LIVE_DELAY);
|
||||
};
|
||||
n.onerror = () => {
|
||||
// En cas d'erreur, on attend un peu avant de réessayer
|
||||
timer = setTimeout(updateLive, LIVE_DELAY * 2);
|
||||
};
|
||||
n.src = '/web/screen.png?t=' + Date.now();
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
dropdown.style.display = 'block';
|
||||
if (!timer) updateLive();
|
||||
};
|
||||
const hide = () => {
|
||||
dropdown.style.display = 'none';
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
// Events
|
||||
character.addEventListener('mouseenter', show);
|
||||
character.addEventListener('mouseleave', () => setTimeout(() => {
|
||||
if (!dropdown.matches(':hover') && !character.matches(':hover')) hide();
|
||||
}, 300));
|
||||
|
||||
character.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.style.display === 'block' ? hide() : show();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (!dropdown.contains(ev.target) && !character.contains(ev.target)) hide();
|
||||
});
|
||||
|
||||
if (liveImg) {
|
||||
liveImg.addEventListener('click', () => router.navigate('/bjorn'));
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
* Footer text fitting (adaptive font size)
|
||||
* ========================================= */
|
||||
|
||||
function setupFooterFit() {
|
||||
function fitTextById(id, opts = {}) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const box = el.parentElement || el;
|
||||
const max = opts.max || 12;
|
||||
const min = opts.min || 7;
|
||||
let size = max;
|
||||
el.style.fontSize = size + 'px';
|
||||
const maxH = parseFloat(getComputedStyle(el).maxHeight) || Infinity;
|
||||
|
||||
while ((el.scrollWidth > box.clientWidth || el.scrollHeight > maxH) && size > min) {
|
||||
size--;
|
||||
el.style.fontSize = size + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function runFooterFit() {
|
||||
fitTextById('bjornStatus', { max: 12, min: 7 });
|
||||
fitTextById('bjornSay', { max: 12, min: 7 });
|
||||
fitTextById('bjornStatus2', { max: 12, min: 7 });
|
||||
fitTextById('bjornProgress', { max: 11, min: 7 }); // 🟢
|
||||
}
|
||||
|
||||
// Run on load & resize
|
||||
window.addEventListener('load', runFooterFit);
|
||||
window.addEventListener('resize', runFooterFit);
|
||||
|
||||
// Observe size/content changes
|
||||
const left = document.querySelector('.status-left');
|
||||
const right = document.querySelector('.status-right');
|
||||
const ro = new ResizeObserver(runFooterFit);
|
||||
if (left) ro.observe(left);
|
||||
if (right) ro.observe(right);
|
||||
|
||||
['bjornStatus', 'bjornSay', 'bjornStatus2', 'bjornProgress'].forEach(id => {
|
||||
const elem = document.getElementById(id);
|
||||
if (!elem) return;
|
||||
ro.observe(elem);
|
||||
new MutationObserver(runFooterFit).observe(elem, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true
|
||||
});
|
||||
});
|
||||
|
||||
const imgs = [document.getElementById('bjornStatusImage'), document.getElementById('bjorncharacter')];
|
||||
imgs.forEach(img => {
|
||||
if (!img) return;
|
||||
if (img.complete) runFooterFit();
|
||||
else img.addEventListener('load', runFooterFit, { once: true });
|
||||
});
|
||||
|
||||
// Initial run
|
||||
runFooterFit();
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
* Page launcher
|
||||
* ========================================= */
|
||||
|
||||
const NAV_MODE_KEY = 'bjorn.navMode'; // 'rail' or 'grid'
|
||||
function getNavMode() { return localStorage.getItem(NAV_MODE_KEY) || 'rail'; }
|
||||
function setNavMode(mode) { localStorage.setItem(NAV_MODE_KEY, mode); }
|
||||
|
||||
const PAGES = [
|
||||
{ path: '/dashboard', icon: 'home.png', label: 'nav.dashboard' },
|
||||
{ path: '/bjorn', icon: 'bjorn_icon.png', label: 'nav.bjorn' },
|
||||
{ path: '/netkb', icon: 'netkb.png', label: 'nav.netkb' },
|
||||
{ path: '/network', icon: 'network.png', label: 'nav.network' },
|
||||
{ path: '/credentials', icon: 'credentials.png', label: 'nav.credentials' },
|
||||
{ path: '/vulnerabilities', icon: 'vulnerabilities.png', label: 'nav.vulnerabilities' },
|
||||
{ path: '/attacks', icon: 'attacks.png', label: 'nav.attacks' },
|
||||
{ path: '/scheduler', icon: 'scheduler.png', label: 'nav.scheduler' },
|
||||
{ path: '/database', icon: 'database.png', label: 'nav.database' },
|
||||
{ path: '/files', icon: 'files_explorer.png', label: 'nav.files' },
|
||||
{ path: '/loot', icon: 'loot.png', label: 'nav.loot' },
|
||||
{ path: '/actions', icon: 'actions_launcher.png', label: 'nav.actions' },
|
||||
{ path: '/actions-studio', icon: 'actions_studio.png', label: 'nav.actionsStudio' },
|
||||
{ path: '/backup', icon: 'backup_update.png', label: 'nav.backup' },
|
||||
{ path: '/web-enum', icon: 'web_enum.png', label: 'nav.webEnum' },
|
||||
{ path: '/zombieland', icon: 'zombieland.png', label: 'nav.zombieland' },
|
||||
{ path: '/ai-dashboard', icon: 'ai_dashboard.png', label: 'nav.ai_dashboard' },
|
||||
{ path: '/bjorn-debug', icon: 'database.png', label: 'Bjorn Debug' },
|
||||
];
|
||||
|
||||
function wireLauncher() {
|
||||
const railOverlay = $('#launcher');
|
||||
const gridOverlay = $('#navOverlay');
|
||||
const navGrid = $('#navGrid');
|
||||
if (!railOverlay) return;
|
||||
|
||||
// Build rail launcher
|
||||
railOverlay.innerHTML = '';
|
||||
const scroll = el('div', { class: 'launcher-scroll' });
|
||||
for (const page of PAGES) {
|
||||
const card = el('button', {
|
||||
class: 'lbtn',
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
title: i18n.t(page.label),
|
||||
onclick: () => {
|
||||
router.navigate(page.path);
|
||||
closeLauncher();
|
||||
},
|
||||
}, [
|
||||
el('img', { src: `/web/images/${page.icon}`, alt: '', width: '48', height: '48' }),
|
||||
el('span', { class: 'lbtn-label', 'data-i18n': page.label }, [i18n.t(page.label)]),
|
||||
]);
|
||||
scroll.appendChild(card);
|
||||
}
|
||||
railOverlay.appendChild(scroll);
|
||||
|
||||
// Build grid launcher
|
||||
if (navGrid) {
|
||||
navGrid.innerHTML = '';
|
||||
for (const page of PAGES) {
|
||||
const card = el('button', {
|
||||
class: 'lbtn',
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
title: i18n.t(page.label),
|
||||
onclick: () => {
|
||||
router.navigate(page.path);
|
||||
closeNavOverlay();
|
||||
},
|
||||
}, [
|
||||
el('img', { src: `/web/images/${page.icon}`, alt: '', width: '48', height: '48' }),
|
||||
el('span', { class: 'lbtn-label', 'data-i18n': page.label }, [i18n.t(page.label)]),
|
||||
]);
|
||||
navGrid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// Close rail on outside click
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
const btn = $('#openLauncher');
|
||||
if (!railOverlay.classList.contains('show')) return;
|
||||
if (railOverlay.contains(e.target)) return;
|
||||
if (btn && btn.contains(e.target)) return;
|
||||
closeLauncher();
|
||||
});
|
||||
|
||||
// Close grid overlay on backdrop click
|
||||
if (gridOverlay) {
|
||||
gridOverlay.addEventListener('click', (e) => {
|
||||
if (e.target === gridOverlay) closeNavOverlay();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLauncher() {
|
||||
if (getNavMode() === 'grid') {
|
||||
toggleNavOverlay();
|
||||
} else {
|
||||
const overlay = $('#launcher');
|
||||
if (!overlay) return;
|
||||
const isOpen = overlay.getAttribute('aria-hidden') !== 'false';
|
||||
overlay.setAttribute('aria-hidden', String(!isOpen));
|
||||
overlay.classList.toggle('show', isOpen);
|
||||
}
|
||||
}
|
||||
|
||||
function closeLauncher() {
|
||||
const overlay = $('#launcher');
|
||||
if (!overlay) return;
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
overlay.classList.remove('show');
|
||||
}
|
||||
|
||||
function toggleNavOverlay() {
|
||||
const overlay = $('#navOverlay');
|
||||
if (!overlay) return;
|
||||
const isOpen = overlay.classList.contains('show');
|
||||
if (isOpen) {
|
||||
overlay.classList.remove('show');
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
} else {
|
||||
overlay.classList.add('show');
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function closeNavOverlay() {
|
||||
const overlay = $('#navOverlay');
|
||||
if (!overlay) return;
|
||||
overlay.classList.remove('show');
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
* Settings modal (tabbed: General, Theme, Config)
|
||||
* Uses the old-style modal-backdrop + modal with tabs
|
||||
* ========================================= */
|
||||
|
||||
function wireSettingsModal() {
|
||||
// Build modal content inside #settingsBackdrop
|
||||
const backdrop = $('#settingsBackdrop');
|
||||
if (!backdrop) return;
|
||||
|
||||
function buildSettings() {
|
||||
backdrop.innerHTML = '';
|
||||
const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Settings' });
|
||||
|
||||
// Tabs navigation
|
||||
const tabs = el('nav', { class: 'tabs', id: 'settingsTabs' });
|
||||
const btnGeneral = el('button', { class: 'tabbtn active', 'data-tab': 'general' }, ['General']);
|
||||
const btnTheme = el('button', { class: 'tabbtn', 'data-tab': 'theme' }, ['Theme']);
|
||||
const btnConfig = el('button', { class: 'tabbtn', 'data-tab': 'config' }, ['Config']);
|
||||
tabs.append(btnGeneral, btnTheme, btnConfig);
|
||||
|
||||
// General tab
|
||||
const tabGeneral = el('section', { class: 'tabpanel', id: 'tab-general' });
|
||||
tabGeneral.append(
|
||||
el('h3', {}, [i18n.t('settings.general')]),
|
||||
el('div', { class: 'row' }, [
|
||||
el('label', {}, ['Notifications']),
|
||||
el('div', { class: 'switch', id: 'switchNotifs' })
|
||||
]),
|
||||
el('div', { class: 'row' }, [
|
||||
el('label', {}, ['Navigation Mode']),
|
||||
el('select', { id: 'selectNavMode', class: 'select' }, [
|
||||
el('option', { value: 'rail' }, ['Floating Bar']),
|
||||
el('option', { value: 'grid' }, ['Grid Overlay']),
|
||||
])
|
||||
]),
|
||||
el('div', { class: 'row' }, [
|
||||
el('label', {}, [i18n.t('settings.language')]),
|
||||
])
|
||||
);
|
||||
// Set current nav mode selection
|
||||
const navModeSelect = tabGeneral.querySelector('#selectNavMode');
|
||||
if (navModeSelect) {
|
||||
navModeSelect.value = getNavMode();
|
||||
navModeSelect.addEventListener('change', () => setNavMode(navModeSelect.value));
|
||||
}
|
||||
// Mount language selector inside general tab
|
||||
const langRow = tabGeneral.querySelector('.row:last-child');
|
||||
if (langRow) i18n.mountLangSelector(langRow);
|
||||
|
||||
// Theme tab
|
||||
const tabTheme = el('section', { class: 'tabpanel', id: 'tab-theme', hidden: '' });
|
||||
tabTheme.append(el('h3', {}, [i18n.t('settings.theme')]));
|
||||
theme.mountEditor(tabTheme);
|
||||
|
||||
// Config tab
|
||||
const tabConfig = el('section', { class: 'tabpanel', id: 'tab-config', hidden: '' }, [
|
||||
el('div', { class: 'cfg-toolbar' }, [
|
||||
el('button', { class: 'btn', id: 'cfgReload' }, [i18n.t('common.refresh')]),
|
||||
el('button', { class: 'btn', id: 'cfgRestore' }, [i18n.t('common.reset')]),
|
||||
el('button', { class: 'btn btn-primary', id: 'cfgSave' }, [i18n.t('common.save')]),
|
||||
]),
|
||||
el('div', { id: 'configFormHost', class: 'cfg-host' }),
|
||||
]);
|
||||
|
||||
modal.append(tabs, tabGeneral, tabTheme, tabConfig);
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
const cfgHost = modal.querySelector('#configFormHost');
|
||||
settingsConfig.mountConfig(cfgHost);
|
||||
|
||||
// Tab switching
|
||||
tabs.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.tabbtn');
|
||||
if (!btn) return;
|
||||
const tabId = btn.dataset.tab;
|
||||
tabs.querySelectorAll('.tabbtn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
modal.querySelectorAll('.tabpanel').forEach(p => p.hidden = true);
|
||||
const panel = modal.querySelector(`#tab-${tabId}`);
|
||||
if (panel) panel.hidden = false;
|
||||
if (tabId === 'config') settingsConfig.loadConfig(cfgHost);
|
||||
});
|
||||
|
||||
// Notifications switch
|
||||
const notifSwitch = modal.querySelector('#switchNotifs');
|
||||
if (notifSwitch) {
|
||||
const notifOn = localStorage.getItem('bjorn.notifs') !== 'off';
|
||||
if (notifOn) notifSwitch.classList.add('on');
|
||||
notifSwitch.addEventListener('click', () => {
|
||||
notifSwitch.classList.toggle('on');
|
||||
localStorage.setItem('bjorn.notifs', notifSwitch.classList.contains('on') ? 'on' : 'off');
|
||||
});
|
||||
}
|
||||
|
||||
// Config actions
|
||||
modal.querySelector('#cfgReload')?.addEventListener('click', () => settingsConfig.loadConfig(cfgHost));
|
||||
modal.querySelector('#cfgSave')?.addEventListener('click', () => settingsConfig.saveConfig());
|
||||
modal.querySelector('#cfgRestore')?.addEventListener('click', () => settingsConfig.restoreDefaults(cfgHost));
|
||||
}
|
||||
|
||||
// Store build function for reuse
|
||||
backdrop._buildSettings = buildSettings;
|
||||
}
|
||||
|
||||
function toggleSettings() {
|
||||
const backdrop = $('#settingsBackdrop');
|
||||
if (!backdrop) return;
|
||||
|
||||
const isOpen = backdrop.style.display === 'flex';
|
||||
if (isOpen) {
|
||||
backdrop.style.display = 'none';
|
||||
backdrop.setAttribute('aria-hidden', 'true');
|
||||
} else {
|
||||
if (backdrop._buildSettings) backdrop._buildSettings();
|
||||
backdrop.style.display = 'flex';
|
||||
backdrop.setAttribute('aria-hidden', 'false');
|
||||
setTimeout(() => {
|
||||
const m = backdrop.querySelector('.modal');
|
||||
if (m) m.classList.add('show');
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Close settings on backdrop click (persistent listener) */
|
||||
document.addEventListener('click', (e) => {
|
||||
const backdrop = $('#settingsBackdrop');
|
||||
if (!backdrop || backdrop.style.display !== 'flex') return;
|
||||
if (e.target === backdrop) toggleSettings();
|
||||
});
|
||||
|
||||
/* Close settings on Escape (persistent listener) */
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const backdrop = $('#settingsBackdrop');
|
||||
if (!backdrop || backdrop.style.display !== 'flex') return;
|
||||
toggleSettings();
|
||||
});
|
||||
|
||||
/* =========================================
|
||||
* Chip Editor (global singleton)
|
||||
* Wires the existing #chipEditBackdrop from HTML
|
||||
* ========================================= */
|
||||
|
||||
function wireChipEditor() {
|
||||
const backdrop = $('#chipEditBackdrop');
|
||||
if (!backdrop || window.ChipsEditor) return;
|
||||
|
||||
const title = $('#chipEditTitle');
|
||||
const label = $('#chipEditLabel');
|
||||
const input = $('#chipEditInput');
|
||||
const ta = $('#chipEditTextarea');
|
||||
const btnSave = $('#chipEditSave');
|
||||
const btnCancel = $('#chipEditCancel');
|
||||
const btnClose = $('#chipEditClose');
|
||||
if (!input || !ta || !btnSave) return;
|
||||
|
||||
let resolver = null;
|
||||
function show() { backdrop.classList.add('show'); requestAnimationFrame(() => (input.offsetParent ? input : ta).focus()); }
|
||||
function hide() { backdrop.classList.remove('show'); resolver = null; }
|
||||
function currentValue() { return (input.offsetParent ? input.value : ta.value).trim(); }
|
||||
function resolve(val) { if (resolver) { resolver(val); hide(); } }
|
||||
function save() { resolve(currentValue()); }
|
||||
function cancel() { resolve(null); }
|
||||
|
||||
btnSave.addEventListener('click', save);
|
||||
btnCancel.addEventListener('click', cancel);
|
||||
btnClose.addEventListener('click', cancel);
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) cancel(); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!backdrop.classList.contains('show')) return;
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||
if (e.key === 'Enter' && e.target.closest('#chipEditBackdrop') && e.target.id !== 'chipEditTextarea') {
|
||||
e.preventDefault(); save();
|
||||
}
|
||||
});
|
||||
|
||||
window.ChipsEditor = {
|
||||
open(opts = {}) {
|
||||
const { value = '', title: ttl = 'Edit value', label: lab = 'Value', placeholder = '', multiline = false, maxLength, confirmLabel = 'Save' } = opts;
|
||||
if (title) title.textContent = ttl;
|
||||
if (label) label.textContent = lab;
|
||||
if (btnSave) btnSave.textContent = confirmLabel;
|
||||
if (multiline) {
|
||||
ta.style.display = '';
|
||||
input.style.display = 'none';
|
||||
ta.value = value;
|
||||
ta.placeholder = placeholder;
|
||||
ta.removeAttribute('maxlength');
|
||||
if (maxLength) ta.setAttribute('maxlength', String(maxLength));
|
||||
} else {
|
||||
input.style.display = '';
|
||||
ta.style.display = 'none';
|
||||
input.value = value;
|
||||
input.placeholder = placeholder;
|
||||
input.removeAttribute('maxlength');
|
||||
if (maxLength) input.setAttribute('maxlength', String(maxLength));
|
||||
}
|
||||
show();
|
||||
return new Promise(res => { resolver = res; });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user