import { $, el, toast, empty } from './dom.js'; import { api } from './api.js'; import { t } from './i18n.js'; const API = { load: '/load_config', save: '/save_config', restore: '/restore_default_config', }; const DEFAULT_RANGE = { min: 0, max: 100, step: 1 }; const RANGES = { web_delay: { min: 0, max: 10000, step: 1 }, screen_delay: { min: 0, max: 10, step: 0.1 }, startup_delay: { min: 0, max: 600, step: 0.1 }, startup_splash_duration: { min: 0, max: 60, step: 0.1 }, fullrefresh_delay: { min: 0, max: 3600, step: 1 }, image_display_delaymin: { min: 0, max: 600, step: 0.1 }, image_display_delaymax: { min: 0, max: 600, step: 0.1 }, comment_delaymin: { min: 0, max: 600, step: 0.1 }, comment_delaymax: { min: 0, max: 600, step: 0.1 }, shared_update_interval: { min: 1, max: 86400, step: 1 }, livestatus_delay: { min: 0, max: 600, step: 0.1 }, ref_width: { min: 32, max: 1024, step: 1 }, ref_height: { min: 32, max: 1024, step: 1 }, vuln_max_ports: { min: 1, max: 65535, step: 1 }, portstart: { min: 0, max: 65535, step: 1 }, portend: { min: 0, max: 65535, step: 1 }, frise_default_x: { min: 0, max: 2000, step: 1 }, frise_default_y: { min: 0, max: 2000, step: 1 }, frise_epd2in7_x: { min: 0, max: 2000, step: 1 }, frise_epd2in7_y: { min: 0, max: 2000, step: 1 }, semaphore_slots: { min: 1, max: 128, step: 1 }, line_spacing: { min: 0, max: 10, step: 0.1 }, vuln_update_interval: { min: 1, max: 86400, step: 1 }, }; let _host = null; let _lastConfig = null; function resolveTooltips(config) { const tips = config?.__tooltips_i18n__; if (!tips || typeof tips !== 'object' || Array.isArray(tips)) return {}; return tips; } function createFieldLabel(key, forId = null, tooltipI18nKey = '') { const attrs = {}; if (forId) attrs.for = forId; if (tooltipI18nKey) { attrs['data-i18n-title'] = tooltipI18nKey; attrs.title = t(tooltipI18nKey); } return el('label', attrs, [key]); } function getRangeForKey(key, value) { if (RANGES[key]) return RANGES[key]; const n = Number(value); if (Number.isFinite(n)) { if (n <= 10) return { min: 0, max: 10, step: 1 }; if (n <= 100) return { min: 0, max: 100, step: 1 }; if (n <= 1000) return { min: 0, max: 1000, step: 1 }; return { min: 0, max: Math.ceil(n * 2), step: Math.max(1, Math.round(n / 100)) }; } return DEFAULT_RANGE; } function normalizeNumber(raw) { const s = String(raw ?? '').trim().replace(',', '.'); if (!s || s === '-' || s === '.' || s === '-.') return NaN; const n = parseFloat(s); return Number.isFinite(n) ? n : NaN; } function ensureChipHelpers() { if (window.Chips) return; const makeChip = (text) => { const chip = el('div', { class: 'cfg-chip' }, [ el('span', {}, [text]), el('button', { class: 'cfg-chip-close', type: 'button', 'aria-label': 'Remove' }, ['x']), ]); return chip; }; document.addEventListener('click', (e) => { const close = e.target.closest('.cfg-chip-close'); if (close) close.closest('.cfg-chip')?.remove(); }); document.addEventListener('keydown', async (e) => { if (!e.target || !(e.target instanceof HTMLInputElement)) return; const input = e.target; const wrap = input.closest('.cfg-chip-input'); if (!wrap) return; if (e.key !== 'Enter' && e.key !== ',') return; e.preventDefault(); const list = wrap.parentElement.querySelector('.cfg-chip-list'); if (!list) return; const values = input.value .split(',') .map(v => v.trim()) .filter(Boolean); if (!values.length) return; const existing = new Set(Array.from(list.querySelectorAll('.cfg-chip span')).map(s => s.textContent)); values.forEach(v => { if (existing.has(v)) return; list.appendChild(makeChip(v)); }); input.value = ''; }); document.addEventListener('click', async (e) => { const chip = e.target.closest('.cfg-chip'); if (!chip || e.target.closest('.cfg-chip-close')) return; if (!window.ChipsEditor) return; const span = chip.querySelector('span'); const cur = span?.textContent || ''; const next = await window.ChipsEditor.open({ value: cur, title: t('settings.editValue'), label: t('common.value'), multiline: false, }); if (next === null) return; const val = String(next).trim(); if (!val) { chip.remove(); return; } const list = chip.parentElement; const exists = Array.from(list.querySelectorAll('.cfg-chip span')).some(s => s !== span && s.textContent === val); if (exists) return; if (span) span.textContent = val; }); window.Chips = { values(root) { return Array.from(root.querySelectorAll('.cfg-chip span')).map(s => s.textContent); }, setValues(root, values = []) { empty(root); values.forEach(v => root.appendChild(makeChip(String(v)))); }, }; } function createBooleanField(key, value, tooltipI18nKey = '') { return el('div', { class: 'cfg-field cfg-toggle-row', 'data-key': key, 'data-type': 'boolean' }, [ createFieldLabel(key, `cfg_${key}`, tooltipI18nKey), el('label', { class: 'switch' }, [ el('input', { id: `cfg_${key}`, type: 'checkbox', ...(value ? { checked: '' } : {}) }), el('span', { class: 'slider' }), ]), ]); } function createNumberField(key, value, tooltipI18nKey = '') { const range = getRangeForKey(key, value); const n = Number.isFinite(Number(value)) ? Number(value) : range.min; const row = el('div', { class: 'cfg-field', 'data-key': key, 'data-type': 'number' }, [ createFieldLabel(key, `cfg_${key}`, tooltipI18nKey), el('div', { class: 'cfg-number' }, [ el('button', { class: 'btn cfg-nudge', type: 'button', 'data-act': 'dec' }, ['-']), el('input', { id: `cfg_${key}`, class: 'input cfg-number-input', type: 'text', inputmode: 'decimal', value: String(n).replace('.', ','), }), el('button', { class: 'btn cfg-nudge', type: 'button', 'data-act': 'inc' }, ['+']), ]), el('input', { class: 'cfg-range', type: 'range', min: String(range.min), max: String(range.max), step: String(range.step), value: String(Math.min(range.max, Math.max(range.min, n))), }), ]); const textInput = row.querySelector('.cfg-number-input'); const slider = row.querySelector('.cfg-range'); const decBtn = row.querySelector('[data-act="dec"]'); const incBtn = row.querySelector('[data-act="inc"]'); const clamp = (v) => Math.max(range.min, Math.min(range.max, v)); const paint = () => { const cur = Number(slider.value); const pct = ((cur - range.min) * 100) / (range.max - range.min || 1); slider.style.backgroundSize = `${pct}% 100%`; }; const syncFromText = () => { const parsed = normalizeNumber(textInput.value); if (Number.isFinite(parsed)) { slider.value = String(clamp(parsed)); paint(); } }; const syncFromRange = () => { textInput.value = String(slider.value).replace('.', ','); paint(); }; const nudge = (dir) => { const parsed = normalizeNumber(textInput.value); const base = Number.isFinite(parsed) ? parsed : Number(slider.value); const next = +(base + dir * range.step).toFixed(10); textInput.value = String(next).replace('.', ','); slider.value = String(clamp(next)); paint(); }; textInput.addEventListener('input', syncFromText); textInput.addEventListener('change', syncFromText); slider.addEventListener('input', syncFromRange); decBtn.addEventListener('click', () => nudge(-1)); incBtn.addEventListener('click', () => nudge(1)); paint(); return row; } function createListField(key, value, tooltipI18nKey = '') { const list = Array.isArray(value) ? value : []; const node = el('div', { class: 'cfg-field', 'data-key': key, 'data-type': 'list' }, [ createFieldLabel(key, null, tooltipI18nKey), el('div', { class: 'cfg-chip-list' }), el('div', { class: 'cfg-chip-input' }, [ el('input', { class: 'input', type: 'text', placeholder: t('settings.addValues') }), ]), ]); const chipList = node.querySelector('.cfg-chip-list'); window.Chips.setValues(chipList, list); return node; } function createStringField(key, value, tooltipI18nKey = '') { const node = el('div', { class: 'cfg-field', 'data-key': key, 'data-type': 'string' }, [ createFieldLabel(key, null, tooltipI18nKey), el('div', { class: 'cfg-chip-list' }), el('div', { class: 'cfg-chip-input' }, [ el('input', { class: 'input', type: 'text', placeholder: t('settings.setValue') }), ]), ]); const chipList = node.querySelector('.cfg-chip-list'); if (value !== undefined && value !== null && String(value) !== '') { window.Chips.setValues(chipList, [String(value)]); } return node; } function createSectionCard(title) { return el('div', { class: 'card cfg-card' }, [ el('div', { class: 'head' }, [el('h3', { class: 'title' }, [title])]), el('div', { class: 'cfg-card-body' }), ]); } function render(config) { if (!_host) return; empty(_host); ensureChipHelpers(); const tooltips = resolveTooltips(config); const togglesCard = createSectionCard(t('settings.toggles')); const togglesBody = togglesCard.querySelector('.cfg-card-body'); const cardsGrid = el('div', { class: 'cfg-cards-grid' }); let currentCard = null; for (const [key, value] of Object.entries(config || {})) { if (key.startsWith('__')) { if (key.startsWith('__title_')) { if (currentCard) cardsGrid.appendChild(currentCard); currentCard = createSectionCard(String(value).replace('__title_', '').replace(/__/g, '')); } continue; } const tooltipI18nKey = String(tooltips[key] || ''); if (typeof value === 'boolean') { togglesBody.appendChild(createBooleanField(key, value, tooltipI18nKey)); continue; } if (!currentCard) currentCard = createSectionCard(t('settings.general')); const body = currentCard.querySelector('.cfg-card-body'); if (Array.isArray(value)) body.appendChild(createListField(key, value, tooltipI18nKey)); else if (typeof value === 'number') body.appendChild(createNumberField(key, value, tooltipI18nKey)); else body.appendChild(createStringField(key, value, tooltipI18nKey)); } if (currentCard) cardsGrid.appendChild(currentCard); _host.appendChild(togglesCard); _host.appendChild(cardsGrid); } function collect() { const payload = {}; if (!_host) return payload; _host.querySelectorAll('.cfg-field[data-key]').forEach(field => { const key = field.getAttribute('data-key'); const type = field.getAttribute('data-type'); if (!key || !type) return; if (type === 'boolean') { payload[key] = !!field.querySelector('input[type="checkbox"]')?.checked; return; } if (type === 'number') { const n = normalizeNumber(field.querySelector('.cfg-number-input')?.value); payload[key] = Number.isFinite(n) ? n : 0; return; } if (type === 'list') { payload[key] = window.Chips.values(field.querySelector('.cfg-chip-list')); return; } if (type === 'string') { const values = window.Chips.values(field.querySelector('.cfg-chip-list')); payload[key] = values[0] ?? ''; } }); return payload; } export async function loadConfig(host = _host) { if (host) _host = host; if (!_host) return; try { const config = await api.get(API.load, { timeout: 15000, retries: 0 }); _lastConfig = config; render(config); } catch (err) { toast(`${t('settings.errorLoading')}: ${err.message}`, 3200, 'error'); } } export async function saveConfig() { if (!_host) return; try { const payload = collect(); await api.post(API.save, payload, { timeout: 20000, retries: 0 }); toast(t('settings.configSaved'), 2200, 'success'); } catch (err) { toast(`${t('settings.errorSaving')}: ${err.message}`, 3200, 'error'); } } export async function restoreDefaults(host = _host) { if (host) _host = host; if (!_host) return; try { const config = await api.get(API.restore, { timeout: 20000, retries: 0 }); _lastConfig = config; render(config); toast(t('settings.defaultsRestored'), 2200, 'success'); } catch (err) { toast(`${t('settings.errorRestoring')}: ${err.message}`, 3200, 'error'); } } export function mountConfig(host) { _host = host || _host; } export function hasLoadedConfig() { return !!_lastConfig; }