/** * ui.js — UI rendering, event handling */ (function() { 'use strict'; const S = window.AppState; // Memory names are free-typed by the user (via textPrompt) - escape before // innerHTML, same convention as the rest of the project. function _esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); } // ── Formatting ──────────────────────────────────────────────────────────────── function fmtFreq(hz) { if (hz >= 1e6) { const s = (hz / 1e6).toFixed(5); const [i, d] = s.split('.'); return `${i}.${d.slice(0,3)}.${d.slice(3)}`; } return (hz / 1000).toFixed(3); } function fmtBand(freq) { for (const [b, r] of Object.entries(S.bands)) { if (freq >= r.min && freq <= r.max) return b; } return '?'; } // ── Connection ──────────────────────────────────────────────────────────────── function updateConnectionStatus(on) { const dot = document.getElementById('conn-dot'); const label = document.getElementById('conn-label'); const sim = document.getElementById('sim-badge'); if (dot) dot.className = 'dot ' + (on ? 'green' : 'red'); if (label) label.textContent = on ? 'ONLINE' : 'OFFLINE'; if (sim) sim.style.display = S.sim ? 'inline' : 'none'; updateDisconnectBadge(); } // "Radio disconnected for Xs" badge — before this, the only sign of a CI-V // drop (dead port, momentary USB/power blip) was a line in the server's own // console, invisible to a remote operator. S.rigDisconnectedSince (epoch // seconds, or null while connected) arrives via 'init' and the dedicated // 'rig_disconnected' WS message (civ.py's _start_sim()/_open()). Runs its // own 1s ticker instead of piggybacking on telemetry broadcasts, which stop // entirely while disconnected (that's the whole problem this fixes). let _disconnTicker = null; function updateDisconnectBadge() { const badge = document.getElementById('rig-disconnected-badge'); if (!badge) return; if (!S.rigDisconnectedSince) { badge.style.display = 'none'; if (_disconnTicker) { clearInterval(_disconnTicker); _disconnTicker = null; } return; } const render = () => { const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - S.rigDisconnectedSince)); const h = Math.floor(elapsed / 3600), m = Math.floor((elapsed % 3600) / 60), s = elapsed % 60; const pad = n => String(n).padStart(2, '0'); const dur = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; badge.textContent = `⚠ ${I18n.t('rig_disconnected_badge')} ${dur}`; }; render(); badge.style.display = 'inline'; if (!_disconnTicker) _disconnTicker = setInterval(render, 1000); } // ── Frequency ───────────────────────────────────────────────────────────────── // Spot-click auto-filled the quick-log callsign (see dxcluster.js::qsy) - // once the VFO drifts more than a few kHz away from that spot's frequency // we're probably no longer listening to that station, so drop it again. // Called from updateFreqDisplay() AND from vfo.js's digit-scroll/click // stepping (_applyDigitStep) - that path updates S.freq and its own compact // VFO display directly, WITHOUT going through updateFreqDisplay() (it's the // fast/frequent interaction path and skips the full re-render on purpose), // so without this second call site the pin never got checked for the most // common way of actually moving the VFO off a spot. const _QLOG_PIN_TOLERANCE_HZ = 5000; // 5 kHz - a small tuning nudge within // the same signal/pileup shouldn't // drop the auto-filled call. function checkQlogSpotPin() { if (S._qlogPinnedFreq != null && Math.abs(S.freq - S._qlogPinnedFreq) > _QLOG_PIN_TOLERANCE_HZ) { S._qlogPinnedFreq = null; const callEl = document.getElementById('qlog-call'); if (callEl) { callEl.value = ''; callEl.style.color = ''; callEl.style.borderColor = ''; } window.RotorMini?.clearHint?.(); } } function updateFreqDisplay() { checkQlogSpotPin(); const el = document.getElementById('freq-display'); if (el) el.textContent = fmtFreq(S.freq); const elB = document.getElementById('freq-b-display'); if (elB) elB.textContent = fmtFreq(S.freqB); updateBandButtons(); // Refresh the digital VFO display (rendered by vfo.js) window.VFO?.updateVFODisplay?.(); } // Color theme — saved in localStorage per user. function setTheme(theme) { const valid = ['default', 'blue', 'mono', 'amber-classic']; if (!valid.includes(theme)) theme = 'default'; if (theme === 'default') { document.documentElement.removeAttribute('data-theme'); } else { document.documentElement.setAttribute('data-theme', theme); } try { const uid = window.CurrentUser?.id || 'default'; localStorage.setItem(`theme_${uid}`, theme); } catch(e) {} const sel = document.getElementById('profile-theme'); if (sel) sel.value = theme; } // Load the saved theme on startup function loadTheme() { try { const uid = window.CurrentUser?.id || 'default'; const saved = localStorage.getItem(`theme_${uid}`) || 'default'; setTheme(saved); } catch(e) {} } function _canControlRadio() { const lock = window.AppState?.radio_lock; const myUid = String(window.AppState?.my_uid || window.CurrentUser?.id || ''); const role = window.CurrentUser?.role; if (role === 'admin') return true; if (!lock?.locked) return false; return String(lock.user_id) === myUid; } // Grays out all radio controls when the user doesn't hold the TRX. The // user sees everything (freq, waterfall, S-meter) but can't click or send // commands. The admin's UI stays fully enabled. function applyRadioLockUI() { const canControl = _canControlRadio(); const page = document.getElementById('page-radio'); if (!page) return; // The radio_lock block goes EXCLUSIVELY through this class (CSS // pointer-events in style.css, .radio-readonly .ptt-btn and // .radio-readonly button:not(...)) — this is the only place that // decides who can click. updatePTT() below knows NOTHING about // radio_lock (see its definition) — it only refreshes the cross-band // split guard, so it must be called here in order to run before the UI returns. page.classList.toggle('radio-readonly', !canControl); updatePTT(); } function sendFreq(freq) { freq = parseInt(freq); if (!freq || freq < 100000) return; if (!_canControlRadio()) { const holder = window.AppState?.radio_lock?.callsign || window.AppState?.radio_lock?.username || '?'; showToast(`⛔ Radio zajęte przez ${holder} — przejmij TRX`, 'error'); return; } S.freq = freq; S._localFreqSetAt = Date.now(); updateFreqDisplay(); updateVFOBadges(); // update the band badge immediately (don't wait for the server) updatePTT(); // cross-band guard scheduleBandMemorySave(); // save to band history/memory (debounced 2s) WS.sendFreqFast ? WS.sendFreqFast(freq) : WS.send({ type: 'freq', freq }); } function tune(dir) { const step = parseInt(document.getElementById('step-select')?.value || 1000); const f = Math.max(100000, S.freq + dir * step); sendFreq(f); } function gotoFreq(freq, mode) { sendFreq(freq); if (mode && mode !== S.mode) setMode(mode); } // ── Mode ────────────────────────────────────────────────────────────────────── function buildModeGrid() { const modes = (S.modes && S.modes.length) ? S.modes : ['USB','LSB','AM','FM','CW']; // DIAGNOSTIC (2026-10-21, see the matching note in loadBandsConfig above) - // logs every single call, including the caller (via console.trace-style // stack in the 2nd console.log arg), so a double-render from two different // triggers (app:ready vs a WS config_update) with different values would // show up as two distinct log lines here. console.log('[modes-debug] buildModeGrid: S.modes =', S.modes, '-> rendering', modes); const modeHtml = modes.map(m => `` ).join(''); ['mode-grid','mode-grid-left'].forEach(id => { const el2 = document.getElementById(id); if (el2) el2.innerHTML = modeHtml; }); } function setMode(mode, bw) { S.mode = mode; if (bw) S.bandwidth = bw; updateModeButtons(); updateVFOBadges(); // update the mode badge immediately // Default filter for the mode — can be '1'/'2'/'3' const filterRaw = S.modeFilters?.[mode]; let filterNum = null; if (filterRaw) { const n = parseInt(String(filterRaw).replace(/[^0-9]/g, '')); if (n >= 1 && n <= 3) filterNum = n; } // Update the filter select in the UI if (filterNum) { const sel = document.getElementById('bw-select'); if (sel) sel.value = String(filterNum); } const msg = { type: 'mode', mode, bandwidth: S.bandwidth }; if (filterNum) msg.filterNum = filterNum; WS.send(msg); } function updateModeButtons() { document.querySelectorAll('.mode-btn').forEach(b => { b.classList.toggle('active', b.textContent === S.mode); }); // Suggest RST in "LOG QSO" (CW->599, phone->59) — called from here // because updateModeButtons() already runs on EVERY mode change (click, // telemetry, WS 'mode'), so it's the only place that needs hooking up. window.QSOLog?.updateRstDefaults?.(S.mode); } // ── Bands ───────────────────────────────────────────────────────────────────── // S.bands used to ALWAYS be empty on the client (never populated) — hence // the band buttons never highlighted, and the fallback in buildBandGrid() // ('20m': {def:...} WITHOUT min/max) made updateBandButtons() compare // f>=undefined, which is always false. The backend HAS the correct data // (min/max/def for 14 bands) at /api/config/bands, but nothing used to // fetch it into S.bands. Called once on startup (app:ready) and on every // 'config_update' from the backend (when the admin changes the list of // enabled bands in settings). async function loadBandsConfig() { try { const [rb, rm] = await Promise.all([ fetch('/api/config/bands'), fetch('/api/config/modes'), ]); const db = await rb.json(); const dm = await rm.json(); const allBands = db.allBands || {}; const enabled = new Set(db.enabledBands || Object.keys(allBands)); const bands = {}; for (const [name, info] of Object.entries(allBands)) { if (enabled.has(name)) bands[name] = info; } S.bands = bands; // Modes and filters S.modes = dm.enabledModes || dm.allModes || ['USB','LSB','AM','FM','CW']; S.modeFilters = dm.modeFilters || {}; // DIAGNOSTIC (2026-10-21, live report: some operators intermittently see // ALL modes as buttons on the RADIO tab instead of the admin-restricted // set, even though the server-side modes_config.log confirms the backend // always returned the correct restricted list for every request in that // session - this is the frontend-side counterpart, to catch it from the // browser's own console (F12) next time it happens, since static code // review found only this one call site setting S.modes). console.log('[modes-debug] loadBandsConfig: raw response', dm, '-> S.modes =', S.modes); buildBandGrid(); buildModeGrid(); updateBandButtons(); } catch (e) { console.warn('[ui] loadBandsConfig error:', e); } } // Per-user localStorage frequency memory per band. The key is the band // name (e.g. '20m'), the value is an object { freq, mode, ts }. function _bandMemoryKey() { const uid = window.CurrentUser?.id || 'default'; return `bandFreq_${uid}`; } function _loadBandMemory() { try { return JSON.parse(localStorage.getItem(_bandMemoryKey()) || '{}'); } catch { return {}; } } function _saveBandMemory(mem) { try { localStorage.setItem(_bandMemoryKey(), JSON.stringify(mem)); } catch(e) { console.warn('[band-mem] save error:', e); } } // Save the current freq+mode to the band memory (called after every freq change) function saveBandMemory() { const band = getBandName(S.freq); if (!band || band.includes('MHz') || band.includes('Hz')) return; // don't save when out of band const mem = _loadBandMemory(); mem[band] = { freq: S.freq, mode: S.mode, ts: Date.now() }; _saveBandMemory(mem); } function buildBandGrid() { const bands = Object.keys(S.bands).length ? S.bands : { '20m': { def: 14200000 } }; const mem = _loadBandMemory(); const bandHtml = Object.entries(bands) .map(([b, r]) => { // Use the remembered freq if present, otherwise the default from the config const remembered = mem[b]; const freq = remembered?.freq || r.def; const mode = remembered?.mode || S.mode; return ``; }) .join(''); ['band-grid','band-grid-left'].forEach(id => { const el2 = document.getElementById(id); if (el2) el2.innerHTML = bandHtml; }); const el = document.getElementById('band-grid') || document.getElementById('band-grid-left'); if (!el) return; updateBandButtons(); // highlight the correct button right away based on current S.freq } function updateBandButtons() { // Highlight the band by the ACTIVE VFO — freqB when VFO B is active, // freq when VFO A is active (default). It used to always use S.freq, // which meant that after switching to VFO B the band buttons showed // VFO A's band instead of VFO B's current band. const f = (S.vfo === 'VFOB') ? (S.freqB || S.freq) : S.freq; document.querySelectorAll('.band-btn').forEach(btn => { const band = S.bands[btn.textContent]; btn.classList.toggle('active', !!band && f >= band.min && f <= band.max); }); } // Fetch the bands once on app startup (auth.js emits 'app:ready' after // login — the same pattern settings.js uses to fetch rig models). window.addEventListener('app:ready', () => { loadBandsConfig(); loadTheme(); }); // ── PTT ─────────────────────────────────────────────────────────────────────── let _pttOwnsMic = false; function setPTT(on) { S.ptt = !!on; updatePTT(); // TX mic tied directly to PTT (SSB/AM/FM only - CW is keyed via CI-V // text in civ.py, never touches the microphone at all). Used to // auto-start once at login instead - live test showed that keeps the // mic (getUserMedia) open for the WHOLE session, and Windows' // "Communications" audio ducking (Control Panel > Sound) then mutes/ // reduces ALL other system sound - including our own RX - for as long // as ANY app holds the mic open, not just while actually transmitting. // Opening it only for the PTT window keeps that ducking limited to // exactly when RX is already intentionally muted anyway (setTxAudioDuck, // above in updatePTT) - no NEW loss of RX audio versus today's normal PTT. if (S.mode !== 'CW' && on && !window.WS?.isTxActive?.()) { // FIX (2026-10-17, live report via an independent SDR in Italy: // "wcisniecie PTT nie odrazu transmituje, jest zwloka"): this used to // send the CI-V ptt:true WS message FIRST and only start the mic/ // audio pipeline (getUserMedia -> WebRTC) afterward - proven via // ptt_timing.log on the live station: audio.tx_active was False on // EVERY single PTT-ON, meaning the radio got keyed (carrier up) before // the mic had even started, let alone before any audio reached the // radio - dead air on the carrier until the mic/network caught up. // Reversed: start the audio pipeline FIRST, key the radio (send // ptt:true) only once startTX() actually resolves. Still not // zero-latency (mic/encoder startup takes real time regardless), but // the carrier no longer sits keyed with silence before modulation // begins. If the mic fails to start (permission denied, no device - // _txMic.start() already shows its own toast for that), the radio is // never keyed at all instead of transmitting dead air. _pttOwnsMic = true; window.WS?.startTX?.().then(ok => { if (ok) { _syncTxMicBtn(true); WS.send({ type: 'ptt', ptt: true }); } else { _pttOwnsMic = false; S.ptt = false; updatePTT(); } }); return; } if (S.mode !== 'CW' && on) { // Mic already running (operator left "Nadawanie TX - mikrofon" on // manually) - audio is already flowing, nothing to wait for. _pttOwnsMic = false; } else if (S.mode !== 'CW' && !on && _pttOwnsMic) { _pttOwnsMic = false; window.WS?.stopTX?.(); _syncTxMicBtn(false); } WS.send({ type: 'ptt', ptt: on }); } // Keeps the manual "Nadawanie TX - mikrofon" button (RADIO tab) in sync // when PTT starts/stops it automatically, so its label/color don't go // stale relative to what's actually running. function _syncTxMicBtn(active) { const btn = document.getElementById('tx-mic-btn'); if (!btn) return; btn.removeAttribute('data-i18n'); btn.textContent = I18n.t(active ? 'tx_mic_stop' : 'tx_mic_start'); btn.style.color = active ? 'var(--red)' : 'var(--dim)'; btn.style.borderColor = active ? 'var(--red)' : 'rgba(var(--red-rgb),0.3)'; } function updatePTT() { const btn = document.getElementById('ptt-btn'); const dot = document.getElementById('ptt-dot'); const lbl = document.getElementById('ptt-label'); if (btn) btn.classList.toggle('active', S.ptt); if (dot) dot.className = 'dot ' + (S.ptt ? 'red' : 'green'); if (lbl) lbl.textContent = S.ptt ? 'TX' : 'RX'; // Mute RX during EVERY transmission (phone and digital). With MONI on, // the radio puts a monitor of its own TX on the USB-out -> on phone you // hear your own ECHO, on FT8 squealing tones. During TX there's nothing // on RX anyway, so the duck doesn't drop anything. Idempotent with the FT8 duck (ft8_tx_status). window.setTxAudioDuck?.(!!S.ptt); // Cross-band split guard: disable PTT when VFO-A and VFO-B are on // different bands (protects the radio/antenna from transmitting on the wrong band) const cross = isCrossBandSplit(); if (btn) { if (cross && !S.ptt) { btn.disabled = true; btn.classList.add('crossband-blocked'); btn.title = `⛔ Cross-band split: RX ${getBandName(S.freq)} / TX ${getBandName(S.freqB || S.freq)}. Wylacz split zeby nadawac.`; } else { btn.disabled = false; btn.classList.remove('crossband-blocked'); btn.title = ''; } } } // Checks whether split is active and VFO-A/B are on different bands. // Called from updatePTT and after every freq/freqB/split change. function isCrossBandSplit() { if (!S.split) return false; const bandA = getBandName(S.freq); const bandB = getBandName(S.freqB || S.freq); return bandA !== bandB; } // ── S-meter ─────────────────────────────────────────────────────────────────── function updateSMeter(val) { S.sMeter = val; // The bargraph maps the REAL IC-7300 S-meter scale, matched to the tick // marks in the HTML (8 labels: 1,3,5,7,9,+20,+40,+60 spread evenly via // space-between, so "9" sits at 4/7 = ~57% of the width): // S0..S9 -> 0..57% (S9 under the "9" label) // S9..S9+60dB -> 57..100% (+20@71%, +40@86%, +60@100%) // It used to hit 100% already at S9 and never showed S9+ at all. // val: 0..9 = S0..S9, 9..15 = S9+0..+60dB (each unit of val>9 is +10dB). let pct; if (val <= 9) { pct = (val / 9) * 57; // S0..S9 -> 0..57% (under the "9" label) } else { pct = 57 + ((val - 9) / 6) * 43; // S9..S9+60dB (val 9..15) -> 57..100% } pct = Math.min(100, Math.max(0, pct)); const fill = document.getElementById('smeter-fill'); const disp = document.getElementById('smeter-value'); const lbl = document.getElementById('smeter-text'); if (fill) fill.style.clipPath = `inset(0 ${100 - pct}% 0 0)`; const txt = val <= 9 ? `S ${Math.round(val)}` : `S9+${Math.round((val - 9) * 10)}dB`; if (disp) disp.textContent = txt; if (lbl) lbl.textContent = txt; } // ── TRX meter (ALC/PWR/SWR/VOLT) ───────────────────────────────────────────── // Stores the last known value of EACH of the 4 meters (the backend sends // them all cyclically, regardless of which one a given user is currently // watching) — this way switching the select shows the data immediately, // without waiting for the next CI-V polling cycle (up to 2s with PTT off, see civ.py n%8==0). const _txMeterValues = { ALC: null, PWR: null, SWR: null, VOLT: null, ID: null }; let _txMeterSelected = 'ALC'; const _TXMETER_UNITS = { ALC: '%', PWR: '%', SWR: '', VOLT: 'V', ID: 'A' }; // Tick marks below the bar — matched to the REAL reading range from the // radio (civ.py), not a plain 0-100 scale. Each point: {at: position in % // of the bar's width (0-100), label: text}. For ALC/PWR, pct is linear // relative to the value, so the ticks are linear too. For SWR, pct = // min(1.0, raw/120) while value = 1.0 + (raw/241)*49 — these two scales // are NOT linearly related, so the 1.5/2.0/3.0 positions have to be // converted back to pct (see the civ.py comment: 0=1.0, 48=1.5, 80=2.0, // 120=3.0, where pct=raw/120 — e.g. 1.5 is at pct=48/120=40%). const _TXMETER_SCALES = { ALC: [{at:0,label:'0'}, {at:25,label:'25'}, {at:50,label:'50'}, {at:75,label:'75'}, {at:100,label:'100%'}], PWR: [{at:0,label:'0'}, {at:25,label:'25'}, {at:50,label:'50'}, {at:75,label:'75'}, {at:100,label:'100%'}], SWR: [{at:0,label:'1.0'}, {at:40,label:'1.5'}, {at:66.7,label:'2.0'}, {at:100,label:'3.0'}], VOLT: [], // no ticks — VOLT is a stable number, not a moving scale (see below) // ID (supply current): pct = amps/25 (civ.py), so the real calibration // breakpoints (0/10/15/25A) land at 0/40/60/100% - same idea as SWR above. ID: [{at:0,label:'0'}, {at:40,label:'10'}, {at:60,label:'15'}, {at:100,label:'25A'}], }; function updateTxMeter(msg) { if (!msg.meter || !(msg.meter in _txMeterValues)) return; _txMeterValues[msg.meter] = msg; if (msg.meter === _txMeterSelected) _renderTxMeter(); } function setTxMeter(meter) { if (!(meter in _txMeterValues)) return; _txMeterSelected = meter; // The only select driving the bargraph now lives in the left column // (TRX FUNCTIONS section below BAND) — we sync it in case setTxMeter() // is called from somewhere other than the dropdown itself. const sel = document.getElementById('trx-funkcje-select'); if (sel) sel.value = meter; _renderTxMeter(); } function _renderTxMeter() { const label = document.getElementById('txmeter-label'); const fill = document.getElementById('txmeter-fill'); const value = document.getElementById('txmeter-value'); const scale = document.getElementById('txmeter-scale'); const m = _txMeterSelected; const data = _txMeterValues[m]; if (label) label.textContent = m; if (fill) { fill.className = 'txmeter-fill ' + m.toLowerCase(); let pct = data ? Math.min(100, Math.max(0, data.pct * 100)) : 0; // VOLT: the supply voltage changes slowly and slightly (CI-V read // noise of around ±0.1-0.2V) — without this the bar would "jitter" on // every polling cycle even though nothing is really changing. We // quantize to 5% steps, so the bar only moves on an actual, visible // voltage change, not measurement noise. if (m === 'VOLT' && data) pct = Math.round(pct / 5) * 5; fill.style.width = pct + '%'; } if (scale) { const ticks = _TXMETER_SCALES[m] || []; scale.innerHTML = ticks.map(t => `${t.label}`).join(''); } if (value) { // ALC/PWR peak-hold (civ.py resets it on every PTT-on): live SSB // voice makes the instantaneous reading jump around a lot (loud // syllable vs. a gap between words, sampled a few tens of ms apart) - // the PEAK seen across the whole transmission, like a real meter's // needle memory, is what actually tells you whether drive is set // right. Shown only for meters that carry a peak (ALC/PWR). const unit = _TXMETER_UNITS[m]; const peakTxt = (data && typeof data.peak === 'number') ? ` (szczyt ${data.peak}${unit})` : ''; value.textContent = data ? `${data.value}${unit}${peakTxt}` : '--'; } } // ── Sliders ─────────────────────────────────────────────────────────────────── function setLevel(param, value) { value = parseInt(value); if (param === 'RFPOWER') { S.rfPower = value; const el = document.getElementById('rf-power-val'); if(el) el.textContent = value; } if (param === 'AF') { S.afGain = value; const el = document.getElementById('af-gain-val'); if(el) el.textContent = value; } if (param === 'SQL') { S.squelch = value; const el = document.getElementById('squelch-val'); if(el) el.textContent = value; } WS.send({ type: 'level', param, value }); } // ── VFO ─────────────────────────────────────────────────────────────────────── // swap/copy use the native CI-V 07 B0/A0 commands (civ.py::vfo_swap/ // vfo_equalize) via {type:'vfo_op'} - the radio does the copy/swap // atomically. Previously these just poked S.freq/S.freqB locally (vfoCopy // never sent anything to the server at all) - looked right in the UI, but // the radio's real VFO B kept its old frequency until switched to or used // via split, which is exactly what it looked like: "changes visually, but // stays on the old freq once you actually use it". function vfoSwap() { [S.freq, S.freqB] = [S.freqB, S.freq]; updateFreqDisplay(); WS.send({ type: 'vfo_op', op: 'swap' }); } function vfoCopy() { S.freqB = S.freq; updateFreqDisplay(); WS.send({ type: 'vfo_op', op: 'equalize' }); } function toggleSplit() { S.split = !S.split; document.getElementById('split-btn')?.classList.toggle('active', S.split); WS.send({ type: 'split', split: S.split, freqB: S.freqB }); } // ── Custom modal instead of prompt() ────────────────────────────────────────── // prompt()/confirm()/alert() are SYNCHRONOUS - they block the entire main // JS thread until the user dismisses them, which live froze audio // streaming (WebAudio/WebRTC) until the dialog was closed (the same root // cause as the rotator fix - see WSJTX.rotorGoManual in wsjtx.js). // #text-prompt-modal in index.html is shared by every place that used to // use prompt() with a single text field. let _textPromptResolve = null; function textPrompt(title, defaultValue) { return new Promise((resolve) => { const modal = document.getElementById('text-prompt-modal'); const input = document.getElementById('text-prompt-input'); const titleEl = document.getElementById('text-prompt-title'); if (!modal || !input) { resolve(null); return; } if (titleEl) titleEl.textContent = title || 'WPISZ WARTOŚĆ'; input.value = defaultValue || ''; _textPromptResolve = resolve; modal.style.display = 'flex'; input.focus(); input.select(); }); } function _textPromptSubmit() { const input = document.getElementById('text-prompt-input'); const val = input ? input.value : ''; _textPromptClose(); _textPromptResolve?.(val); _textPromptResolve = null; } function _textPromptCancel() { _textPromptClose(); _textPromptResolve?.(null); _textPromptResolve = null; } function _textPromptClose() { const modal = document.getElementById('text-prompt-modal'); if (modal) modal.style.display = 'none'; } let _confirmModalResolve = null; // A confirm() replacement — non-blocking, but still requires an explicit // OK/CANCEL click before the caller gets an answer (unlike alerts turned // into showToast() below, where there's no decision to make). // danger=true colors OK red for destructive actions. function confirmModal(message, { title = I18n.t('common_confirm_title'), okLabel = 'OK', danger = false } = {}) { return new Promise((resolve) => { const modal = document.getElementById('confirm-modal'); const msgEl = document.getElementById('confirm-modal-msg'); const titleEl = document.getElementById('confirm-modal-title'); const okBtn = document.getElementById('confirm-modal-ok'); if (!modal || !msgEl) { resolve(false); return; } if (titleEl) { titleEl.removeAttribute('data-i18n'); titleEl.textContent = title; } msgEl.textContent = message; if (okBtn) { okBtn.textContent = okLabel; okBtn.style.background = danger ? 'var(--red)' : ''; okBtn.style.borderColor = danger ? 'var(--red)' : ''; } _confirmModalResolve = resolve; modal.style.display = 'flex'; okBtn?.focus(); }); } function _confirmModalSubmit() { _confirmModalClose(); _confirmModalResolve?.(true); _confirmModalResolve = null; } function _confirmModalCancel() { _confirmModalClose(); _confirmModalResolve?.(false); _confirmModalResolve = null; } function _confirmModalClose() { const modal = document.getElementById('confirm-modal'); if (modal) modal.style.display = 'none'; } // ── Memories ────────────────────────────────────────────────────────────────── async function saveMemory() { const name = (await textPrompt('NAZWA CZĘSTOTLIWOŚCI (opcjonalnie)', '')) ?? ''; S.memories.push({ freq: S.freq, mode: S.mode, name }); S.saveMemories(); renderMemories(); } function deleteMemory(i) { S.memories.splice(i, 1); S.saveMemories(); renderMemories(); } function renderMemories() { const el = document.getElementById('memory-list'); if (!el) return; if (!S.memories.length) { el.innerHTML = '