mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-10 14:42:04 +00:00
- 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.
721 lines
25 KiB
JavaScript
721 lines
25 KiB
JavaScript
/**
|
|
* 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; });
|
|
}
|
|
};
|
|
} |