public/index.html
Rama main · réplica de lectura del repositorio original.
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Ham Radio Control</title>
<link rel="icon" href="favicon.ico">
<!-- Dzien/noc: ustaw data-mode PRZED zaladowaniem CSS/reszty strony, zeby
nie bylo widac blysku zlego motywu przy starcie (patrz theme.js).
UWAGA: data-mode (jasny/ciemny), NIE data-theme — data-theme to
ZUPELNIE INNA, wczesniejsza funkcja (wybor wariantu koloru blue/mono/
amber-classic w PROFIL, patrz UI.setTheme w ui.js) na tym samym
elemencie <html>. Uzycie tego samego atrybutu dla obu kolidowalo i
psulo wybor wariantu koloru (live report 2026-10-15: "przestaly
dzialac kolory bursztyn/crt"). -->
<script>
(function () {
try {
const saved = localStorage.getItem('ham_theme');
document.documentElement.setAttribute('data-mode', (saved === 'light' || saved === 'dark') ? saved : 'dark');
} catch (e) { document.documentElement.setAttribute('data-mode', 'dark'); }
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=202611021900">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/2.47.0/iconfont/tabler-icons.min.css">
<!-- opus-decoder.min.js usuniety: audio dekodowane natywnym AudioDecoder
(Web Codecs API, patrz initOpusDecoder w ws.js). Biblioteka WASM byla
martwa zaszloscia i rzucala 404 (plik przeniesiony do js/). -->
</head>
<body class="page-radio-active">
<!-- ── #app-scale: caly UI w referencyjnym rozmiarze 1440x1080, skalowany
przez JS (transform:scale) wg viewportu — gwarantuje identyczne
pozycje px-perfect wszystkich elementow na kazdym ekranie/rozmiarze
okna. Patrz funkcja updateScale na koncu pliku. Topbar jest TERAZ w
srodku (nie poza) — "opcja 2" (2026-10-16, user decision): skalowany
razem z reszta UI, co daje naprawde jednoekranowy uklad Radio/FT8 bez
przewijania strony; position:sticky usuniete (i tak nie dzialal
poprawnie na potomku transform:scale, patrz historia tego pliku),
wysokosc 52px jest stala i odejmowana od dostepnej wysokosci siatki w
updateScale() (zmienna --radio-grid-h / --wsjtx-h). ── -->
<div id="app-scale">
<!-- ─── TOP BAR ─────────────────────────────────────────────────────────────── -->
<div class="topbar">
<div class="logo">HAM RADIO <span class="logo-accent">CTRL</span></div>
<div class="nav-tabs">
<button class="tab-btn active" onclick="UI.setPage('radio')" data-i18n="tab_radio">RADIO</button>
<button class="tab-btn" onclick="UI.setPage('settings')" data-perm="view" data-i18n="tab_settings">USTAWIENIA</button>
<button class="tab-btn" onclick="UI.setPage('profile')" data-perm="view" id="tab-profile" data-i18n="tab_profile">👤 PROFIL</button>
<button class="tab-btn" onclick="UI.setPage('log')" data-perm="view" id="tab-log" data-i18n="tab_log">LOG QSO</button>
<button class="tab-btn" onclick="UI.setPage('config')" data-perm="settings" id="tab-config" data-i18n="tab_config">KONFIGURACJA</button>
<button class="tab-btn" id="tab-wsjtx" onclick="UI.setPage('wsjtx')" data-perm="view">FT8/WSJT-X</button>
<button class="tab-btn" id="tab-dxcluster" onclick="UI.setPage('dxcluster')" data-perm="view">📡 DX CLUSTER</button>
<button class="tab-btn" id="tab-internet" onclick="UI.setPage('internet')" data-perm="admin">🌐 INTERNET</button>
<button class="tab-btn" id="tab-admin" onclick="UI.setPage('admin')" style="display:none" data-i18n="tab_admin">ADMIN</button>
</div>
<div class="topbar-right">
<div class="status-row">
<span id="sim-badge" style="display:none;background:rgba(var(--amber-rgb),0.2);color:var(--amber);font-size:9px;padding:2px 6px;border-radius:3px;font-family:var(--mono);border:1px solid rgba(var(--amber-rgb),0.4);">SIM</span>
<span id="rig-disconnected-badge" style="display:none;background:rgba(var(--red-rgb),0.15);color:var(--red);font-size:9px;padding:2px 6px;border-radius:3px;font-family:var(--mono);border:1px solid rgba(var(--red-rgb),0.4);"></span>
<div class="indicator"><div class="dot" id="ptt-dot"></div><span id="ptt-label" style="font-family:var(--mono);font-size:11px;">RX</span></div>
<div class="indicator"><div class="dot" id="conn-dot"></div><span id="conn-label" style="font-family:var(--mono);font-size:11px;">--</span></div>
<span id="user-role" class="role-badge"></span>
<span id="callsign-display" style="font-family:var(--mono);font-size:12px;color:var(--green);font-weight:bold;letter-spacing:1.5px;">--</span>
<!-- Dzien/noc — zapamietywane per przegladarka w localStorage (jak PL/EN
ponizej), domyslnie ciemny (obecny wyglad). Patrz theme.js. -->
<button class="lang-switch-btn" id="theme-switch" onclick="Theme.toggle()" title="Jasny / ciemny motyw">🌙</button>
<button class="lang-switch-btn" id="lang-switch" onclick="I18n.toggle()" title="Switch language / Zmien jezyk">PL</button>
<!-- Czat — skrót do panelu w OPERATORZY (2026-09-03): ten panel wymaga
przewinięcia strony żeby go zobaczyć (patrz CLAUDE.md notatka o
tej samej sesji), więc badge tutaj daje znać o nowej wiadomości
bez konieczności ciągłego przewijania w dół. Klik przewija płynnie
do panelu (Chat.scrollToChat, chat.js) i czyści licznik. -->
<a class="lang-switch-btn" href="javascript:void(0)" onclick="Chat.scrollToChat()" data-i18n-title="chat_hdr" title="Czat" style="position:relative;">💬<span id="chat-badge" class="chat-badge" hidden>0</span></a>
<a class="lang-switch-btn" href="/?ui=mobile" title="Wersja mobilna">📱</a>
</div>
<button class="logout-btn" onclick="Auth.logout()" title="Wyloguj" data-i18n-title="logout_title" data-i18n="logout_btn">⏻ WYLOGUJ</button>
</div>
</div>
<!-- ─── PAGE: RADIO ──────────────────────────────────────────────────────────── -->
<div class="page-content active" id="page-radio">
<!-- ── KAFELKOWY GRID ── -->
<div class="tile-grid">
<!-- ═══════════════════════════════ LEWA KOLUMNA ═══════════════════════════ -->
<div class="tile tile-left">
<!-- Selektor radia (widoczny gdy >1) -->
<div id="rig-selector-row" style="display:none;padding:6px 8px 0;">
<select id="active-rig-select"
onchange="WS.send({type:'connect',rigId:parseInt(this.value)})">
</select>
</div>
<!-- TRYB -->
<div class="tile-header" data-perm="mode" data-i18n="radio_mode_hdr">TRYB</div>
<div class="mode-band-grid" id="mode-grid-left" data-perm="mode"></div>
<div class="tile-divider"></div>
<!-- PASMO -->
<div class="tile-header" data-perm="band" data-i18n="radio_band_hdr">PASMO</div>
<div class="mode-band-grid" id="band-grid-left" data-perm="band"></div>
<!-- HISTORIA FREQ (rozwijalny panel) -->
<div class="tile-divider"></div>
<div style="padding:4px 8px;">
<div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer;user-select:none;padding:2px 0;"
onclick="UI.toggleFreqHistory()">
<span style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:2px;" data-i18n="radio_freq_history">HISTORIA FREQ</span>
<span id="freq-history-toggle" style="font-family:var(--mono);font-size:10px;color:var(--dim);">▶</span>
</div>
<div id="freq-history-list" style="display:none;max-height:180px;overflow-y:auto;padding-top:4px;font-family:var(--mono);font-size:10px;">
<!-- generowane przez UI.renderFreqHistory() -->
</div>
</div>
<div class="tile-divider"></div>
<!-- TRX FUNKCJE (wybor miernika pokazywanego w bargrafie pod S-metrem) -->
<div class="rf-section">
<div class="rf-section-title" data-i18n="radio_trx_functions">TRX FUNKCJE</div>
<div style="padding-bottom:6px;">
<span class="rf-sub-lbl" data-i18n="radio_meter_indicator">WSKAZNIK (bargraf pod S-metrem)</span>
<select id="trx-funkcje-select"
onchange="UI.setTxMeter(this.value)">
<option value="ALC">ALC</option>
<option value="PWR">PWR</option>
<option value="SWR">SWR</option>
<option value="VOLT">VOLT</option>
<option value="ID">ID</option>
</select>
</div>
</div>
<div class="tile-divider"></div>
<!-- AGC — wypelniane przez advradio.js. Gated by the per-rig FEATURES
whitelist (rigs/features.py's "agc" entry, KONFIGURACJA -> FUNKCJE
RADIA), same mechanism as split/tuner/autotune - NOT the
"settings" admin gate, so one admin switch exposes it to every
regular operator at once (see auth.js's FEATURE_ONLY_KEYS/
FEATURE_ID_FOR_PERM). Admin always sees it regardless. -->
<div class="rf-section" data-perm="agc">
<div class="rf-section-title" data-i18n="adv_agc_hdr">AGC</div>
<div id="adv-agc"></div>
</div>
<div class="tile-divider"></div>
<!-- AUDIO (przeniesione z bocznego panelu RF/AF) -->
<div class="rf-section">
<div class="rf-section-title">AUDIO</div>
<div style="padding-bottom:6px;">
<span class="rf-sub-lbl" data-i18n="radio_filter_lbl">FILTR (FIL1/2/3 — ustawienia z menu radia)</span>
<select id="bw-select" onchange="WS.send({type:'mode', mode:AppState.mode, filterNum: parseInt(this.value)})">
<option value="1">FIL1</option>
<option value="2">FIL2</option>
<option value="3">FIL3</option>
</select>
</div>
<div style="padding-bottom:6px;">
<span class="rf-sub-lbl">PREAMP (CI-V 16 02)</span>
<select id="preamp-select" onchange="WS.send({type:'preamp', value: parseInt(this.value)})">
<option value="0">P.AMP OFF</option>
<option value="1">P.AMP 1 ON</option>
<option value="2">P.AMP 2 ON</option>
</select>
</div>
<div style="padding-bottom:6px;">
<span class="rf-sub-lbl">ATT (CI-V 11)</span>
<select id="att-select" onchange="WS.send({type:'attenuator', value: this.value === '1'})">
<option value="0">ATT OFF</option>
<option value="1">ATT ON</option>
</select>
</div>
<button id="tx-mic-btn" class="audio-toggle-btn" data-perm="ptt" data-i18n="tx_mic_start" style="margin-top:4px;border-color:rgba(var(--red-rgb),0.3);color:var(--dim);"
onclick="this.removeAttribute('data-i18n'); WS.isTxActive() ? (WS.stopTX(), this.textContent=I18n.t('tx_mic_start'), this.style.color='var(--dim)', this.style.borderColor='rgba(var(--red-rgb),0.3)') : WS.startTX().then(ok => { if(ok){ this.textContent=I18n.t('tx_mic_stop'); this.style.color='var(--red)'; this.style.borderColor='var(--red)'; } })">
🎤 Nadawanie TX — mikrofon
</button>
<!-- TX GAIN z dynamicznym VU metrem -->
<div style="margin-top:8px;">
<div style="display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--dim);margin-bottom:3px;">
<span>TX GAIN</span>
<div id="tx-vu-bar" style="flex:1;height:6px;background:var(--panel3);border-radius:3px;overflow:hidden;position:relative;">
<div id="tx-vu-fill" style="height:100%;width:0%;background:var(--green);border-radius:3px;transition:width 0.05s;"></div>
</div>
<span id="tx-gain-val" style="width:30px;text-align:right;">1.0x</span>
</div>
<input type="range" id="tx-gain-slider" min="0.01" max="1" step="0.01" value="0.15"
style="width:100%;accent-color:var(--green);cursor:pointer;"
oninput="AudioControls.setTxGain(parseFloat(this.value))">
</div>
<!-- RX VOL -->
<div style="margin-top:6px;">
<div style="display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--dim);margin-bottom:3px;">
<span>RX VOL</span>
<span id="rx-vol-val" style="margin-left:auto;width:30px;text-align:right;">70%</span>
</div>
<input type="range" id="rx-vol-slider" min="0" max="100" step="1" value="70"
style="width:100%;accent-color:var(--green);cursor:pointer;"
oninput="AudioControls.setRxVol(parseInt(this.value))">
</div>
</div>
<!-- Dynamiczne slidery z dump_caps (RadioFunctions module) -->
<div class="rf-section" id="rig-dynamic-sliders" style="display:none;">
<div class="rf-section-title">RADIO</div>
<!-- wypelniane przez radiofunctions.js -->
</div>
</div><!-- /tile-left -->
<!-- ═══════════════════════════════ VFO ════════════════════════════════════ -->
<div class="tile tile-vfo" id="vfo-box">
<!-- Badges: SPLIT, RX/TX, tryb, pasmo (VFO A/B usuniete — sa w rf-buttons-row) -->
<div style="display:flex;align-items:center;gap:6px;padding:3px 8px;border-bottom:1px solid var(--border);background:var(--panel2);">
<!-- Ukryte elementy zachowane dla JS (updateVFOBadges) -->
<span id="badge-vfoa" style="display:none;"></span>
<span id="badge-vfob" style="display:none;"></span>
<span id="badge-split" style="display:none;" class="vfo-badge" style="border-color:rgba(var(--amber-rgb),0.4);color:var(--amber);cursor:pointer;" onclick="UI.toggleSplit()">SPLIT</span>
<span id="badge-rx" class="vfo-badge" style="border-color:rgba(var(--green-rgb),0.3);background:rgba(var(--green-rgb),0.1);color:var(--green);">RX</span>
<span id="badge-tx" style="display:none;" class="vfo-badge" style="border-color:rgba(var(--red-rgb),0.4);background:rgba(var(--red-rgb),0.15);color:var(--red);">TX</span>
<span style="font-family:var(--mono);font-size:9px;color:var(--dim);">WS</span><span id="badge-latency" class="vfo-badge" onclick="WS.ping()" data-i18n-title="ws_badge_title" title="Opoznienie polaczenia sterujacego (ping/pong) — NIE opoznienie samego audio. Kliknij aby odswiezyc." style="cursor:pointer;border-color:var(--border2);color:var(--dim);min-width:60px;text-align:center;">--</span>
<span style="font-family:var(--mono);font-size:9px;color:var(--dim);">AUDIO</span><span id="badge-audio-latency" class="vfo-badge" data-i18n-title="audio_badge_title" title="Rzeczywiste opoznienie odsluchu RX: ile audio jest zbuforowane miedzy odbiorem z serwera a odtworzeniem. Cel adaptacyjny 180-300ms (rosnie po niedoborach, opada gdy lacze spokojne), twardy limit 400ms." style="border-color:var(--border2);color:var(--dim);min-width:60px;text-align:center;">--</span>
<span id="vfo-mode-badge" style="margin-left:auto;font-family:var(--mono);font-size:10px;font-weight:600;color:var(--amber);background:rgba(var(--amber-rgb),0.15);border:1px solid rgba(var(--amber-rgb),0.4);padding:2px 7px;border-radius:3px;letter-spacing:1px;">USB</span>
<span id="vfo-band-badge" style="font-family:var(--mono);font-size:10px;font-weight:600;color:var(--green);background:rgba(var(--green-rgb),0.12);border:1px solid var(--green2);padding:2px 7px;border-radius:3px;letter-spacing:1px;margin-right:4px;">20m</span>
</div>
<!-- Cyfry VFO A -->
<div class="vfo-display">
<div style="display:flex;align-items:baseline;gap:6px;">
<div id="vfo-digits" style="display:flex;align-items:center;gap:1px;font-family:var(--display);font-size:32px;font-weight:900;color:var(--green);text-shadow:0 0 20px rgba(var(--green-rgb),0.4);cursor:default;user-select:none;letter-spacing:-1px;"></div>
<span style="font-family:var(--mono);font-size:10px;color:var(--dim);margin-bottom:2px;">MHz</span>
</div>
<!-- Kroki strojenia -->
<div class="tuning-steps" data-perm="freq">
<button class="step-btn" onclick="VFO.updateStep(1)">1</button>
<button class="step-btn" onclick="VFO.updateStep(10)">10</button>
<button class="step-btn" onclick="VFO.updateStep(100)">100</button>
<button class="step-btn" onclick="VFO.updateStep(1000)">1k</button>
<button class="step-btn" onclick="VFO.updateStep(5000)">5k</button>
<button class="step-btn" onclick="VFO.updateStep(10000)">10k</button>
<button class="step-btn" onclick="VFO.updateStep(100000)">100k</button>
<button class="step-btn" onclick="VFO.updateStep(1000000)">1M</button>
<!-- VFO-B (przeniesione tutaj z wiersza ponizej) -->
<span id="vfo-b-row" onclick="UI.vfoSelect('B')"
oncontextmenu="event.preventDefault(); UI.editVfoB();"
onwheel="UI.wheelVfoB(event)"
data-i18n-title="vfo_b_tooltip" title="Klik: przełącz na VFO-B | Prawy klik: wpisz częstotliwość | Scroll: zmieniaj co 1kHz"
style="margin-left:auto;display:flex;align-items:baseline;gap:5px;cursor:pointer;user-select:none;">
<span style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:1px;">VFO-B</span>
<span id="freq-b-display" style="font-family:var(--display);font-size:16px;font-weight:700;color:var(--dim);">14.200.000</span>
<span style="font-family:var(--mono);font-size:9px;color:var(--dim);">MHz</span>
<span id="vfo-b-split-label" style="display:none;font-family:var(--mono);font-size:9px;color:var(--amber);background:rgba(var(--amber-rgb),0.1);border:1px solid rgba(var(--amber-rgb),0.3);padding:1px 5px;border-radius:2px;">TX</span>
</span>
</div>
<!-- RIT/ΔTX removed 2026-10-18 - Hz-set gave NG on this radio, no
fix found, dropped per user decision. -->
</div>
<!-- S-meter + PTT -->
<div class="smeter-wrap">
<div class="smeter-scale">
<span>1</span><span>3</span><span>5</span><span>7</span><span>9</span>
<span style="color:var(--amber)">+20</span>
<span style="color:var(--amber)">+40</span>
<span style="color:var(--red)">+60</span>
</div>
<div class="smeter-bar-wrap"><div class="smeter-fill" id="smeter-fill"></div></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:2px;">
<span id="smeter-value" class="smeter-value">S 0</span>
<!-- OVF (ADC/front-end overflow indicator, CI-V 15 07) - the
radio's own overdrive warning, otherwise invisible to a
remote operator (no physical panel to glance at). -->
<span id="ovf-badge" style="display:none;background:rgba(var(--red-rgb),0.15);color:var(--red);
font-size:10px;padding:2px 7px;border-radius:3px;font-family:var(--mono);
border:1px solid rgba(var(--red-rgb),0.4);letter-spacing:1px;">OVF</span>
</div>
<div style="display:flex;justify-content:center;margin-top:2px;">
<button id="ptt-btn" class="ptt-btn" data-perm="ptt"
onpointerdown="this.setPointerCapture(event.pointerId); UI.setPTT(true)"
onpointerup="UI.setPTT(false)" onpointercancel="UI.setPTT(false)">PTT</button>
</div>
<!-- TRX meter — bargraf kompaktowy, jeden wiersz: NAZWA + pasek + wartosc,
plus podzialka pod paskiem (dopasowana do realnego zakresu odczytu
z radia, inna dla kazdej funkcji — patrz UI._renderTxMeter()).
Wybor funkcji (ALC/PWR/SWR/VOLT) jest TYLKO w lewej kolumnie
(sekcja TRX FUNKCJE pod PASMO) — patrz UI.setTxMeter(). -->
<div class="txmeter-wrap">
<span id="txmeter-label" class="txmeter-label-name">ALC</span>
<div class="txmeter-bar-col">
<div class="txmeter-bar-wrap">
<div class="txmeter-fill" id="txmeter-fill"></div>
</div>
<div class="txmeter-scale" id="txmeter-scale"></div>
</div>
<span id="txmeter-value" class="txmeter-value">--</span>
</div>
</div>
</div><!-- /tile-vfo -->
<!-- ═══════════════════════════════ PANEL FUNKCJI RADIA ════════════════════ -->
<!-- Jeden wiersz przyciskow (VFO A/B, Zasilanie, funkcje NB/NR/ANF/VOX...)
miedzy PTT i waterfallem. Bez naglowka — kompaktowy pasek. -->
<div class="tile tile-rigfeatures" id="rig-features-panel">
<!-- SZYBKIE LOGOWANIE QSO — jeden wiersz, zawsze widoczny -->
<div id="quick-log-bar" style="display:flex;align-items:center;gap:6px;
padding:3px 8px;border-bottom:1px solid var(--border);background:var(--panel2);">
<span style="font-family:var(--mono);font-size:10px;color:var(--dim);
letter-spacing:1px;flex-shrink:0;" data-i18n="tab_log">LOG QSO</span>
<input type="text" id="qlog-call" placeholder="CALL"
style="font-family:var(--mono);font-size:11px;text-transform:uppercase;
width:100px;flex-shrink:0;padding:2px 6px;background:var(--panel3);
border:1px solid var(--border2);border-radius:3px;color:var(--text);"
onkeydown="if(event.key==='Enter')QSOLog.quickLog()"
oninput="window.AppState._qlogPinnedFreq=null;QSOLog.checkQuickLogWorkedBefore(this.value);QSOLog.updateQuickLogHint()">
<span style="font-family:var(--mono);font-size:10px;color:var(--dim);flex-shrink:0;" data-i18n-title="sent_title" title="RST wyslane">SENT</span>
<input type="text" id="qlog-rst-s" placeholder="599" maxlength="3"
style="font-family:var(--mono);font-size:11px;width:38px;flex-shrink:0;
padding:2px 5px;background:var(--panel3);border:1px solid var(--border2);
border-radius:3px;color:var(--text);text-align:center;" value="599">
<span style="font-family:var(--mono);font-size:10px;color:var(--dim);flex-shrink:0;" data-i18n-title="rcvd_title" title="RST odebrane">RCVD</span>
<input type="text" id="qlog-rst-r" placeholder="599" maxlength="3"
style="font-family:var(--mono);font-size:11px;width:38px;flex-shrink:0;
padding:2px 5px;background:var(--panel3);border:1px solid var(--border2);
border-radius:3px;color:var(--text);text-align:center;" value="599">
<span style="font-family:var(--mono);font-size:10px;color:var(--dim);flex-shrink:0;">GRID</span>
<input type="text" id="qlog-grid" placeholder="KO02" maxlength="8"
style="font-family:var(--mono);font-size:11px;width:58px;flex-shrink:0;
padding:2px 5px;background:var(--panel3);border:1px solid var(--border2);
border-radius:3px;color:var(--text);text-transform:uppercase;"
onkeydown="if(event.key==='Enter')QSOLog.quickLog()"
oninput="QSOLog.updateQuickLogHint()">
<button onclick="QSOLog.quickLog()"
style="font-family:var(--mono);font-size:10px;letter-spacing:1px;
padding:2px 12px;background:rgba(76,219,106,0.12);border:1px solid var(--green2);
border-radius:3px;color:var(--green);cursor:pointer;flex-shrink:0;">LOG</button>
<span id="qlog-status" style="font-family:var(--mono);font-size:10px;
color:var(--dim);flex:1;text-align:right;"></span>
</div>
<div class="rf-buttons-row">
<!-- A<->B / A->B / SPLIT — przeniesione z wiersza krokow strojenia,
zawsze widoczne (CI-V 07 B0/A0/0F, patrz UI.vfoSwap/vfoCopy/toggleSplit) -->
<button class="rf-btn" onclick="UI.vfoSwap()" data-perm="freq" data-i18n-title="vfoab_title" title="Zamien VFO A i B (CI-V 07 B0)">A↔B</button>
<button class="rf-btn" onclick="UI.vfoCopy()" data-perm="freq" data-i18n-title="vfoa2b_title" title="Skopiuj A do B (CI-V 07 A0)">A→B</button>
<button class="rf-btn" id="split-btn" onclick="UI.toggleSplit()" data-perm="split" title="Split VFO A/B (CI-V 0F)">SPLIT</button>
<span style="width:1px;height:16px;background:var(--border2);margin:0 2px;"></span>
<!-- TUNER (toggle ON/OFF, CI-V 1C 01 00/01) i AUTOTUNE (jednorazowy
cykl dopasowania, CI-V 1C 01 02 — generuje krotki TX) -->
<!-- data-perm changed 2026-09-03: TUNER/AUTOTUNE used to piggyback
on freq/ptt permissions (unrelated to what they actually do) -
now their own feature-only keys, same pattern as SPLIT -
governed solely by KONFIGURACJA -> FUNKCJE RADIA, see
auth.js's FEATURE_ONLY_KEYS. -->
<button class="rf-btn" id="tuner-btn" onclick="UI.toggleTuner()" data-perm="tuner" title="Antenna Tuner ON/OFF (CI-V 1C 01)">TUNER</button>
<button class="rf-btn" onclick="UI.startAutotune()" data-perm="autotune" data-i18n-title="autotune_title" title="Autotune — cykl dopasowania (CI-V 1C 01 02, generuje TX)">AUTOTUNE</button>
</div>
<div id="rig-features-buttons" class="rf-buttons-row" style="display:none;">
<!-- przyciski generowane dynamicznie przez radiofunctions.js -->
</div>
<div id="relay-buttons" class="rf-buttons-row" style="display:none;flex-wrap:wrap;gap:4px;padding-top:4px;">
<!-- przyciski przekaznikow generowane przez relay_ui.js -->
</div>
</div><!-- /tile-rigfeatures -->
<!-- ═══════════════════════════════ WATERFALL ══════════════════════════════ -->
<div class="tile tile-wf">
<div class="wf-header">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-family:var(--mono);font-size:10px;font-weight:700;color:var(--dim);letter-spacing:1px;">WATERFALL</span>
<span id="wf-source-badge" style="font-family:var(--mono);font-size:9px;color:rgba(200,200,200,0.35);">● SIM</span>
<span id="smeter-text" style="font-family:var(--mono);font-size:10px;color:var(--green)">S0</span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<!-- SPAN/PEAK usuniete stad (2026-10-17, na zadanie uzytkownika:
"w gornej belce jest peak i span i nadole jest tez" -
zdublowane z dolnym paskiem softkeys (#wf-btn-span/#wf-btn-peak,
_updateSoftkeys() w waterfall.js), ktory jest pelnoprawnym
zamiennikiem - zweryfikowano na zywo ze cycleSpan()/
togglePeak() poprawnie zmieniaja stan. #wf-span-select i
#wf-peak-toggle byly tylko SYNC-TARGETAMI (if(sel)/if(chk),
zrodlem prawdy jest localStorage['wf_span_hz'] + _peakEnabled),
wiec usuniecie ich jest bezpieczne, init() w waterfall.js ich
nie wymaga. -->
<button id="wf-collapse-btn" onclick="Waterfall.toggleCollapse()" data-i18n-title="wf_collapse_title" title="Zwiń wodospad (np. gdy radio nie ma scope'a)"
style="font-family:var(--mono);font-size:9px;padding:2px 6px;background:var(--panel3);border:1px solid var(--border);color:var(--dim);border-radius:2px;cursor:pointer;width:20px;">−</button>
<button onclick="Waterfall.toggleFullscreen()" title="Fullscreen (F11 alternate)"
style="font-family:var(--mono);font-size:9px;padding:2px 6px;background:var(--panel3);border:1px solid var(--border);color:var(--dim);border-radius:2px;cursor:pointer;">⛶</button>
</div>
</div>
<!-- 1. SPECTRUM SCOPE -->
<canvas id="wf-spectrum"></canvas>
<!-- 2. OS CZESTOTLIWOSCI (miedzy widmem a wodospadem, jak w IC-7300) -->
<canvas id="wf-axis"></canvas>
<!-- 3. WATERFALL (WODOSPAD) -->
<canvas id="wf-waterfall"></canvas>
<!-- 4. PASEK SOFTKEYS (ekranowe przyciski radia) -->
<div class="wf-softkeys" id="wf-softkeys">
<select class="wf-softkey-select" id="wf-btn-cent" onchange="Waterfall.setScopeMode(parseInt(this.value,10))">
<option value="0">CENTER</option>
<option value="1">FIXED</option>
</select>
<!-- Wartosci MUSZA pokrywac sie DOKLADNIE z SCOPE_SPAN_KHZ w civ.py -
IC-7300 NIE obsluguje 25 kHz (radio odrzuca to komenda NG),
tylko 5/10/20/50/100/200/500/1000. Nie dodawac 25000 z powrotem. -->
<select class="wf-softkey-select" id="wf-btn-span" onchange="Waterfall.setSpan(parseInt(this.value,10))">
<option value="5000">SPAN: 5k</option>
<option value="10000">SPAN: 10k</option>
<option value="20000">SPAN: 20k</option>
<option value="50000">SPAN: 50k</option>
<option value="100000">SPAN: 100k</option>
<option value="200000">SPAN: 200k</option>
<option value="500000">SPAN: 500k</option>
<option value="1000000">SPAN: 1M</option>
</select>
<button class="wf-softkey-btn active" id="wf-btn-peak" onclick="Waterfall.togglePeak()">PEAK: MAX</button>
<button class="wf-softkey-btn active" id="wf-btn-spots" onclick="Waterfall.toggleSpots()">SPOTS: ON</button>
<select class="wf-softkey-select" id="wf-btn-speed" onchange="Waterfall.setSpeed(parseFloat(this.value))">
<option value="0.5">SPEED: SLOW</option>
<option value="1">SPEED: MID</option>
<option value="2">SPEED: FAST</option>
</select>
<select class="wf-softkey-select" id="wf-btn-ref" onchange="Waterfall.setRefLevel(parseInt(this.value,10))">
<option value="-30">REF: -30dB</option>
<option value="-20">REF: -20dB</option>
<option value="-15">REF: -15dB</option>
<option value="-10">REF: -10dB</option>
<option value="-5">REF: -5dB</option>
<option value="0">REF: 0dB</option>
</select>
</div>
</div><!-- /tile-wf -->
<!-- ═══════════════════════════════ ROTATOR ════════════════════════════════ -->
<!-- FIX (2026-09-03, reported live: "jak nie ma pozwolenia na rotor to
widnieje pusta ramka w jego miejscu"): this tile used to be
data-perm="rotator" (display:none for anyone lacking the granular
permission). .tile-rotator is explicitly placed at grid-column:4
(style.css) - that column track is always reserved by
grid-template-columns regardless of whether anything occupies it,
so hiding the tile left a blank column-shaped gap instead of the
layout reflowing. Now the tile always shows; the interactive
controls inside use data-perm-disable (same grayed-out/disabled
pattern as PTT/VFO/freq elsewhere) instead of hiding the whole
panel. The server-side gate on the actual rotator API calls is
what enforces this now (see /api/rotator/*/position and /stop) -
see the comment there, this permission was previously ONLY
enforced by hiding this tile, not by the backend.
-->
<!-- ═══════════════════════════════ PRAWA KOLUMNA (ROTATOR + CW KEYER + CZAT) ═══════════════════════════════
Jeden kafelek siatki (grid-column:4) obejmujacy pelna wysokosc
(grid-row:1/4), scrollowany wewnetrznie — "opcja 2" (2026-10-16):
rotator i CW keyer (z panelem OPERATORZY/CZAT) juz nie sa
osobnymi komorkami siatki, tylko blokami jeden pod drugim w tym
jednym, przewijanym kafelku. -->
<div class="tile tile-right" id="tile-right" style="grid-column:4;grid-row:1/4;overflow-y:auto !important;overflow-x:hidden !important;height:100% !important;max-height:100% !important;min-height:0 !important;display:block !important;">
<div class="tile-rotator" id="rot-side-panel" style="display:flex;flex-direction:column;flex-shrink:0;">
<div class="tile-header">
<span>ROTATOR</span>
<span id="rot-status-badge" data-i18n="rotator_none" style="font-family:var(--mono);font-size:9px;color:var(--dim);">● brak</span>
</div>
<div class="rotator-inner">
<div style="position:relative;width:205px;height:205px;margin:0 auto 4px;">
<canvas id="rot-compass" width="205" height="205" style="display:block;border-radius:50%;width:205px;height:205px;"></canvas>
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;">
<span id="rot-az-display" class="rot-az-display">---</span>
<span style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:1px;">° AZ</span>
</div>
</div>
<div id="rot-name-label" style="font-family:var(--mono);font-size:9px;color:var(--dim);text-align:center;margin-bottom:3px;"></div>
<div>
<div style="font-family:var(--mono);font-size:8px;color:var(--dim);margin-bottom:3px;letter-spacing:1px;" data-i18n="rotator_target_lbl">CEL (stopnie lub lokator)</div>
<input id="rot-target-inp" type="text" class="rot-target-inp" data-i18n-placeholder="rotator_target_placeholder" placeholder="272 lub KO02"
style="text-transform:uppercase;" data-perm-disable="rotator"
onkeydown="if(event.key==='Enter')RotW.go()"
oninput="RotW.onInput()">
<div id="rot-target-preview" style="font-family:var(--mono);font-size:9px;color:var(--green);margin-top:3px;min-height:13px;"></div>
</div>
<div class="rot-btns" style="margin-top:6px;">
<button class="rot-go-btn" onclick="RotW.go()" data-perm-disable="rotator">▶ START</button>
<button class="rot-stop-btn" onclick="RotW.stop()" data-perm-disable="rotator">■ STOP</button>
</div>
</div>
</div><!-- /tile-rotator -->
<!-- ═══════════════════════════════ CW KEYER ═══════════════════════════════ -->
<div class="tile-cw" id="cw-inline" data-perm="cw" style="display:flex;flex-direction:column;flex-shrink:0;border-top:1px solid var(--border);">
<div class="tile-header" style="flex-wrap:wrap;gap:4px;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="white-space:nowrap;">CW KEYER</span>
<span id="cw-method-badge" style="font-family:var(--mono);font-size:9px;color:var(--dim);background:rgba(200,200,200,0.07);padding:1px 6px;border-radius:2px;border:1px solid var(--border);">DTR</span>
</div>
<div style="display:flex;align-items:center;gap:5px;flex-shrink:0;">
<input type="range" min="6" max="48" step="1" value="18" id="cw-wpm-slider"
style="width:42px;-webkit-appearance:none;height:3px;background:var(--panel3);border-radius:2px;outline:none;cursor:pointer;"
oninput="CW.setWPM(this.value)">
<span id="cw-wpm-val" style="font-family:var(--mono);font-size:11px;color:var(--green);width:18px;text-align:right;">18</span>
<!-- Dekoder CW (DeepCW) — otwiera plywajace okno z dekodowanym
tekstem. Dekoder chodzi TYLKO gdy okno otwarte (zero CPU po
zamknieciu). -->
<button onclick="DeepCW.openModal()" title="Dekoder CW (DeepCW)"
style="background:rgba(76,204,255,0.1);border:1px solid rgba(76,204,255,0.4);
border-radius:3px;color:#4cf;font-family:var(--mono);font-size:10px;
padding:2px 7px;cursor:pointer;">◈ RX</button>
<button onclick="CW.stopCW()" title="STOP" data-perm-disable="ptt"
style="background:rgba(var(--red-rgb),0.1);border:1px solid rgba(var(--red-rgb),0.4);border-radius:3px;color:var(--red);font-family:var(--mono);font-size:10px;padding:2px 7px;cursor:pointer;">■</button>
</div>
</div>
<div id="cw-macros-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:3px;padding:6px 8px;flex-shrink:0;">
<!-- F1 -->
<div class="cw-macro-card" id="cwm-1" title="" onclick="CW.sendMacro(1)">
<div class="cw-macro-header"><span class="cw-macro-label" id="cwm-lbl-1">CQ</span><span class="cw-macro-num">F1</span><button class="cw-macro-edit-btn" onclick="CW.startEdit(1);event.stopPropagation()" data-i18n-title="cw_edit_macro_title" title="Edytuj makro F1">✎</button></div>
<div class="cw-macro-text" id="cwm-txt-1">CQ DE {MYCALL} K</div>
</div>
<!-- F2 -->
<div class="cw-macro-card" id="cwm-2" title="" onclick="CW.sendMacro(2)">
<div class="cw-macro-header"><span class="cw-macro-label" id="cwm-lbl-2">RST</span><span class="cw-macro-num">F2</span><button class="cw-macro-edit-btn" onclick="CW.startEdit(2);event.stopPropagation()" data-i18n-title="cw_edit_macro_title" title="Edytuj makro F2">✎</button></div>
<div class="cw-macro-text" id="cwm-txt-2">UR 599 {NR} BK</div>
</div>
<!-- F3 -->
<div class="cw-macro-card" id="cwm-3" title="" onclick="CW.sendMacro(3)">
<div class="cw-macro-header"><span class="cw-macro-label" id="cwm-lbl-3">73</span><span class="cw-macro-num">F3</span><button class="cw-macro-edit-btn" onclick="CW.startEdit(3);event.stopPropagation()" data-i18n-title="cw_edit_macro_title" title="Edytuj makro F3">✎</button></div>
<div class="cw-macro-text" id="cwm-txt-3">73 DE {MYCALL} SK</div>
</div>
<!-- F4 -->
<div class="cw-macro-card" id="cwm-4" title="" onclick="CW.sendMacro(4)">
<div class="cw-macro-header"><span class="cw-macro-label" id="cwm-lbl-4">QRZ?</span><span class="cw-macro-num">F4</span><button class="cw-macro-edit-btn" onclick="CW.startEdit(4);event.stopPropagation()" data-i18n-title="cw_edit_macro_title" title="Edytuj makro F4">✎</button></div>
<div class="cw-macro-text" id="cwm-txt-4">QRZ? DE {MYCALL}</div>
</div>
<!-- F5 -->
<div class="cw-macro-card" id="cwm-5" title="" onclick="CW.sendMacro(5)">
<div class="cw-macro-header"><span class="cw-macro-label" id="cwm-lbl-5">AGN?</span><span class="cw-macro-num">F5</span><button class="cw-macro-edit-btn" onclick="CW.startEdit(5);event.stopPropagation()" data-i18n-title="cw_edit_macro_title" title="Edytuj makro F5">✎</button></div>
<div class="cw-macro-text" id="cwm-txt-5">PSE AGN?</div>
</div>
<!-- F6 -->
<div class="cw-macro-card" id="cwm-6" title="" onclick="CW.sendMacro(6)">
<div class="cw-macro-header"><span class="cw-macro-label" id="cwm-lbl-6">QSL</span><span class="cw-macro-num">F6</span><button class="cw-macro-edit-btn" onclick="CW.startEdit(6);event.stopPropagation()" data-i18n-title="cw_edit_macro_title" title="Edytuj makro F6">✎</button></div>
<div class="cw-macro-text" id="cwm-txt-6">TU QSL 73</div>
</div>
</div>
<div style="display:flex;gap:4px;padding:0 8px 8px;">
<input type="text" id="cw-custom-input" data-i18n-placeholder="cw_custom_placeholder" placeholder="Wpisz tekst CW…"
style="flex:1;font-family:var(--mono);font-size:12px;text-transform:uppercase;padding:5px 8px;background:var(--input-bg,#0a0a0a);border:1px solid var(--border);color:var(--fg);border-radius:3px;"
onkeydown="if(event.key==='Enter')CW.sendCustom()">
<button onclick="CW.sendCustom()" data-perm-disable="ptt"
style="background:rgba(var(--green-rgb),0.12);border:1px solid var(--green2);color:var(--green);font-family:var(--mono);font-size:11px;padding:5px 10px;border-radius:3px;cursor:pointer;">TX</button>
</div>
<div class="tile-divider"></div>
<!-- OPERATORZY ONLINE — lista + TRX lock -->
<div style="display:flex;flex-direction:column;">
<div class="tile-header" style="font-size:9px;letter-spacing:2px;">
<span data-i18n="operators_hdr">OPERATORZY</span>
<span id="op-online-count" style="font-family:var(--mono);font-size:9px;color:var(--green);">0</span>
</div>
<!-- Lista zalogowanych -->
<!-- max-height shrunk 200px->90px (2026-09-03, live report: "musze
przewijac strone"): CZAT was added below this same tile
(see further down) and the combined height pushed the whole
RADIO page past the viewport, forcing a page-level scroll
just to reach the chat input. 200px assumed many operators
online at once - for a small station that's rarely true, and
this list already scrolls internally (overflow-y) beyond
whatever fits, so nothing is actually lost, just less wasted
space when there are only 2-3 people online. -->
<div id="op-list" style="overflow-y:auto;padding:4px 6px;display:flex;flex-direction:column;gap:3px;max-height:90px;">
<!-- wypełniane przez RadioLock._renderOpPanel() -->
</div>
<!-- Przyciski TRX -->
<div style="padding:6px 8px;display:flex;flex-direction:column;gap:4px;border-top:1px solid var(--border);">
<div id="op-lock-status" style="font-family:var(--mono);font-size:9px;color:var(--dim);text-align:center;padding:2px 0;">
🔓 RADIO WOLNE
</div>
<div style="display:flex;gap:4px;">
<button id="op-btn-take" onclick="RadioLock.lockRadio()" data-i18n="take_trx_btn"
style="flex:1;background:rgba(var(--green-rgb),0.1);border:1px solid var(--green2);
border-radius:3px;color:var(--green);font-family:var(--mono);font-size:10px;
padding:5px;cursor:pointer;letter-spacing:1px;">
▶ PRZEJMIJ TRX
</button>
<button id="op-btn-release" onclick="RadioLock.releaseRadio()" data-i18n="release_trx_btn"
style="flex:1;display:none;background:rgba(var(--red-rgb),0.08);
border:1px solid rgba(var(--red-rgb),0.3);border-radius:3px;color:var(--red);
font-family:var(--mono);font-size:10px;padding:5px;cursor:pointer;">
✕ ODDAJ TRX
</button>
</div>
<button id="op-btn-request" onclick="RadioLock.requestRadio()" data-i18n="request_trx_btn"
style="display:none;background:rgba(var(--amber-rgb),0.08);border:1px solid rgba(var(--amber-rgb),0.3);
border-radius:3px;color:var(--amber);font-family:var(--mono);font-size:10px;
padding:5px;cursor:pointer;letter-spacing:1px;">
🙋 POPROŚ O TRX
</button>
<!-- Tylko admin -->
<button id="op-btn-force" onclick="RadioLock.forceRelease()" data-i18n="force_release_btn"
style="display:none;background:none;border:1px solid var(--border);
border-radius:3px;color:var(--dim);font-family:var(--mono);font-size:9px;
padding:3px;cursor:pointer;">
⚡ WYMUŚ (admin)
</button>
</div>
<!-- CZAT — wątek wiadomości operatorów, tylko sesja (znika po
restarcie serwera, patrz App._chat_history w webapp.py).
Wysokość regulowana przeciąganiem #chat-messages (ten sam
wzorzec co dekoder CW, patrz deepcw.js::_initCwScaling) —
domyślnie kompaktowa, żeby nie spychać przycisków TRX powyżej. -->
<div style="border-top:1px solid var(--border);display:flex;flex-direction:column;">
<div class="tile-header" style="font-size:9px;letter-spacing:2px;">
<span data-i18n="chat_hdr">CZAT</span>
</div>
<div id="chat-messages" style="height:90px;min-height:50px;max-height:400px;overflow:auto;resize:vertical;padding:4px 6px;display:flex;flex-direction:column;gap:5px;"></div>
<div style="display:flex;gap:4px;padding:4px 6px;border-top:1px solid var(--border);">
<input id="chat-input" type="text" maxlength="500" data-i18n-placeholder="chat_input_ph" placeholder="Wiadomość..."
style="flex:1;font-family:var(--mono);font-size:11px;padding:5px 8px;background:var(--input-bg,#0a0a0a);border:1px solid var(--border);color:var(--fg);border-radius:3px;">
<button onclick="Chat.send()"
style="background:rgba(var(--green-rgb),0.12);border:1px solid var(--green2);color:var(--green);
font-family:var(--mono);font-size:11px;padding:5px 10px;border-radius:3px;cursor:pointer;">➤</button>
</div>
</div>
</div>
</div><!-- /tile-cw -->
</div><!-- /tile-right -->
</div><!-- /tile-grid -->
</div><!-- /page-radio -->
<!-- ─── PAGE: WSJT-X ────────────────────────────────────────────────────────── -->
<div class="page-content" id="page-wsjtx">
<div class="wsjtx-layout">
<!-- ── GÓRNY PASEK STATUSU (dwie linie, przejete z forka 2026-10-17) ── -->
<div class="wsjtx-topbar">
<div class="wj-topbar-row-1">
<div class="wsjtx-freq" id="wj-freq">14.074.000</div>
<div class="wj-mode-switch" id="wj-mode-switch" data-i18n-title="wj_decode_mode_title" title="Tryb dekodowania">
<button class="wj-mode-btn active" id="wj-mode-ft8-btn" onclick="WSJTX.setDecodeMode('FT8')">FT8</button>
<button class="wj-mode-btn" id="wj-mode-ft4-btn" onclick="WSJTX.setDecodeMode('FT4')">FT4</button>
</div>
<select id="wj-band-select" class="wj-band-select" data-i18n-title="wj_band_select_title" title="Wybierz pasmo i przestrój radio na standardowa czestotliwosc"
onchange="WSJTX.tuneToBand(this.value)">
<option value="" data-i18n="wj_band_placeholder">-- pasmo --</option>
</select>
<div class="wsjtx-status-pill" id="wj-status-pill">○ OFFLINE</div>
<!-- Kontrolki palety wodospadu (Reference/Zero/Gain). margin-left:auto
(2026-10-21, live request) pushes this group and everything after
it (TUNE, HIDE WORKED) to the right edge, leaving a gap mid-row. -->
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;padding:0 8px;border-left:1px solid var(--wj-border);border-right:1px solid var(--wj-border);font-family:var(--mono);font-size:12px;color:var(--wj-text-dim);">
<label data-i18n-title="wj_ref_title" title="Reference: przesuniecie punktu 'typowego szumu' na palecie. Wyzej = wodospad jasniejszy dla szumu.">REF:
<input type="range" id="wj-palette-ref" min="0" max="100" value="15" step="1"
oninput="WSJTXScope.setPaletteReference(this.value/100);document.getElementById('wj-palette-ref-val').textContent=this.value"
style="width:55px;accent-color:var(--green);vertical-align:middle;cursor:pointer;">
<span id="wj-palette-ref-val" style="width:20px;display:inline-block;text-align:right;">15</span>
</label>
<label data-i18n-title="wj_zero_title" title="Zero: odcina najslabsze sygnaly (podnosi 'dol' palety). Wyzej = wiecej szumu tla znika w czern.">ZERO:
<input type="range" id="wj-palette-zero" min="0" max="60" value="0" step="1"
oninput="WSJTXScope.setPaletteZero(this.value/100);document.getElementById('wj-palette-zero-val').textContent=this.value"
style="width:55px;accent-color:var(--amber);vertical-align:middle;cursor:pointer;">
<span id="wj-palette-zero-val" style="width:20px;display:inline-block;text-align:right;">0</span>
</label>
<label data-i18n-title="wj_gain_title" title="Gain: kontrast krzywej. Wyzej = mocniejsze rozroznienie miedzy szumem a sygnalem.">GAIN:
<input type="range" id="wj-palette-gain" min="30" max="300" value="100" step="5"
oninput="WSJTXScope.setPaletteGain(this.value/100);document.getElementById('wj-palette-gain-val').textContent=(this.value/100).toFixed(1)"
style="width:55px;accent-color:var(--red);vertical-align:middle;cursor:pointer;">
<span id="wj-palette-gain-val" style="width:22px;display:inline-block;text-align:right;">1.0</span>
</label>
<button onclick="WSJTX.resetPaletteAdjust()" data-i18n-title="wj_reset_palette_title" title="Przywroc domyslne (REF=15, ZERO=0, GAIN=1.0)" class="wj-btn wj-btn-xs" style="padding:2px 6px;">↺</button>
</div>
<button onclick="WSJTX.startTune()" class="wj-btn wj-btn-tune" id="wj-tune-btn" data-i18n-title="wj_tune_title" title="Nadaj stały ton dla strojenia ATU. Kliknij ponownie aby przerwać.">🎵 TUNE</button>
<label class="wj-topbar-check" data-i18n-title="wj_hide_worked_title" title="Ukryj stacje już zaliczone w logu QSO">
<input type="checkbox" id="wj-hide-worked" onchange="WSJTX.toggleHideWorked(this)" style="accent-color:var(--amber);cursor:pointer;">
HIDE WORKED
</label>
</div>
<!-- DOLNA LINIA: cq ; hound ; 1st/2st ; zegar odliczania periodow ; glosnik ; [spacer] ; START / STOP (wlasny RX) -->
<div class="wj-topbar-row-2">
<div class="wj-topbar-left">
<label class="wj-topbar-check" data-i18n-title="wj_cq_only_title" title="Pokazuj tylko wywolania CQ w Band Activity">
<input type="checkbox" id="wj-cq-only-toggle" onchange="WSJTX.toggleCqOnly()"> CQ
</label>
<label class="wj-topbar-check hound-box" data-i18n-title="wj_hound_title" title="Fox/Hound DXpedition mode — wpisz znak Foxa w polu DX">
<input type="checkbox" id="wj-hound-toggle" onchange="WSJTX.toggleHound(this.checked)"> HOUND
</label>
<!-- WSJTX.houndConfirm() istnial i byl eksportowany, ale nic go nigdy
nie wolalo - toast "potwierdz obecnosc" (_houndCheckConfirm co
30s) wisial bez zadnego przycisku do klikniecia, blokujac Hounda
na stale po 2 minutach. Domyslnie ukryty, pokazywany razem z
toastem przez _houndCheckConfirm. -->
<button id="wj-hound-confirm-btn" onclick="WSJTX.houndConfirm()" style="display:none;color:#f97316;border:1px solid #f97316;border-radius:6px;padding:6px 12px;background:rgba(249,115,22,0.2);cursor:pointer;font-family:var(--mono);font-size:13px;font-weight:800;"
data-i18n="wj_hound_confirm_btn">⚡ POTWIERDŹ</button>
<div class="wj-period-switch" id="wj-period-switch" data-i18n-title="wj_period_title" title="Okres nadawania (parzyste/nieparzyste okno 15s)">
<button class="wj-mode-btn active" id="wj-period-1-btn" onclick="WSJTX.setTxPeriod(1)">1st</button>
<button class="wj-mode-btn" id="wj-period-2-btn" onclick="WSJTX.setTxPeriod(2)">2nd</button>
</div>
<div class="wsjtx-clock-wrap" title="Zegar odliczania periodow UTC">
<canvas id="wj-clock" width="36" height="36"></canvas>
<div id="wj-clock-s">0</div>
</div>
<!-- MUTE (2026-09-06, live request: "jeden lubi sluchac dekodow
nadawania, drugiemu to przeszkadza, per user") — per-operator
speaker mute, DIGI ONLY (confirmed live: musi dzialac wylacznie
gdy radio faktycznie jest w trybie digi - USB-D/PKTUSB na tym
IC-7300 - i nie tykac SSB/CW, ktore maja juz swoj wlasny przycisk
MON). Zaimplementowane w AudioControls (ws.js), zeby dzielic ten
sam _masterGain co RX VOL, ale sprawdza aktualny tryb radia przed
wyciszeniem. -->
<button id="wj-mute-btn" onclick="AudioControls.toggleRxMute()"
title="Wycisz dzwiek RX na digi (FT8/FT4) — nie dotyka SSB/CW"
class="wj-btn" style="padding:6px 10px;font-size:14px;line-height:1;">🔊</button>
<!-- max-width+ellipsis (2026-09-03): d.text (the FT8 message being
waited on) has no length cap from the backend - without this, a
long message could grow this pill wide enough to push the row.
Widened 280->440px (2026-10-20). A 2026-10-21 attempt switched
this to wrap instead of truncate, but that made the row jump in
HEIGHT every time the pill's line count changed (live report:
"co chwile strona lata") and looked bad - reverted to a single
line, just with a much more generous cap (560px) that should
comfortably cover any realistic message ("XX1XX DE SQ3MZM
RR73" etc.). flex-shrink:0 is the actual fix (found live
2026-10-21): every sibling in this row is flex-shrink:0, so
THIS pill absorbed all of the row's shrinking and got squeezed
to ~115px regardless of max-width. -->
<div class="wsjtx-status-pill" id="wj-tx-wait-status" style="display:none;background:rgba(255,170,0,0.15);border-color:#f59e0b;color:#fbbf24;max-width:560px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
data-i18n-title="wj_tx_wait_title" title="Nadawanie FT8/FT4 musi wystartowac dokladnie na granicy okna 15s/7.5s UTC — to normalne oczekiwanie, nie zawieszenie. Sam TX (PTT) zaczyna sie po zejsciu licznika do 0.">⏳ TX za --s</div>
</div>
<!-- Spacer -->
<div style="flex:1;"></div>
<!-- Przycisk START / STOP (własny RX) po prawej stronie dokładnie nad kolumną automatyki -->
<div class="wj-topbar-right">
<button id="wj-own-rx-btn" onclick="WSJTX.toggleOwnRx()" class="wj-btn stopped" data-i18n-title="wj_own_rx_title" title="Wlasny dekoder FT8 (audio z radia, bez zewnetrznego WSJT-X)">▶ START (własny RX)</button>
</div>
</div>
</div>
<!-- Wrapper: lewa(scope+decodes+bottombar) | prawa kolumna -->
<div style="display:flex;flex-direction:row;flex:1;overflow:hidden;min-height:0;">
<div style="display:flex;flex-direction:column;flex:1;overflow:hidden;min-width:0;min-height:0;">
<!-- ── PASEK POSTĘPU CYKLU UTC (przejety z forka 2026-10-17) ──
Rozbudowuje istniejacy _updateClock() w wsjtx.js (obok rysowania
malej ikony-zegara #wj-clock, ktora zostaje bez zmian) o te 4
elementy: #wj-cycle-fill (szerokosc %), #wj-cycle-time (tekst),
#wj-cycle-type (tekst), #wj-cycle-phase (tekst+kolor). -->
<div class="cycle-bar-wrap">
<div class="cycle-bar-header">
<span><span data-i18n="wj_cycle_utc_lbl">UTC CYKL:</span> <strong id="wj-cycle-type" style="color:#38bdf8;">FT8 (15.0s)</strong></span>
<span id="wj-cycle-phase" style="color:#22c55e;font-weight:800;" data-i18n="wj_cycle_phase_rx">ODBIÓR (RX)</span>
<span id="wj-cycle-time" style="font-weight:700;">00.0s / 15.0s</span>
</div>
<div class="cycle-progress-outer">
<div class="cycle-progress-fill" id="wj-cycle-fill"></div>
<div class="cycle-markers-overlay">
<span class="c-marker-rx">RX</span>
<span class="c-marker-dec" data-i18n="wj_cycle_marker_dec">DEKODOWANIE</span>
<span class="c-marker-tx">TX PREP</span>
</div>
</div>
</div>
<!-- ── WODOSPAD / SCOPE ── -->
<div class="wsjtx-scope-wrap">
<div class="wsjtx-scope-toolbar">
<span class="wj-freq-field" style="color:#f55;">
TX:
<input type="number" id="wj-tx-freq-input" class="wj-freq-input" step="1"
onchange="WSJTX.setTxFreqManual(this.value)">Hz
</span>
<button id="wj-rx-eq-tx-btn" onclick="WSJTX.rxEqTx()" class="wj-btn" data-i18n-title="wj_rx_eq_tx_title" title="Ustaw znacznik RX na biezaca czestotliwosc TX" style="margin-left:6px;">RX=TX</button>
<button id="wj-tx-eq-rx-btn" onclick="WSJTX.txEqRx()" class="wj-btn" data-i18n-title="wj_tx_eq_rx_title" title="Ustaw znacznik TX na biezaca czestotliwosc RX" style="margin-left:4px;">TX=RX</button>
<span class="wj-freq-field" style="color:#5c5; margin-left:10px;">
RX:
<input type="number" id="wj-rx-freq-input" class="wj-freq-input" step="1"
onchange="WSJTX.setRxFreqManual(this.value)">Hz
</span>
<div style="flex:1"></div>
<button onclick="WSJTX.haltTx()" class="wj-btn" id="wj-halt-tx-btn" style="border-color:#ef4444;color:#ef4444;" data-i18n-title="wj_halt_tx_title" title="Zatrzymaj TX po bieżącym oknie">⛔ HALT TX</button>
<button id="wj-tx-freeze-btn" onclick="WSJTX.toggleTxFreeze()" class="wj-btn" style="margin-left:6px;" data-i18n-title="wj_tx_freeze_title" title="Zamroz znacznik TX w wybranym miejscu — kliknij na waterfalllu aby ustawic pozycje TX, potem zamroz. RX nadal automatycznie podaza za wywolaniami adresowanymi do Ciebie.">🔓 TX WOLNY</button>
<button id="wj-fake-split-toggle" onclick="WSJTX.toggleFakeSplit()" class="wj-btn" style="margin-left:6px;"
data-i18n-title="wj_fake_split_title" title="Fake Split: przesuwa VFO na czas nadawania tak, zeby audio bylo blisko srodka filtra SSB (~1500Hz) — pelna moc, brak splatterow na krawedziach filtra. Steruje radiem podczas TX, VFO wraca po nadaniu.">⭕ Fake Split: OFF</button>
<span id="wj-fake-split-target" style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-left:4px;"></span>
</div>
<div id="wj-scope-axis" class="wsjtx-scope-axis"></div>
<div style="position:relative;width:100%;">
<canvas id="wj-scope-canvas" class="wsjtx-scope-canvas" height="160"></canvas>
<!-- Overlay UTC time labels co 1 min - pozycjonowany absolutnie nad waterfallem -->
<div id="wj-scope-time-overlay" style="position:absolute;left:0;top:0;bottom:0;width:38px;pointer-events:none;font-family:var(--mono);font-size:9px;color:rgba(255,255,255,0.5);"></div>
<!-- Flagi stacji wolających CQ (klikalne — QSY do nich), odpowiednik
flag DX-spotów na wodospadzie radia (waterfall.js), ale zrodlo
to NASZE dekody FT8 (kto AKTUALNIE woła CQ), nie DX cluster —
patrz WSJTX.getCqDecodes / WSJTXScope.refreshCqFlags. -->
<div id="wj-scope-cq-flags" style="position:absolute;left:0;top:0;right:0;height:32px;pointer-events:none;"></div>
</div>
</div>
<!-- ── POZIOMY RESIZER (GORA/DOL) MIEDZY WODOSPADEM A OKNAMI DEKODOW ── -->
<div class="wj-h-split-resizer" id="wj-h-split-resizer" title="Przeciagnij gora/dol aby zmienic wysokosc okien dekodowania">
<div class="wj-h-split-grip"></div>
</div>
<!-- ── ŚRODEK: DEKODOWANIA + TX PANEL ── -->
<div class="wsjtx-main" style="display:flex;flex-direction:row;flex:1;overflow:hidden;min-height:0;">
<!-- LEWA: tabela dekodowań (Band Activity) + Rx Frequency, obok siebie, podzial regulowany -->
<div class="wsjtx-decodes-wrap" id="wj-decodes-wrap">
<div class="wj-split-pane" id="wj-pane-activity" style="flex-basis:50%;">
<div class="wj-panel-title" style="padding:4px 8px;font-size:10px;display:flex;align-items:center;">
<span data-i18n="wj_band_activity_hdr">BAND ACTIVITY</span>
<div style="flex:1"></div>
<button class="wj-btn wj-btn-xs wj-country-mode-btn" onclick="WSJTX.toggleCountryMode()"
data-i18n-title="wj_country_col_title" title="Kolumna Kraj: flaga (klik: przelacz na nazwe)">🏳️</button>
<button class="wj-btn wj-btn-xs" onclick="WSJTX.clearDecodes()" data-i18n-title="wj_clear_band_activity_title" title="Wyczysc Band Activity">🗑</button>
</div>
<div class="wsjtx-decode-header">
<span class="wjh">UTC</span>
<span class="wjh wjh-r">dB</span>
<span class="wjh wjh-r">DT</span>
<span class="wjh wjh-r">Freq</span>
<span class="wjh" style="flex:1;" data-i18n="wj_th_message">Wiadomość</span>
<span class="wjh" data-i18n="wj_th_grid">Siatka</span>
<span class="wjh" data-i18n="wj_th_country">Kraj</span>
</div>
<div id="wj-decodes" class="wsjtx-decode-list">
<div class="wj-empty" data-i18n="wj_no_decodes">Brak dekodowań — uruchom WSJT-X i kliknij ▶ START</div>
</div>
</div>
<div class="wj-split-resizer" id="wj-split-resizer" title="Przeciagnij aby zmienic proporcje"></div>
<div class="wj-split-pane" id="wj-pane-rxfreq" style="flex-basis:50%;">
<div class="wj-panel-title" style="padding:4px 8px;font-size:10px;display:flex;align-items:center;">
<span data-i18n="wj_rxfreq_hdr">RX FREQUENCY</span>
<div style="flex:1"></div>
<button class="wj-btn wj-btn-xs wj-country-mode-btn" onclick="WSJTX.toggleCountryMode()"
data-i18n-title="wj_country_col_title" title="Kolumna Kraj: flaga (klik: przelacz na nazwe)">🏳️</button>
<button class="wj-btn wj-btn-xs" onclick="WSJTX.clearRxFreqPanel()" data-i18n-title="wj_clear_rxfreq_title" title="Wyczysc Rx Frequency">🗑</button>
</div>
<div class="wsjtx-decode-header">
<span class="wjh">UTC</span>
<span class="wjh wjh-r">dB</span>
<span class="wjh wjh-r">DT</span>
<span class="wjh wjh-r">Freq</span>
<span class="wjh" style="flex:1;" data-i18n="wj_th_message">Wiadomość</span>
<span class="wjh" data-i18n="wj_th_grid">Siatka</span>
<span class="wjh" data-i18n="wj_th_country">Kraj</span>
</div>
<div id="wj-rx-freq-row" class="wsjtx-decode-list">
<div class="wj-empty" data-i18n="wj_no_rxfreq_signal">Brak sygnału na czestotliwosci RX</div>
</div>
</div>
</div>
</div><!-- /wsjtx-main -->
<!-- ── DOLNY PASEK (przejety z forka 2026-10-17: audio-status + engine-label statyczne, wj-decode-count/wj-version zostaja tym samym elementem co dawniej) ── -->
<div class="wsjtx-bottombar">
<span id="wj-audio-status" style="font-size:11.5px;color:var(--wj-text-dim);">Audio: 12000 Hz float mono • Rig: Icom IC-7300 (USB-D)</span>
<div style="flex:1"></div>
<span id="wj-decode-count" data-i18n="wj_decode_count_default" style="font-weight:700;">0 dekodowań</span>
<span style="margin-left:14px;font-weight:800;color:var(--green);" id="wj-engine-label">HAMCTRL Engine</span>
<span style="font-family:var(--mono);font-size:10px;color:var(--wj-text-dimmer);margin-left:12px;" id="wj-version"></span>
</div>
</div><!-- /lewa-kolumna -->
<!-- PRAWA KOLUMNA od gornej linii waterfalla -->
<!-- PRAWA: panel TX + log -->
<div class="wsjtx-right" id="wj-right-col">
<!-- AUTOMATYKA QSO — ZAWSZE AKTYWNA. Klik na makro = ręczne wymuszenie.
Status-box (przejety z forka 2026-10-16) zamiast samej linii tekstu —
#wj-autoqso-status zostaje tym samym elementem co dawniej (JS go
znajduje po id bez zmian), tylko teraz w srodku karty .auto-qso-box. -->
<div class="wsjtx-tx-panel" id="wj-autoqso-panel">
<div class="wj-panel-title" style="display:flex;align-items:center;justify-content:space-between;">
<span data-i18n="wj_autoqso_hdr">AUTOMATYKA QSO</span>
<span style="font-family:var(--mono);font-size:11px;padding:2px 8px;border-radius:4px;background:rgba(34,197,94,0.22);color:#22c55e;font-weight:800;letter-spacing:0.5px;" data-i18n-title="wj_auto_badge_title" title="Automatyka jest zawsze aktywna. Klik na makro (F1-F6) w panelu TX wymusza ręczne nadanie tej wiadomości." data-i18n="wj_auto_badge">● AUTO</span>
</div>
<div class="auto-qso-box active">
<div id="wj-autoqso-status" data-i18n="wj_autoqso_ready" style="font-size:12px;color:#cbd5e1;line-height:1.4;">Automatyka gotowa — kliknij wiersz CQ aby rozpocząć automatyczne QSO • klik makro = ręczne wymuszenie</div>
</div>
<div style="display:flex;gap:4px;margin-top:6px;">
<button id="wj-autoqso-skip" onclick="WSJTX.skipAutoQso()" style="display:none;width:100%;font-family:var(--mono);font-size:12px;font-weight:800;padding:6px;background:rgba(245,158,11,0.28);border:1px solid #f59e0b;color:#fbbf24;border-radius:4px;cursor:pointer;" data-i18n-title="wj_skip_title" title="Porzuć bieżącą stację (bez czekania na limit prób)" data-i18n="wj_skip_btn">⏭ Pomiń stację</button>
</div>
</div>
<!-- Ukryty checkbox dla kompatybilnosci JS (auto-seq zawsze true) -->
<input type="checkbox" id="wj-auto-seq-toggle" checked style="display:none;">
<!-- FOX / HOUND — jeden wiersz, bez rozwijania -->
<!-- Ukryte elementy Hound dla JS -->
<div id="wj-hound-status" style="display:none;"></div>
<div id="wj-hound-seq" style="display:none;"></div>
<div id="wj-hound-step-1" style="display:none;"><span id="wj-hound-step-1-status"></span></div>
<div id="wj-hound-step-2" style="display:none;"><span id="wj-hound-step-2-status"></span></div>
<div id="wj-hound-step-3" style="display:none;"><span id="wj-hound-step-3-status"></span></div>
<div id="wj-hound-step-4" style="display:none;"><span id="wj-hound-step-4-status"></span></div>
<span id="wj-hound-fox-call" style="display:none;"></span>
<span id="wj-hound-tx-freq" style="display:none;"></span>
<span id="wj-hound-attempts" style="display:none;"></span>
<div id="wj-hound-active-badge" style="display:none;"></div>
<div id="wj-hound-desc" style="display:none;"></div>
<div id="wj-hound-status-inline" style="display:none;"></div>
<button id="wj-hound-stop-btn" style="display:none;" onclick="WSJTX.houndStop()"></button>
<!-- NADAWANIE TX -->
<div class="wsjtx-tx-panel">
<div class="wj-panel-title" style="display:flex;align-items:center;justify-content:space-between;">
<span data-i18n="wj_tx_panel_hdr">NADAWANIE TX</span>
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-family:var(--mono);font-size:11px;color:var(--dim);" data-i18n="wj_timer_lbl">TIMER:</span>
<span id="ft8-timer-display" style="font-family:var(--mono);font-size:14px;font-weight:800;
color:#22c55e;min-width:42px;text-align:right;">--:--</span>
<button id="ft8-timer-confirm" onclick="FT8Timer.confirm()"
style="display:none;font-family:var(--mono);font-size:11px;padding:3px 8px;
background:rgba(255,140,0,0.25);border:1px solid #f90;border-radius:4px;
color:#f90;cursor:pointer;font-weight:800;animation:wj-tx-pulse 0.5s ease infinite alternate;" data-i18n="wj_confirm_btn">
POTWIERDŹ ▶
</button>
</div>
</div>
<div class="wsjtx-tx-grid">
<button class="wj-tx-btn" id="wj-tx1" onclick="WSJTX.sendTx(1)">
<span class="wj-tx-num">F1</span>
<span class="wj-tx-text" id="wj-tx1-text">CQ {MYCALL} {GRID}</span>
</button>
<button class="wj-tx-btn" id="wj-tx2" onclick="WSJTX.sendTx(2)">
<span class="wj-tx-num">F2</span>
<span class="wj-tx-text" id="wj-tx2-text">{DXCALL} {MYCALL} {GRID}</span>
</button>
<button class="wj-tx-btn" id="wj-tx3" onclick="WSJTX.sendTx(3)">
<span class="wj-tx-num">F3</span>
<span class="wj-tx-text" id="wj-tx3-text">{DXCALL} {MYCALL} R{REPORT}</span>
</button>
<button class="wj-tx-btn" id="wj-tx4" onclick="WSJTX.sendTx(4)">
<span class="wj-tx-num">F4</span>
<span class="wj-tx-text" id="wj-tx4-text">{DXCALL} {MYCALL} RRR</span>
</button>
<button class="wj-tx-btn" id="wj-tx5" onclick="WSJTX.sendTx(5)">
<span class="wj-tx-num">F5</span>
<span class="wj-tx-text" id="wj-tx5-text">{DXCALL} {MYCALL} 73</span>
</button>
<button class="wj-tx-btn" id="wj-tx6" onclick="WSJTX.sendTx(6)">
<span class="wj-tx-num">F6</span>
<span class="wj-tx-text" id="wj-tx6-text">{DXCALL} RR73</span>
</button>
</div>
<div style="display:flex;gap:8px;margin-top:8px;align-items:center;">
<span style="font-family:var(--mono);font-size:14px;font-weight:800;color:var(--dim);">DX:</span>
<input id="wj-dx-call" type="text" data-i18n-placeholder="wj_dx_call_ph" placeholder="-- KLIKNIJ WIERSZ LUB WPISZ --"
onkeydown="if(event.key==='Enter'){WSJTX.searchDxCall(this.value)}"
onblur="WSJTX.searchDxCall(this.value)"
style="flex:1;text-transform:uppercase;">
<input id="wj-dx-grid" type="text" placeholder="Grid"
style="text-transform:uppercase;">
</div>
<div style="display:flex;gap:6px;margin-top:6px;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:13px;">
<span style="color:var(--dim);font-weight:700;">SNR:</span>
<span id="wj-dx-snr" style="font-weight:800;font-size:15px;color:#22c55e;min-width:40px;">--</span>
</div>
<div id="wj-tx-indicator" style="display:none;background:#ef4444;color:#fff;
font-family:var(--mono);font-size:11px;font-weight:800;padding:3px 10px;
border-radius:4px;letter-spacing:1px;animation:wj-tx-pulse 0.5s ease infinite alternate;">
📡 NADAWANIE (TX)
</div>
</div>
</div>
<!-- ANTENA I ROTOR — wydzielone do wlasnej karty (poprzednio bylo
wtopione w naglowek panelu LOG QSO) -->
<div class="wsjtx-tx-panel">
<div class="wj-panel-title" style="display:flex;align-items:center;justify-content:space-between;">
<span data-i18n="wj_antenna_hdr">ANTENA I ROTOR</span>
<span id="wj-rotor-az" style="color:#22c55e;font-weight:800;font-size:13.5px;">ROTOR: --°</span>
</div>
<!-- Kierunek anteny na korespondenta — liczony w przegladarce po
wpisaniu znaku (lokator z pola / cache FT8 / logu / prefiksu).
STALE WIDOCZNY (nie chowa sie miedzy polami) — placeholder "---°"
dopoki nie wybrano stacji. Prawa strona: zywa pozycja rotora (ten
sam WS "rotator_update" co duzy kompas w RADIO) + SP/LP - klik od
razu wysyla komende, bez osobnego potwierdzenia (patrz
rotorGoBeam w wsjtx.js). -->
<div id="wj-beam-row" style="display:flex;align-items:center;justify-content:space-between;background:var(--wj-input-bg);border:1px solid var(--wj-border-bright);border-radius:5px;padding:8px 10px;font-family:var(--mono);">
<!-- Lewa czesc (namiar na stacje) MOZE sie skracac/obcinac (...) -
prawa (kontrolki rotora) MA flex-shrink:0, wiec nigdy nie
zostaje wypchnieta poza widoczny obszar bez wzgledu na to jak
dlugi zrobi sie tekst namiaru. Wczesniej caly wiersz mial
overflow:hidden bez tego podzialu, wiec wypelnienie namiaru po
wybraniu stacji (dluzszy tekst) obcinalo przyciski SP/LP/target
po prawej - wygladalo jak zniknely, choc byly tylko wypchniete
poza krawedz. Zglaszone na zywo: "znacznik recznego wyboru
pozycji rotora zniknal jak tylko wybralem stacje do QSO". -->
<div style="overflow:hidden;white-space:normal;min-width:0;flex-shrink:1;line-height:1.5;">
<span style="color:var(--dim);">AZ:</span>
<strong id="wj-beam-az" style="color:#f59e0b;font-size:20px;margin:0 6px;">---°</strong>
<span id="wj-beam-dist" style="color:var(--green);font-size:13px;font-weight:800;"></span>
<span id="wj-beam-src" style="color:var(--wj-text-dimmer);margin-left:4px;font-size:11px;"></span>
<span id="wj-beam-long" style="color:var(--wj-text-dimmer);margin-left:6px;font-size:11px;"></span>
</div>
<div style="display:flex;gap:4px;align-items:center;flex-shrink:0;">
<button id="wj-rotor-sp-btn" onclick="WSJTX.rotorGoBeam('sp')" disabled
data-i18n-title="wj_rotor_sp_title" title="Ustaw rotor na azymut SP (krotka droga)"
class="wj-btn" style="padding:4px 8px;font-size:11.5px;">▸SP</button>
<button id="wj-rotor-lp-btn" onclick="WSJTX.rotorGoBeam('lp')" disabled
data-i18n-title="wj_rotor_lp_title" title="Ustaw rotor na azymut LP (dluga droga)"
class="wj-btn" style="padding:4px 8px;font-size:11.5px;">▸LP</button>
<button id="wj-rotor-manual-btn" onclick="WSJTX.rotorGoManual()" disabled
data-i18n-title="wj_rotor_manual_title" title="Recznie: wpisz azymut w stopniach lub lokator"
class="wj-btn" style="padding:4px 8px;font-size:11.5px;">🎯</button>
</div>
</div>
</div>
<!-- SZYBKI ZAPIS QSO -->
<div class="wsjtx-log-panel">
<div class="wj-panel-title" style="display:flex;align-items:center;justify-content:space-between;">
<span data-i18n="wj_quicklog_hdr">SZYBKI ZAPIS QSO</span>
<span style="color:var(--wj-text-dimmer);font-size:11px;">DB: qso.db</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
<input id="wj-log-call" type="text" data-i18n-placeholder="wj_call_dx_ph" placeholder="CALLSIGN DX *"
oninput="WSJTX.updateBeamRow()" style="text-transform:uppercase;">
<input id="wj-log-grid" type="text" data-i18n-placeholder="wj_grid_dx_ph" placeholder="Grid DX"
oninput="WSJTX.updateBeamRow()" style="text-transform:uppercase;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
<select id="wj-log-mode">
<option>FT8</option><option>FT4</option>
</select>
<input id="wj-log-rst-sent" type="text" placeholder="RST Sent">
<input id="wj-log-rst-rcvd" type="text" placeholder="RST Rcvd">
</div>
<input id="wj-log-comment" type="text" data-i18n-placeholder="wj_comment_ph" placeholder="Komentarz (opcjonalnie)">
<div style="display:flex;gap:6px;">
<button onclick="WSJTX.addLog()" class="wj-btn" style="flex:1;background:rgba(34,197,94,0.25);border-color:#22c55e;color:#22c55e;font-weight:800;font-size:13.5px;padding:8px;" data-i18n="wj_addlog_btn">+ LOG QSO</button>
<button onclick="WSJTX.exportAdif()" class="wj-btn" style="padding:8px 12px;font-size:12px;" data-i18n-title="wj_adif_btn_title" title="Eksport ADIF">⬇ ADIF</button>
<button onclick="WSJTX.exportAllTxt()" class="wj-btn" style="padding:8px 12px;font-size:12px;" data-i18n-title="wj_alltxt_btn_title" title="Pobierz ALL.TXT - wszystko odebrane i nadane">⬇ ALL.TXT</button>
</div>
<!-- Mini-log: czysto informacyjny podglad tego co wyladowalo w logu -->
<!-- (pasmo/znak/RST) - edycja i pelny zapis sa na osobnej stronie -->
<!-- LOG QSO (tab-log), tu tylko podglad na biezaco. -->
<div class="minilog-wrap" style="margin-top:6px;border-top:1px solid var(--wj-border);padding-top:6px;">
<div style="font-size:10px;color:var(--dim);margin-bottom:4px;font-family:var(--mono);font-weight:700;" data-i18n="wj_minilog_hdr">OSTATNIO ZALOGOWANE:</div>
<div style="max-height:80px;overflow-y:auto;">
<table style="width:100%;border-collapse:collapse;font-family:var(--mono);font-size:10.5px;">
<tbody id="wj-minilog-body">
<tr><td colspan="4" style="color:var(--dim);text-align:center;padding:8px;" data-i18n="wj_no_logged_qso">Brak zalogowanych QSO</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- LEGENDA KOLORÓW wierszy dekodowania (Band Activity / RX Frequency) -->
<div class="wsjtx-config-panel">
<div class="wj-panel-title" data-i18n="wj_color_legend_hdr">LEGENDA KOLORÓW</div>
<div class="wj-legend-grid">
<div class="wj-legend-item"><span class="wj-legend-dot" style="background:#38bdf8;"></span><span data-i18n="wj_legend_cq">CQ</span></div>
<div class="wj-legend-item"><span class="wj-legend-dot" style="background:#22c55e;"></span><span data-i18n="wj_legend_dx">Odpowiedź do DX</span></div>
<div class="wj-legend-item"><span class="wj-legend-dot" style="background:#ef4444;"></span><span data-i18n="wj_legend_mycall">Mój znak</span></div>
<div class="wj-legend-item"><span class="wj-legend-dot" style="background:#fbbf24;"></span><span data-i18n="wj_legend_73">73 / RR73</span></div>
<div class="wj-legend-item"><span class="wj-legend-dot" style="background:#f59e0b;"></span><span data-i18n="wj_legend_owntx">Moje TX</span></div>
<div class="wj-legend-item"><span class="wj-legend-dot" style="background:var(--wj-text-dimmer);opacity:0.6;"></span><span data-i18n="wj_legend_worked">Już w logu</span></div>
</div>
</div>
</div><!-- /wsjtx-right -->
</div><!-- /ft8-wrapper -->
</div>
</div>
<!-- ─── PAGE: ROTATORY (standalone) usunieta — rotator dostepny na ekranie RADIO ── -->
<!-- ─── PAGE: LOG QSO ───────────────────────────────────────────────────────── -->
<div class="page-content" id="page-log">
<div class="page-col" style="max-width:100%;">
<!-- PASEK FILTRÓW I AKCJI -->
<div class="box">
<div class="box-header" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span data-i18n="tab_log">LOG QSO</span>
<span id="log-count" style="font-size:10px;color:var(--dim);">0 QSO</span>
<div style="margin-left:auto;display:flex;gap:6px;flex-wrap:wrap;">
<button class="save-btn" style="padding:5px 14px;" onclick="QSOLog.openNew()" data-i18n="log_new_qso_btn">+ NOWE QSO</button>
<button class="save-btn" style="padding:5px 14px;" onclick="QSOLog.exportADI()">EXPORT ADIF</button>
<button class="save-btn" style="padding:5px 14px;" onclick="QSOLog.exportCSV()">EXPORT CSV</button>
<label class="save-btn" style="padding:5px 14px;cursor:pointer;text-align:center;display:block;" data-i18n-title="log_import_title" title="Importuj QSO z pliku ADIF (.adi/.adif)">
IMPORT ADIF
<input type="file" accept=".adi,.adif" style="display:none;" onchange="QSOLog.importADIF(this)">
</label>
<button class="save-btn" style="padding:5px 14px;background:rgba(var(--red-rgb),0.1);border-color:rgba(var(--red-rgb),0.3);color:var(--red);" onclick="QSOLog.deleteSelected()" data-i18n="log_delete_selected_btn">USUŃ ZAZNACZONE</button>
<button class="save-btn" style="padding:5px 14px;background:rgba(var(--red-rgb),0.15);border-color:var(--red);color:var(--red);font-weight:bold;" onclick="QSOLog.deleteAll()" data-i18n="log_delete_all_btn">⚠ KASUJ CAŁY LOG</button>
</div>
</div>
<!-- FILTRY -->
<div style="display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);flex-wrap:wrap;align-items:flex-end;">
<!-- Filtr użytkownika — tylko admin -->
<div id="log-filter-user-wrap" style="display:none;flex-direction:column;gap:3px;">
<label style="font-family:var(--mono);font-size:10px;color:var(--amber);" data-i18n="log_filter_user_lbl">UŻYTKOWNIK</label>
<select id="log-filter-user" style="font-family:var(--mono);font-size:11px;padding:4px 8px;background:var(--panel2);border:1px solid var(--amber);border-radius:3px;color:var(--text);">
<option value="" data-i18n="log_filter_all_users">Wszyscy</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:3px;">
<label style="font-family:var(--mono);font-size:10px;color:var(--dim);" data-i18n="log_filter_from_lbl">OD</label>
<input type="date" id="log-filter-from" style="font-family:var(--mono);font-size:11px;padding:4px 8px;background:var(--panel2);border:1px solid var(--border2);border-radius:3px;color:var(--text);">
</div>
<div style="display:flex;flex-direction:column;gap:3px;">
<label style="font-family:var(--mono);font-size:10px;color:var(--dim);" data-i18n="log_filter_to_lbl">DO</label>
<input type="date" id="log-filter-to" style="font-family:var(--mono);font-size:11px;padding:4px 8px;background:var(--panel2);border:1px solid var(--border2);border-radius:3px;color:var(--text);">
</div>
<div style="display:flex;flex-direction:column;gap:3px;">
<label style="font-family:var(--mono);font-size:10px;color:var(--dim);" data-i18n="log_filter_call_lbl">ZNAK</label>
<input type="text" id="log-filter-call" data-i18n-placeholder="log_filter_call_ph" placeholder="np. SP3GSK" style="font-family:var(--mono);font-size:11px;padding:4px 8px;background:var(--panel2);border:1px solid var(--border2);border-radius:3px;color:var(--text);width:100px;">
</div>
<div style="display:flex;flex-direction:column;gap:3px;">
<label style="font-family:var(--mono);font-size:10px;color:var(--dim);" data-i18n="log_pasmo_lbl">PASMO</label>
<select id="log-filter-band" style="font-family:var(--mono);font-size:11px;padding:4px 8px;background:var(--panel2);border:1px solid var(--border2);border-radius:3px;color:var(--text);">
<option value="" data-i18n="log_filter_all">Wszystkie</option>
<option>160m</option><option>80m</option><option>60m</option><option>40m</option>
<option>30m</option><option>20m</option><option>17m</option><option>15m</option>
<option>12m</option><option>10m</option><option>6m</option><option>4m</option>
<option>2m</option><option>1.25m</option><option>70cm</option>
<option>33cm</option><option>23cm</option><option>13cm</option><option>9cm</option>
<option>6cm</option><option>3cm</option><option>1.2cm</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:3px;">
<label style="font-family:var(--mono);font-size:10px;color:var(--dim);" data-i18n="log_mode_lbl">TRYB</label>
<select id="log-filter-mode" style="font-family:var(--mono);font-size:11px;padding:4px 8px;background:var(--panel2);border:1px solid var(--border2);border-radius:3px;color:var(--text);">
<option value="" data-i18n="log_filter_all">Wszystkie</option>
<option>SSB</option><option>CW</option><option>FT8</option><option>FT4</option>
<option>AM</option><option>FM</option><option>RTTY</option><option>PSK31</option>
</select>
</div>
<div style="display:flex;gap:6px;align-items:flex-end;">
<button class="save-btn" style="padding:5px 14px;" onclick="QSOLog.load()" data-i18n="log_search_btn">SZUKAJ</button>
<button class="save-btn" style="padding:5px 14px;background:rgba(var(--red-rgb),0.1);border-color:rgba(var(--red-rgb),0.3);color:var(--red);" onclick="QSOLog.clearFilters()" data-i18n="log_clear_btn">WYCZYŚĆ</button>
</div>
</div>
<!-- TABELA QSO -->
<div style="overflow-x:auto;">
<table id="log-table" style="width:100%;border-collapse:collapse;font-family:var(--mono);font-size:12px;">
<thead>
<tr style="background:var(--panel2);border-bottom:1px solid var(--border);">
<th class="log-th" style="width:28px;text-align:center;">
<input type="checkbox" onclick="QSOLog.selectAll(this)" data-i18n-title="log_select_all_title" title="Zaznacz wszystkie (tylko ta strona)"
style="width:13px;height:13px;cursor:pointer;accent-color:var(--red);">
</th>
<th class="log-th" onclick="QSOLog.sort('date')" style="cursor:pointer;" data-i18n-title="log_sort_date_title" title="Sortuj po dacie" data-i18n="log_th_date">DATA ↕</th>
<th class="log-th" data-i18n="log_th_time_utc">CZAS UTC</th>
<th class="log-th" onclick="QSOLog.sort('call')" style="cursor:pointer;" data-i18n-title="log_sort_call_title" title="Sortuj po znaku">CALL ↕</th>
<th class="log-th" data-i18n="log_pasmo_lbl">PASMO</th>
<th class="log-th" data-i18n="log_mode_lbl">TRYB</th>
<th class="log-th">FREQ MHz</th>
<th class="log-th">RST S</th>
<th class="log-th">RST R</th>
<th class="log-th" data-i18n="log_th_locator">LOKATOR</th>
<th class="log-th" data-i18n="log_th_comment">KOMENTARZ</th>
<th class="log-th" data-i18n="log_th_actions">AKCJE</th>
</tr>
</thead>
<tbody id="log-table-body">
<tr><td colspan="12" style="text-align:center;padding:20px;color:var(--dim);" data-i18n="settings_loading">Ładowanie...</td></tr>
</tbody>
</table>
</div>
<!-- PAGINACJA -->
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;border-top:1px solid var(--border);">
<button class="save-btn" style="padding:4px 12px;" onclick="QSOLog.prevPage()" data-i18n="log_prev_page_btn">◀ POPRZEDNIA</button>
<span id="log-page-info" style="font-family:var(--mono);font-size:11px;color:var(--dim);">Strona 1</span>
<button class="save-btn" style="padding:4px 12px;" onclick="QSOLog.nextPage()" data-i18n="log_next_page_btn">NASTĘPNA ▶</button>
<span style="margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--dim);" data-i18n="log_per_page">
50 QSO na stronę
</span>
</div>
</div>
</div>
</div>
<!-- Modal: Nowe/Edytuj QSO -->
<div id="log-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.7);z-index:1000;align-items:center;justify-content:center;">
<div style="background:var(--panel);border:1px solid var(--border);border-radius:6px;
padding:24px;width:580px;max-width:90vw;max-height:90vh;overflow-y:auto;overflow-x:hidden;box-sizing:border-box;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<span style="font-family:var(--mono);font-size:13px;letter-spacing:2px;color:var(--green);" id="log-modal-title" data-i18n="log_modal_new_title">NOWE QSO</span>
<button onclick="QSOLog.closeModal()" style="background:none;border:none;color:var(--dim);font-size:18px;cursor:pointer;">✕</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;min-width:0;">
<div class="sg" style="min-width:0;"><label><span data-i18n="log_field_call_lbl">ZNAK *</span>
<span id="qso-worked-badge" style="display:none;margin-left:6px;font-family:var(--mono);font-size:9px;padding:1px 5px;border-radius:2px;letter-spacing:1px;"></span>
</label>
<div style="display:flex;gap:6px;min-width:0;">
<input type="text" id="qso-call" placeholder="SP3GSK" style="flex:1;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;text-transform:uppercase;"
oninput="QSOLog?.checkWorkedBefore?.();QSOLog?.autoFillCountry?.()">
<button type="button" id="qso-lookup-btn" onclick="QSOLog.lookupCall()" data-i18n-title="log_lookup_title" title="Pobierz dane stacji z QRZ.com/HamQTH"
style="flex:0 0 auto;width:auto;font-family:var(--mono);font-size:12px;padding:0 10px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:3px;cursor:pointer;white-space:nowrap;">🔍</button>
</div>
</div>
<div class="sg" style="min-width:0;"><label data-i18n="log_th_locator">LOKATOR</label><input type="text" id="qso-gridsquare" data-i18n-placeholder="log_locator_ph" placeholder="KO02 lub KO02ab" maxlength="8" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;text-transform:uppercase;"></div>
<div class="sg" style="min-width:0;"><label data-i18n="profile_name_lbl">IMIĘ</label><input type="text" id="qso-name" placeholder="John" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
<div class="sg" style="min-width:0;"><label>QTH</label><input type="text" id="qso-qth" placeholder="Warsaw" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
<div class="sg" style="min-width:0;"><label><span data-i18n="log_field_country_lbl">KRAJ (DXCC)</span>
<span id="qso-country-flag" style="margin-left:4px;"></span>
</label><input type="text" id="qso-country" data-i18n-placeholder="log_country_ph" placeholder="auto ze znaku" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
<div class="sg" style="min-width:0;"><label data-i18n="log_field_date_lbl">DATA *</label><input type="date" id="qso-date" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
<div class="sg" style="min-width:0;"><label data-i18n="log_field_time_lbl">CZAS UTC *</label><input type="time" id="qso-time" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
<div class="sg" style="min-width:0;"><label data-i18n="log_field_band_lbl">PASMO *</label>
<select id="qso-band" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;">
<option>160m</option><option>80m</option><option>60m</option><option>40m</option>
<option>30m</option><option selected>20m</option><option>17m</option><option>15m</option>
<option>12m</option><option>10m</option><option>6m</option><option>4m</option>
<option>2m</option><option>1.25m</option><option>70cm</option>
<option>33cm</option><option>23cm</option><option>13cm</option><option>9cm</option>
<option>6cm</option><option>3cm</option><option>1.2cm</option>
</select>
</div>
<div class="sg" style="min-width:0;"><label data-i18n="log_field_mode_lbl">TRYB *</label>
<select id="qso-mode" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;">
<option>SSB</option><option>CW</option><option>FT8</option><option>FT4</option>
<option>AM</option><option>FM</option><option>RTTY</option><option>PSK31</option>
</select>
</div>
<div class="sg" style="min-width:0;"><label>FREQ MHz</label><input type="text" id="qso-freq" placeholder="14.215" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
<div class="sg" style="min-width:0;"><label>PWR (W)</label><input type="number" id="qso-power" placeholder="100" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
<div class="sg" style="min-width:0;"><label>RST SENT</label><input type="text" id="qso-rst-sent" placeholder="599" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
<div class="sg" style="min-width:0;"><label>RST RCVD</label><input type="text" id="qso-rst-rcvd" placeholder="599" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
<div class="sg" style="grid-column:1/-1;min-width:0;"><label data-i18n="log_th_comment">KOMENTARZ</label>
<input type="text" id="qso-comment" placeholder="73 de SP3GSK" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;">
</div>
</div>
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer;
font-family:var(--mono);font-size:11px;color:var(--dim);">
<input type="checkbox" id="qso-is-sat" onchange="QSOLog.toggleSatFields(this.checked)">
<span data-i18n="log_sat_checkbox">🛰 Łączność przez satelitę</span>
</label>
<!-- PASMO/FREQ wyzej = uplink (nadajesz). Downlink (odbierasz) to osobne
pole FREQ_RX/BAND_RX w ADIF - satelity pracuja cross-band (full duplex),
wiec jedno pole PASMO nie wystarczy. -->
<div id="qso-sat-fields" style="display:none;grid-template-columns:1fr 1fr;gap:10px;min-width:0;
margin-top:10px;padding:10px;border:1px solid var(--green2);border-radius:4px;box-sizing:border-box;">
<div class="sg" style="min-width:0;"><label data-i18n="log_sat_name_lbl">SATELITA</label>
<input type="text" id="qso-sat-name" data-i18n-placeholder="log_sat_name_ph" placeholder="np. SO-50, QO-100, ISS" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;text-transform:uppercase;"></div>
<div class="sg" style="min-width:0;"><label data-i18n="log_sat_mode_lbl">TRYB SATELITY</label>
<input type="text" id="qso-sat-mode" data-i18n-placeholder="log_sat_mode_ph" placeholder="np. U/V, V/U, L/X" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;text-transform:uppercase;"></div>
<div class="sg" style="min-width:0;"><label data-i18n="log_downlink_band_lbl">DOWNLINK PASMO</label>
<select id="qso-band-rx" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;">
<option value="" data-i18n="log_downlink_band_default">— jak uplink —</option>
<option>160m</option><option>80m</option><option>60m</option><option>40m</option>
<option>30m</option><option>20m</option><option>17m</option><option>15m</option>
<option>12m</option><option>10m</option><option>6m</option><option>4m</option>
<option>2m</option><option>1.25m</option><option>70cm</option>
<option>33cm</option><option>23cm</option><option>13cm</option><option>9cm</option>
<option>6cm</option><option>3cm</option><option>1.2cm</option>
</select>
</div>
<div class="sg" style="min-width:0;"><label>DOWNLINK FREQ MHz</label>
<input type="text" id="qso-freq-rx" placeholder="145.803" style="width:100%;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:12px;"></div>
</div>
<div style="display:flex;gap:8px;margin-top:16px;">
<button class="save-btn" style="flex:1;" onclick="QSOLog.saveQSO()" data-i18n="log_save_qso_btn">ZAPISZ QSO</button>
<button class="save-btn" style="flex:0 0 auto;background:rgba(var(--red-rgb),0.1);border-color:rgba(var(--red-rgb),0.3);color:var(--red);" onclick="QSOLog.closeModal()" data-i18n="common_cancel_btn">ANULUJ</button>
</div>
</div>
</div>
<!-- ─── PAGE: PROFIL ──────────────────────────────────────────────────────────── -->
<div class="page-content" id="page-profile">
<div class="page-col">
<div class="profile-row">
<div class="box">
<div class="box-header" data-i18n="profile_myprofile_hdr">👤 MÓJ PROFIL</div>
<div class="box-body change-pwd-form">
<div class="field"><label data-i18n="profile_name_lbl">IMIĘ</label>
<input type="text" id="profile-name" data-i18n-placeholder="profile_name_ph" placeholder="Jan" autocomplete="name"></div>
<div class="field"><label data-i18n="profile_callsign_lbl">ZNAK WYWOŁAWCZY</label>
<input type="text" id="profile-callsign" placeholder="SP0ABC"
style="text-transform:uppercase;font-family:var(--mono);font-size:14px;letter-spacing:2px;"
autocomplete="off" oninput="this.value=this.value.toUpperCase()"></div>
<div class="field"><label data-i18n="profile_locator_lbl">LOKATOR QTH (np. JO82)</label>
<input type="text" id="profile-locator" placeholder="JO82" maxlength="8"
style="text-transform:uppercase;font-family:var(--mono);"
autocomplete="off" oninput="this.value=this.value.toUpperCase()"></div>
<div class="field"><label>E-MAIL</label>
<input type="email" id="profile-email" placeholder="user@example.com" autocomplete="email"></div>
<div style="display:flex;align-items:center;gap:12px;margin-top:8px;">
<button class="save-btn" onclick="saveProfile()" data-i18n="profile_save_btn">💾 ZAPISZ PROFIL</button>
<span id="profile-status" style="font-family:var(--mono);font-size:10px;color:var(--dim);"></span>
</div>
</div>
</div>
<div class="box">
<div class="box-header" data-i18n="profile_changepwd_hdr">🔑 ZMIANA HASŁA</div>
<div class="box-body change-pwd-form">
<div class="field"><label data-i18n="profile_old_pwd_lbl">STARE HASŁO</label>
<input type="password" id="profile-old-pwd" autocomplete="current-password"></div>
<div class="field"><label data-i18n="profile_new_pwd_lbl">NOWE HASŁO (min. 8 znaków)</label>
<input type="password" id="profile-new-pwd" autocomplete="new-password"></div>
<div class="field"><label data-i18n="profile_new_pwd2_lbl">POWTÓRZ NOWE HASŁO</label>
<input type="password" id="profile-new-pwd2" autocomplete="new-password"></div>
<div style="display:flex;align-items:center;gap:12px;margin-top:8px;">
<button class="save-btn" onclick="changeProfilePassword()" data-i18n="profile_changepwd_btn">🔑 ZMIEŃ HASŁO</button>
<span id="profile-pwd-status" style="font-family:var(--mono);font-size:10px;color:var(--dim);"></span>
</div>
</div>
</div>
</div>
<div class="profile-row">
<div class="box">
<div class="box-header" data-i18n="profile_audio_hdr">🔊 AUDIO — MOJE URZĄDZENIA (przeglądarka)</div>
<div class="box-body" style="display:flex;flex-direction:column;gap:10px;">
<div data-i18n="profile_audio_desc" style="font-family:var(--mono);font-size:11px;color:var(--dim);line-height:1.5;">
Wybierz gdzie słyszysz odbiór (RX) i skąd nadajesz głos (TX mikrofon).
Ustawienia zapisywane lokalnie w Twojej przeglądarce.
</div>
<div class="field">
<label data-i18n="profile_audio_out_lbl">GŁOŚNIK RX (odbiór radia → Twoje słuchawki)</label>
<div style="display:flex;gap:6px;">
<select id="profile-audio-out" onchange="ProfileAudio.setOutput(this.value)"
style="flex:1;font-family:var(--mono);font-size:12px;padding:6px;">
<option value="" data-i18n="profile_loading_option">— ładowanie... —</option>
</select>
<button onclick="ProfileAudio.testOutput()" data-i18n-title="profile_audio_test_out_title" title="Odtwórz ton testowy"
style="font-family:var(--mono);font-size:10px;padding:6px 12px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:3px;cursor:pointer;white-space:nowrap;">
🔉 TEST
</button>
</div>
</div>
<div class="field">
<label data-i18n="profile_audio_in_lbl">MIKROFON TX (Twój głos → radio)</label>
<div style="display:flex;gap:6px;">
<select id="profile-audio-in" onchange="ProfileAudio.setInput(this.value)"
style="flex:1;font-family:var(--mono);font-size:12px;padding:6px;">
<option value="" data-i18n="profile_loading_option">— ładowanie... —</option>
</select>
<button onclick="ProfileAudio.testInput()" data-i18n-title="profile_audio_test_in_title" title="Zmierz poziom mikrofonu (5s)"
style="font-family:var(--mono);font-size:10px;padding:6px 12px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:3px;cursor:pointer;white-space:nowrap;">
🎤 TEST
</button>
</div>
</div>
<div id="profile-audio-meter" style="display:none;">
<div data-i18n="profile_mic_level_lbl" style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:3px;">POZIOM MIKROFONU:</div>
<div style="height:14px;background:var(--panel3);border:1px solid var(--border);border-radius:3px;overflow:hidden;">
<div id="profile-audio-level" style="height:100%;width:0%;background:linear-gradient(90deg,var(--green),var(--amber),var(--red));transition:width 0.05s;"></div>
</div>
<div id="profile-audio-hint" style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:3px;"></div>
</div>
<div data-i18n="profile_audio_footnote" style="font-family:var(--mono);font-size:9px;color:var(--dim);line-height:1.4;padding-top:4px;border-top:1px solid var(--border);">
💡 Unikaj urządzeń "CABLE"/"Virtual" — to wirtualne kable dla programów (WSJT-X), nie Twój sprzęt.
</div>
</div>
</div>
<div class="box">
<div class="box-header" data-i18n="profile_theme_hdr">🎨 WYGLĄD (motyw kolorów)</div>
<div class="box-body" style="display:flex;flex-direction:column;gap:8px;">
<div data-i18n="profile_theme_desc" style="font-family:var(--mono);font-size:11px;color:var(--dim);line-height:1.5;">
Motyw zmienia kolory akcentów w interfejsie. Zapisywany lokalnie w przeglądarce.
</div>
<div class="field">
<label data-i18n="profile_theme_lbl">MOTYW</label>
<select id="profile-theme" onchange="UI.setTheme(this.value)"
style="font-family:var(--mono);font-size:12px;padding:6px;width:100%;">
<option value="default" data-i18n="profile_theme_opt_default">Zielony + bursztyn (klasyczny IC-7300)</option>
<option value="blue" data-i18n="profile_theme_opt_blue">Niebieski (nowoczesny)</option>
<option value="mono" data-i18n="profile_theme_opt_mono">Monochromatyczny (dyskretny)</option>
<option value="amber-classic" data-i18n="profile_theme_opt_amber">Bursztyn klasyczny (retro CRT)</option>
</select>
</div>
</div>
</div>
</div>
<div class="box">
<div class="box-header" data-i18n="profile_eq_hdr">🎤 TX MIKROFON — KOREKCJA AUDIO (EQ)</div>
<div class="box-body" style="display:flex;flex-direction:column;gap:10px;">
<div data-i18n="profile_eq_desc" style="font-family:var(--mono);font-size:11px;color:var(--dim);line-height:1.5;">
Korekcja audio dopasowuje sygnał mikrofonu do pasma SSB (300-2700 Hz).
Wybierz preset pasujący do Twojego głosu i sprawdź odsłuchem
jak będzie brzmieć w eterze. Ustawienia zapisują się lokalnie w przeglądarce.
</div>
<div class="field">
<label data-i18n="profile_eq_preset_lbl">PRESET EQ</label>
<select id="profile-eq-preset" onchange="TxEq.applyPreset(this.value)"
style="font-family:var(--mono);font-size:12px;padding:6px;width:100%;">
<option value="default" data-i18n="profile_eq_opt_default">Standardowy (zbalansowany)</option>
<option value="dark" data-i18n="profile_eq_opt_dark">Ciemny głos (męski/gruby) — więcej klarowności</option>
<option value="bright" data-i18n="profile_eq_opt_bright">Jasny głos (kobiecy/dziecięcy) — mniej wysokich</option>
<option value="dx" data-i18n="profile_eq_opt_dx">DX/Contest — maksymalny punch</option>
<option value="ragchew" data-i18n="profile_eq_opt_ragchew">Ragchew — naturalny, mniej agresywny</option>
<option value="flat" data-i18n="profile_eq_opt_flat">Flat (bez EQ, tylko limiter)</option>
<option value="custom" data-i18n="profile_eq_opt_custom">Custom (ręcznie)</option>
</select>
</div>
<div id="profile-eq-custom" style="display:none;flex-direction:column;gap:6px;border:1px solid var(--panel3);padding:8px;border-radius:4px;">
<div data-i18n="profile_eq_custom_hdr" style="font-family:var(--mono);font-size:10px;color:var(--amber);">CUSTOM EQ — ręczna regulacja</div>
<div class="field">
<label style="font-size:10px;"><span data-i18n="profile_eq_bass_lbl">BAS (200 Hz):</span> <span id="eq-bass-val">-3</span> dB</label>
<input type="range" id="eq-bass" min="-10" max="6" step="1" value="-3"
oninput="TxEq.setBand('bass', parseFloat(this.value))" style="width:100%;">
</div>
<div class="field">
<label style="font-size:10px;"><span data-i18n="profile_eq_mud_lbl">MUD (700 Hz):</span> <span id="eq-mud-val">-5</span> dB</label>
<input type="range" id="eq-mud" min="-10" max="0" step="1" value="-5"
oninput="TxEq.setBand('mud', parseFloat(this.value))" style="width:100%;">
</div>
<div class="field">
<label style="font-size:10px;"><span data-i18n="profile_eq_clarity_lbl">KLAROWNOŚĆ (1800 Hz):</span> <span id="eq-clarity-val">6</span> dB</label>
<input type="range" id="eq-clarity" min="0" max="12" step="1" value="6"
oninput="TxEq.setBand('clarity', parseFloat(this.value))" style="width:100%;">
</div>
<div class="field">
<label style="font-size:10px;"><span data-i18n="profile_eq_punch_lbl">PUNCH (2400 Hz):</span> <span id="eq-punch-val">8</span> dB</label>
<input type="range" id="eq-punch" min="0" max="12" step="1" value="8"
oninput="TxEq.setBand('punch', parseFloat(this.value))" style="width:100%;">
</div>
<div class="field">
<label style="font-size:10px;"><span data-i18n="profile_eq_air_lbl">POWIETRZE (2700 Hz):</span> <span id="eq-air-val">3</span> dB</label>
<input type="range" id="eq-air" min="0" max="8" step="1" value="3"
oninput="TxEq.setBand('air', parseFloat(this.value))" style="width:100%;">
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px;border:1px solid var(--green2);padding:8px;border-radius:4px;">
<div data-i18n="profile_eq_monitor_hdr" style="font-family:var(--mono);font-size:10px;color:var(--green);">🎧 ODSŁUCH (Monitor) — usłysz jak EQ zmienia Twój głos</div>
<div style="display:flex;gap:8px;">
<button id="eq-monitor-btn" onclick="TxEq.toggleMonitor()" data-i18n="profile_eq_monitor_start_btn"
style="flex:1;font-family:var(--mono);font-size:11px;padding:8px;background:var(--panel3);
border:1px solid var(--green2);color:var(--green);cursor:pointer;border-radius:4px;">
▶ START ODSŁUCH
</button>
</div>
<div data-i18n="profile_eq_monitor_desc" style="font-family:var(--mono);font-size:9px;color:var(--dim);">
Odsłuch przepuszcza mikrofon przez EQ i odtwarza w słuchawkach lokalnie
(nie nadaje przez radio). Używaj słuchawek żeby uniknąć sprzężenia!
</div>
<div style="display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:10px;">
<span data-i18n="profile_eq_monitor_vol_lbl">Głośność:</span>
<input type="range" id="eq-monitor-vol" min="0" max="1" step="0.05" value="0.3"
oninput="TxEq.setMonitorVol(parseFloat(this.value))" style="flex:1;">
<span id="eq-monitor-vol-val" style="width:40px;text-align:right;">0.30</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ─── PAGE: USTAWIENIA ─────────────────────────────────────────────────────── -->
<!-- ─── PAGE: KONFIGURACJA ───────────────────────────────────────────────────── -->
<div class="page-content" id="page-config">
<div class="page-col">
<!-- AUDIO RADIA - przeniesione z Ustawien - konfigurowane raz na start -->
<div class="box">
<div class="box-header">
<span data-i18n="cfg_audio_autodetect_hdr">🎙 AUDIO RADIA — AUTO-DETEKCJA</span>
<span id="audio-detect-badge" data-i18n="cfg_loading_dash" style="font-family:var(--mono);font-size:9px;padding:2px 6px;border-radius:3px;margin-left:8px;color:var(--dim);background:var(--panel3);">— ładowanie —</span>
</div>
<div style="padding:10px 12px;">
<div data-i18n-html="cfg_audio_autodetect_desc" style="font-family:var(--mono);font-size:10px;color:var(--dim);line-height:1.6;margin-bottom:10px;">
Serwer automatycznie wykrywa kartę audio radia (IC-7300, IC-705, FT-991 itp.) po nazwie.<br>
Standardowo nie musisz nic konfigurować — po podłączeniu radia przez USB karta zostanie znaleziona.
</div>
<div id="audio-detect-info" style="font-family:var(--mono);font-size:11px;padding:8px 10px;background:var(--panel3);border-radius:3px;border-left:3px solid var(--green2);margin-bottom:10px;">
<div style="color:var(--dim);" data-i18n="settings_loading">Ładowanie…</div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button onclick="AudioAutoDetect.rescan()" data-i18n="cfg_rescan_btn"
style="font-family:var(--mono);font-size:10px;padding:5px 12px;background:rgba(var(--green-rgb),0.1);border:1px solid var(--green2);color:var(--green);border-radius:3px;cursor:pointer;letter-spacing:1px;">
🔍 SKANUJ PONOWNIE
</button>
<button onclick="AudioAutoDetect.toggleExpert()" data-i18n="cfg_expert_mode_btn"
style="font-family:var(--mono);font-size:10px;padding:5px 12px;background:none;border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;letter-spacing:1px;">
⚙ TRYB EKSPERT (ręczny wybór karty)
</button>
</div>
</div>
</div>
<!-- SEKCJA RĘCZNEGO WYBORU KART - domyślnie ukryta, tylko dla ekspertów -->
<div class="box" id="audio-manual-config" style="display:none;">
<div class="box-header" data-i18n="cfg_manual_cards_hdr">⚙ KARTY DŹWIĘKOWE — RĘCZNIE (tryb ekspert)</div>
<div data-i18n="cfg_manual_cards_warn" style="padding:6px 12px;font-family:var(--mono);font-size:10px;color:var(--amber);border-bottom:1px solid var(--border);">
⚠ Zwykle nie potrzebujesz tego dotykać. Używaj gdy auto-detekcja nie znajduje karty lub masz kilka radi.
</div>
<div class="settings-inner">
<div class="sg"><label data-i18n="cfg_card_rx_lbl">Karta RX (radio → serwer)</label>
<select id="cfg-audio-rx" style="flex:1;font-family:var(--mono);font-size:11px;">
<option value="" data-i18n="cfg_default_dash">— domyślna —</option>
</select>
</div>
<div class="sg"><label data-i18n="cfg_card_tx_lbl">Karta TX (serwer → radio)</label>
<select id="cfg-audio-tx" style="flex:1;font-family:var(--mono);font-size:11px;">
<option value="" data-i18n="cfg_default_dash">— domyślna —</option>
</select>
</div>
<div class="sg"><label data-i18n="cfg_bitrate_lbl">Bitrate Opus</label>
<select id="cfg-audio-bitrate" style="width:120px;font-family:var(--mono);font-size:11px;">
<option value="12000">12 kbps</option>
<option value="16000">16 kbps</option>
<option value="24000" selected>24 kbps</option>
<option value="32000">32 kbps</option>
<option value="48000">48 kbps</option>
</select>
</div>
<div class="sg"><label>Status</label>
<span id="cfg-audio-status" style="font-family:var(--mono);font-size:10px;color:var(--dim);">—</span>
</div>
<div class="sg" style="grid-column:1/-1;"><label data-i18n="cfg_txvol_ft8_lbl">TX Volume FT8/FT4 (mnożnik tonu enkodera → radio)</label>
<div style="display:flex;align-items:center;gap:8px;flex:1">
<input type="range" id="cfg-tx-volume" min="0.1" max="16" step="0.1" value="4"
style="flex:1;accent-color:var(--red)"
oninput="document.getElementById('cfg-tx-volume-val').textContent=this.value+'x'">
<span id="cfg-tx-volume-val" style="width:35px;font-size:11px">4x</span>
</div>
</div>
<div class="sg" style="grid-column:1/-1;"><label><span data-i18n="cfg_gain_mode_ssb_lbl">Tryb wzmocnienia mikrofonu SSB</span>
<span style="color:var(--dim);font-size:9px;" data-i18n="cfg_gain_mode_ssb_sublabel">(globalne, dotyczy wszystkich operatorów — wybierz jedno z dwóch)</span></label>
<select id="cfg-tx-gain-mode-ssb" style="flex:1;font-family:var(--mono);font-size:11px;"
onchange="_txGainModeSsbToggle()">
<option value="manual" data-i18n="cfg_gain_mode_manual_opt">Stały mnożnik (suwak obok)</option>
<option value="agc" data-i18n="cfg_gain_mode_agc_opt">AGC (auto-wyrównanie poziomu)</option>
</select>
</div>
<div class="sg" style="grid-column:1/-1;"><label><span data-i18n="cfg_txvol_ssb_lbl">TX Volume SSB (mnożnik mikrofonu → radio)</span>
<span style="color:var(--dim);font-size:9px;" data-i18n="cfg_txvol_ssb_sublabel">(osobny od FT8/FT4 — inna charakterystyka sygnału, łatwiej trafić w ALC)</span></label>
<div style="display:flex;align-items:center;gap:8px;flex:1">
<input type="range" id="cfg-tx-volume-ssb" min="0.1" max="16" step="0.1" value="1"
style="flex:1;accent-color:var(--red)"
oninput="document.getElementById('cfg-tx-volume-ssb-val').textContent=this.value+'x'">
<span id="cfg-tx-volume-ssb-val" style="width:35px;font-size:11px">1x</span>
</div>
</div>
</div>
<div style="padding:0 12px 12px;display:flex;gap:8px;flex-wrap:wrap;">
<button class="save-btn" onclick="_audioDeviceSave()" data-i18n="cfg_save_apply_btn">💾 ZAPISZ I ZASTOSUJ (karty + TX Volume)</button>
<button class="save-btn" style="background:rgba(76,160,219,0.1);border-color:#4aa;"
onclick="_audioDeviceRefresh()" data-i18n="cfg_refresh_cards_btn">↺ ODŚWIEŻ KARTY</button>
</div>
</div>
<div class="box">
<div class="box-header">
<span data-i18n="cfg_rig_config_hdr">KONFIGURACJA RADIA</span>
<button id="btn-add-rig" onclick="Settings.addRig()" data-i18n="cfg_add_rig_btn"
style="background:none;border:1px solid var(--green2);border-radius:4px;
color:var(--green);font-family:var(--mono);font-size:10px;
padding:3px 10px;cursor:pointer;letter-spacing:1px;">+ DODAJ RADIO</button>
</div>
<div id="rigs-settings-container"></div>
</div>
<div class="box">
<div class="box-header"><span data-i18n="cfg_rotator_config_hdr">KONFIGURACJA ROTATORÓW</span><button class="admin-btn" onclick="Admin.addRotator()" data-i18n="cfg_add_btn">+ DODAJ</button></div>
<!-- Lokator STACJI — miejsce gdzie stoi antena. Od niego liczony jest
azymut na korespondenta (ten sam dla wszystkich operatorow). -->
<div style="padding:12px 12px 0;display:flex;align-items:center;gap:10px;
flex-wrap:wrap;border-bottom:1px solid #1a1a1a;padding-bottom:12px;">
<span style="font-size:11px;color:var(--dim);letter-spacing:1px;" data-i18n="cfg_station_locator_lbl">
LOKATOR STACJI</span>
<input id="cfg-station-locator" type="text" data-i18n-placeholder="cfg_station_locator_ph" placeholder="np. JO72"
maxlength="6" style="font-family:var(--mono);font-size:12px;
text-transform:uppercase;background:#0a0a0a;border:1px solid #2a2a2a;
color:#fa0;padding:4px 8px;border-radius:2px;width:90px;">
<button class="admin-btn" onclick="Admin.saveStationLocator()" data-i18n="settings_save_btn">ZAPISZ</button>
<span id="cfg-station-locator-msg" style="font-size:10px;color:var(--dim);"></span>
<div style="flex-basis:100%;font-size:10px;color:#666;" data-i18n="cfg_station_locator_desc">
Pozycja anteny — od niej liczony jest kierunek na korespondenta
(widoczny w logu QSO po wpisaniu znaku). Wspólny dla wszystkich
operatorów, niezależnie skąd się łączą.
</div>
</div>
<div style="padding:12px;" id="rotator-config-list" data-i18n="settings_loading">Ładowanie...</div>
<div style="padding:0 12px 12px;"><button class="save-btn" onclick="Admin.saveRotatorConfig()" data-i18n="cfg_save_restart_rotators_btn">ZAPISZ I ZRESTARTUJ ROTATORY</button></div>
</div>
<div class="box">
<div class="box-header"><span data-i18n="cfg_deepcw_hdr">DEKODER CW — MODEL DEEPCW</span></div>
<div style="padding:12px;font-size:11px;color:var(--dim);">
<span data-i18n="cfg_deepcw_desc">Sieciowy dekoder CW (~15 MB). Pobierany raz, zapisywany w katalogu
danych — przetrwa aktualizacje programu. Dekoder pracuje tylko gdy
okno dekodera jest otwarte.</span>
<div style="display:flex;align-items:center;gap:10px;margin-top:10px;">
<button class="admin-btn" onclick="Admin.deepcwDownload()" data-i18n="cfg_deepcw_download_btn">POBIERZ / AKTUALIZUJ MODEL</button>
<button class="admin-btn" onclick="Admin.deepcwStatus()" data-i18n="cfg_deepcw_check_btn">SPRAWDŹ STAN</button>
<span id="deepcw-admin-status" style="font-size:10px;color:var(--dim);"></span>
</div>
<div id="deepcw-admin-bar" style="display:none;margin-top:8px;height:4px;
background:#1a1a1a;border-radius:2px;overflow:hidden;">
<div class="deepcw-bar-fill" style="height:100%;width:0;background:var(--green);
transition:width .3s;"></div>
</div>
<div id="deepcw-admin-log" style="margin-top:6px;font-family:var(--mono);
font-size:10px;color:#666;"></div>
</div>
</div>
<div class="box">
<div class="box-header"><span data-i18n="cfg_rig_features_hdr">FUNKCJE RADIA — DOSTĘP DLA UŻYTKOWNIKÓW</span></div>
<div style="padding:12px;font-size:11px;color:var(--dim);" data-i18n="cfg_rig_features_desc">
Zaznacz funkcje dostępne dla zwykłych użytkowników (operator/viewer).
Funkcje wyszarzone nie są wspierane przez aktualnie podłączone radio.
</div>
<div style="padding:0 12px 12px;" id="rig-features-config-list" data-i18n="settings_loading">Ładowanie...</div>
<div style="padding:0 12px 12px;"><button class="save-btn" onclick="Admin.saveRigFeatures()" data-i18n="cfg_save_rig_features_btn">ZAPISZ FUNKCJE RADIA</button></div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
Ustawienia zaawansowane CI-V (admin-only, approved 2026-10-15) —
wypelniane generycznie przez advradio.js z rigs/civ_profiles.py's
"adv" table (GET/POST /api/rig/adv). Zmiana dziala na zywo na
podlaczonym radiu.
═══════════════════════════════════════════════════════════════════ -->
<div class="box">
<div class="box-header"><span data-i18n="adv_txdelay_hdr">TX DELAY — sekwencja PTT (wzmacniacz / przekaźnik antenowy)</span></div>
<div style="padding:4px 12px 12px;" id="adv-txdelay"></div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
COM BRIDGE — sekcja informacyjna (2 stale porty CI-V dla kazdego)
═══════════════════════════════════════════════════════════════════ -->
<div class="box" id="com-bridge-config-section">
<div class="box-header">
<span data-i18n="cfg_combridge_hdr">🔌 COM BRIDGE — zdalny dostęp CAT (CW Skimmer, HRD, Logger32)</span>
<span id="cb-client-status" data-i18n="cfg_combridge_no_client" style="font-family:var(--mono);font-size:9px;padding:2px 6px;border-radius:3px;background:var(--panel3);color:var(--dim);margin-left:8px;">— brak podłączonego klienta —</span>
</div>
<div data-i18n-html="cfg_combridge_desc" style="padding:12px;font-family:var(--mono);font-size:11px;color:var(--dim);line-height:1.7;">
Aplikacja <b style="color:var(--fg);">HAM RADIO CTRL</b> (klient Windows) tworzy wirtualne porty COM na Twoim
komputerze i łączy je ze zdalnym radiem przez internet. Programy jak
<b>CW Skimmer</b>, <b>Ham Radio Deluxe</b> czy <b>Logger32</b> widzą radio tak jakby było podłączone lokalnie.
</div>
<div class="box-body">
<!-- 2 dostepne porty -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px;">
<div style="padding:12px;background:var(--panel2);border:1px solid var(--green2);border-radius:6px;">
<div style="font-family:var(--mono);font-size:9px;color:var(--green);letter-spacing:1px;margin-bottom:4px;" data-i18n="cfg_combridge_port1_lbl">PORT 1 — pierwsza aplikacja</div>
<div style="font-family:var(--mono);font-size:18px;color:var(--fg);font-weight:bold;">COM11</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:6px;line-height:1.5;">
Icom IC-7300 · CI-V <b>0x94</b><br>19200 baud · 8N1
</div>
</div>
<div style="padding:12px;background:var(--panel2);border:1px solid var(--green2);border-radius:6px;">
<div style="font-family:var(--mono);font-size:9px;color:var(--green);letter-spacing:1px;margin-bottom:4px;" data-i18n="cfg_combridge_port2_lbl">PORT 2 — druga aplikacja</div>
<div style="font-family:var(--mono);font-size:18px;color:var(--fg);font-weight:bold;">COM21</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:6px;line-height:1.5;">
Icom IC-7300 · CI-V <b>0x94</b><br>19200 baud · 8N1
</div>
</div>
</div>
<div data-i18n-html="cfg_combridge_note" style="padding:10px 12px;background:rgba(var(--green-rgb),0.06);border-left:3px solid var(--green2);border-radius:4px;font-family:var(--mono);font-size:10px;color:var(--dim);line-height:1.7;margin-bottom:12px;">
<b style="color:var(--fg);">Masz 2 niezależne porty</b> — możesz uruchomić dwie aplikacje jednocześnie
(np. CW Skimmer na COM11 i HRD na COM21). Nie kolidują ze sobą ani z serwerem.<br>
<span style="color:var(--amber);">Uwaga:</span> numery COM na Twoim komputerze mogą się różnić —
sprawdź w oknie aplikacji HAM RADIO CTRL (kolumna „COM dla apki").
</div>
<!-- Krok po kroku -->
<details style="margin-bottom:12px;">
<summary style="cursor:pointer;font-family:var(--mono);font-size:11px;color:var(--green);padding:6px 0;" data-i18n="cfg_combridge_howto_summary">📖 Jak skonfigurować (kliknij)</summary>
<div data-i18n-html="cfg_combridge_steps" style="padding:10px 12px;font-family:var(--mono);font-size:10px;color:var(--dim);line-height:1.8;">
<b style="color:var(--fg);">1.</b> Pobierz i uruchom <b>Most COM</b> (przycisk poniżej)<br>
<b style="color:var(--fg);">2.</b> Wpisz adres serwera, swój login i hasło → <b>POŁĄCZ</b><br>
<b style="color:var(--fg);">3.</b> Aplikacja utworzy porty COM (przy pierwszym razie zezwól na UAC)<br>
<b style="color:var(--fg);">4.</b> W CW Skimmer / HRD wybierz <b>Icom IC-7300</b>, CI-V adres <b>94</b>, <b>19200</b> baud<br>
<b style="color:var(--fg);">5.</b> Wpisz numer COM z okna aplikacji (kolumna „COM dla apki")<br>
<b style="color:var(--fg);">6.</b> Connect — radio działa jakby było lokalne!
</div>
</details>
<!-- Link do pobrania EXE -->
<a href="/download/com-bridge" download data-i18n="cfg_combridge_download_btn"
style="display:flex;align-items:center;justify-content:center;gap:8px;padding:12px;background:rgba(var(--green-rgb),0.12);border:1px solid var(--green2);border-radius:6px;color:var(--green);text-decoration:none;font-family:var(--mono);font-size:12px;font-weight:bold;">
⬇️ POBIERZ MOST COM (Windows .exe)
</a>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);text-align:center;margin-top:6px;" data-i18n="cfg_combridge_winreq">
Wymaga Windows 10/11 · ~25 MB · zawiera sterownik com0com
</div>
</div>
</div>
<!-- Statystyki dla admin - lista podlaczonych klientow -->
<div class="box adminonly" id="cb-admin-stats-box" style="display:none;">
<div class="box-header">
<span data-i18n="cfg_combridge_stats_hdr">📊 COM BRIDGE — podłączeni klienci (ADMIN)</span>
<button onclick="ComBridge.loadStats()" data-i18n="cfg_refresh_btn" style="font-family:var(--mono);font-size:10px;padding:3px 10px;background:var(--panel3);border:1px solid var(--border2);border-radius:3px;color:var(--dim);cursor:pointer;">🔍 ODŚWIEŻ</button>
</div>
<div class="box-body">
<div id="cb-stats-list" style="font-family:var(--mono);font-size:11px;color:var(--dim);">
<span style="color:var(--dim);" data-i18n="cfg_combridge_stats_hint">Kliknij ODŚWIEŻ żeby zobaczyć podłączonych klientów.</span>
</div>
</div>
</div>
<!-- PASMA — WYBÓR DOSTĘPNYCH -->
<div class="box">
<div class="box-header" data-i18n="cfg_bands_hdr">PASMA — WIDOCZNE W UI</div>
<div data-i18n-html="cfg_bands_desc" style="padding:8px 12px;font-family:var(--mono);font-size:9px;color:var(--dim);border-bottom:1px solid var(--border);line-height:1.9;">
Wybierz które pasma będą widoczne użytkownikom w siatce PASMO.<br>
Zmiana obowiązuje natychmiast dla wszystkich połączonych klientów.
</div>
<div style="padding:12px;" id="admin-bands-container">
<div class="tw-widget" id="bands-transfer">
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:2px;margin-bottom:8px;" data-i18n="cfg_bands_widget_title">PASMA</div>
<div class="tw-body">
<div class="tw-col">
<div class="tw-col-header"><span data-i18n="tw_visible_lbl">Widoczne</span> <span class="tw-count" id="bands-transfer-sel-count">0</span></div>
<div class="tw-list" id="bands-transfer-selected" data-side="selected"></div>
<div class="tw-hint" data-i18n="tw_hint_remove">Kliknij dwukrotnie aby usunąć</div>
</div>
<div class="tw-buttons">
<button class="tw-btn" onclick="_twMoveAll('bands-transfer','selected')" data-i18n="tw_btn_all_left">«« Wszystkie</button>
<button class="tw-btn" onclick="_twMoveSelected('bands-transfer','selected')" data-i18n="tw_btn_add">« Dodaj</button>
<button class="tw-btn" onclick="_twMoveSelected('bands-transfer','available')" data-i18n="tw_btn_remove">Usuń »</button>
<button class="tw-btn" onclick="_twMoveAll('bands-transfer','available')" data-i18n="tw_btn_all_right">Wszystkie »»</button>
<div style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px;">
<button class="tw-btn" onclick="_twMoveUp('bands-transfer')" data-i18n="tw_btn_up">▲ Wyżej</button>
<button class="tw-btn" onclick="_twMoveDown('bands-transfer')" data-i18n="tw_btn_down">▼ Niżej</button>
</div>
</div>
<div class="tw-col">
<div class="tw-col-header"><span data-i18n="tw_available_lbl">Dostępne</span> <span class="tw-count" id="bands-transfer-avl-count">0</span></div>
<div class="tw-list" id="bands-transfer-available" data-side="available"></div>
<div class="tw-hint" data-i18n="tw_hint_add">Kliknij dwukrotnie aby dodać</div>
</div>
</div>
</div>
</div>
<div style="padding:0 12px 12px;">
<button class="save-btn" onclick="AdminBands.save()" data-i18n="cfg_save_bands_btn">ZAPISZ PASMA</button>
</div>
</div>
<!-- TRYBY — WYBÓR DOSTĘPNYCH + FILTR DLA USB-D -->
<div class="box">
<div class="box-header" data-i18n="cfg_modes_hdr">TRYBY PRACY — WIDOCZNE W UI + FILTRY</div>
<div data-i18n="cfg_modes_desc" style="padding:8px 12px;font-family:var(--mono);font-size:9px;color:var(--dim);border-bottom:1px solid var(--border);line-height:1.9;">
Wybierz tryby widoczne w siatce TRYB. USB-D/LSB-D = tryby cyfrowe (DATA) — można przypisać domyślny filtr.
</div>
<div style="padding:12px;">
<div class="tw-widget" id="modes-transfer">
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:2px;margin-bottom:8px;" data-i18n="cfg_modes_widget_title">TRYBY</div>
<div class="tw-body">
<div class="tw-col">
<div class="tw-col-header"><span data-i18n="tw_visible_lbl">Widoczne</span> <span class="tw-count" id="modes-transfer-sel-count">0</span></div>
<div class="tw-list" id="modes-transfer-selected" data-side="selected"></div>
<div class="tw-hint" data-i18n="tw_hint_remove">Kliknij dwukrotnie aby usunąć</div>
</div>
<div class="tw-buttons">
<button class="tw-btn" onclick="_twMoveAll('modes-transfer','selected')" data-i18n="tw_btn_all_left">«« Wszystkie</button>
<button class="tw-btn" onclick="_twMoveSelected('modes-transfer','selected')" data-i18n="tw_btn_add">« Dodaj</button>
<button class="tw-btn" onclick="_twMoveSelected('modes-transfer','available')" data-i18n="tw_btn_remove">Usuń »</button>
<button class="tw-btn" onclick="_twMoveAll('modes-transfer','available')" data-i18n="tw_btn_all_right">Wszystkie »»</button>
<div style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px;">
<button class="tw-btn" onclick="_twMoveUp('modes-transfer')" data-i18n="tw_btn_up">▲ Wyżej</button>
<button class="tw-btn" onclick="_twMoveDown('modes-transfer')" data-i18n="tw_btn_down">▼ Niżej</button>
</div>
</div>
<div class="tw-col">
<div class="tw-col-header"><span data-i18n="tw_available_lbl">Dostępne</span> <span class="tw-count" id="modes-transfer-avl-count">0</span></div>
<div class="tw-list" id="modes-transfer-available" data-side="available"></div>
<div class="tw-hint" data-i18n="tw_hint_add">Kliknij dwukrotnie aby dodać</div>
</div>
</div>
</div>
<!-- Przypisanie filtrów do trybów cyfrowych -->
<div style="margin-top:14px;border:1px solid var(--border);border-radius:4px;padding:10px 12px;">
<div style="font-family:var(--mono);font-size:9px;color:var(--amber);letter-spacing:2px;margin-bottom:10px;" data-i18n="cfg_mode_filter_hdr">
DOMYŚLNY FILTR DLA TRYBU
</div>
<div data-i18n-html="cfg_mode_filter_desc" style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:8px;line-height:1.8;">
Gdy użytkownik przełączy tryb, serwer automatycznie ustawi wybrany filtr.<br>
Puste = brak automatycznej zmiany filtra.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;" id="mode-filter-grid">
<!-- Wypełniane przez AdminBands.load() -->
</div>
</div>
</div>
<div style="padding:0 12px 12px;">
<button class="save-btn" onclick="AdminBands.saveModes()" data-i18n="cfg_save_modes_btn">ZAPISZ TRYBY I FILTRY</button>
</div>
</div>
</div>
</div>
<div class="page-content" id="page-settings">
<div class="page-col">
<!-- COM BRIDGE — zdalny dostep CAT (info + download dla wszystkich userow) -->
<div class="box" id="com-bridge-settings-card">
<div class="box-header" data-i18n="settings_combridge_hdr">🔌 ZDALNY DOSTĘP CAT — CW Skimmer, HRD, Logger32</div>
<div class="settings-inner">
<div data-i18n-html="settings_combridge_desc" style="font-family:var(--mono);font-size:11px;color:var(--dim);line-height:1.7;margin-bottom:12px;">
Pobierz aplikację <b style="color:var(--fg);">HAM RADIO CTRL</b> i używaj zdalnego radia
w programach CAT tak jakby było podłączone lokalnie. Masz <b style="color:var(--green);">2 niezależne porty</b> —
np. CW Skimmer i HRD jednocześnie.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px;">
<div style="padding:10px;background:var(--panel2);border:1px solid var(--green2);border-radius:6px;text-align:center;">
<div style="font-family:var(--mono);font-size:9px;color:var(--green);letter-spacing:1px;">PORT 1</div>
<div style="font-family:var(--mono);font-size:16px;color:var(--fg);font-weight:bold;">COM11</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;">IC-7300 · CI-V 94 · 19200</div>
</div>
<div style="padding:10px;background:var(--panel2);border:1px solid var(--green2);border-radius:6px;text-align:center;">
<div style="font-family:var(--mono);font-size:9px;color:var(--green);letter-spacing:1px;">PORT 2</div>
<div style="font-family:var(--mono);font-size:16px;color:var(--fg);font-weight:bold;">COM21</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;">IC-7300 · CI-V 94 · 19200</div>
</div>
</div>
<div data-i18n-html="settings_combridge_warn" style="padding:8px 10px;background:rgba(217,180,106,0.08);border-left:3px solid var(--amber);border-radius:4px;font-family:var(--mono);font-size:9px;color:var(--dim);line-height:1.6;margin-bottom:12px;">
<b style="color:var(--amber);">Uwaga:</b> zmiana częstotliwości/trybu przez CAT wymaga przejęcia radia (blokady).
Bez blokady możesz tylko <b>odczytywać</b> stan radia (podgląd freq w programie).
Numery COM na Twoim PC mogą się różnić — sprawdź w oknie aplikacji.
</div>
<a href="/download/com-bridge" download data-i18n="settings_combridge_download"
style="display:flex;align-items:center;justify-content:center;gap:8px;padding:12px;background:rgba(var(--green-rgb),0.12);border:1px solid var(--green2);border-radius:6px;color:var(--green);text-decoration:none;font-family:var(--mono);font-size:12px;font-weight:bold;">
⬇️ POBIERZ MOST COM (Windows)
</a>
<div data-i18n="settings_combridge_winreq" style="font-family:var(--mono);font-size:9px;color:var(--dim);text-align:center;margin-top:6px;">
Windows 10/11 · ~25 MB · zawiera com0com
</div>
</div>
</div>
<!-- CLOUDLOG / WAVELOG -->
<div class="box">
<div class="box-header" style="display:flex;align-items:center;gap:8px;">
<span>CLOUDLOG / WAVELOG</span>
<span id="cloudlog-status" data-i18n-title="settings_cloudlog_status_title"
style="width:10px;height:10px;border-radius:50%;background:var(--dim);
display:inline-block;flex-shrink:0;box-shadow:0 0 0 0 transparent;
transition:background 0.3s,box-shadow 0.3s;cursor:pointer;"
onclick="CloudLog.test()"></span>
<span id="cloudlog-status-text" data-i18n="settings_cloudlog_status_default" style="font-family:var(--mono);font-size:9px;
color:var(--dim);">nie sprawdzono</span>
</div>
<div class="settings-inner">
<div class="sg">
<label data-i18n="settings_cl_url_lbl">Adres serwera</label>
<input type="text" id="cl-url" placeholder="https://cloudlog.example.com"
style="flex:1;font-family:var(--mono);font-size:11px;">
</div>
<div class="sg">
<label data-i18n="settings_cl_apikey_qso_lbl">API Key — logowanie QSO</label>
<div style="display:flex;gap:6px;flex:1;">
<input type="text" id="cl-api-key-qso" data-i18n-placeholder="settings_cl_apikey_qso_ph" placeholder="klucz API dla QSO"
style="flex:1;font-family:var(--mono);font-size:11px;">
<input type="number" id="cl-station-id" data-i18n-placeholder="settings_cl_stationid_ph" placeholder="ID stacji" min="1"
style="width:80px;font-family:var(--mono);font-size:11px;">
</div>
</div>
<div class="sg">
<label data-i18n="settings_cl_apikey_radio_lbl">API Key — częstotliwość live</label>
<input type="text" id="cl-api-key-radio" data-i18n-placeholder="settings_cl_apikey_radio_ph" placeholder="klucz API dla live freq/mode"
style="flex:1;font-family:var(--mono);font-size:11px;">
</div>
<div class="sg">
<label>Live freq/mode</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:11px;">
<input type="checkbox" id="cl-live-enabled" onchange="CloudLog.setLive(this.checked)">
<span data-i18n="settings_cl_live_checkbox">Wysyłaj freq+mode automatycznie (co 5s)</span>
</label>
</div>
</div>
<div style="padding:0 12px 12px;display:flex;gap:8px;">
<button class="save-btn" style="flex:1;" onclick="CloudLog.save()" data-i18n="settings_save_btn">ZAPISZ</button>
<button class="save-btn" style="flex:0 0 auto;background:rgba(76,219,106,0.08);"
onclick="CloudLog.test()" data-i18n="settings_test_connection_btn">TEST POŁĄCZENIA</button>
</div>
</div>
<!-- WYSZUKIWANIE ZNAKÓW — QRZ.com / HamQTH.com, wlasne konto kazdego usera -->
<div class="box">
<div class="box-header" data-i18n="settings_lookup_hdr">🔍 WYSZUKIWANIE ZNAKÓW — QRZ.com / HamQTH</div>
<div class="settings-inner">
<div data-i18n-html="settings_lookup_desc" style="grid-column:1/-1;font-family:var(--mono);font-size:11px;color:var(--dim);line-height:1.5;">
Przy logowaniu QSO ikonka 🔍 przy polu ZNAK spróbuje pobrać imię/QTH/kraj
drugiej stacji — najpierw z QRZ.com (jeśli skonfigurowane), potem HamQTH.
Wymaga <b style="color:var(--fg);">Twojego własnego</b> konta w tych serwisach
(QRZ: płatna subskrypcja "XML Data" · HamQTH: darmowe konto).
</div>
<div class="sg" style="min-width:0;">
<label><span data-i18n="settings_qrz_login_lbl">QRZ.com — login</span>
<span id="cb-qrz-status" title="Status QRZ.com"
style="margin-left:4px;font-size:9px;color:var(--dim);"></span>
</label>
<input type="text" id="cb-qrz-user" data-i18n-placeholder="ph_qrz_login" placeholder="login QRZ.com"
style="width:100%;box-sizing:border-box;font-family:var(--mono);font-size:11px;">
</div>
<div class="sg" style="min-width:0;">
<label data-i18n="settings_qrz_pass_lbl">QRZ.com — hasło</label>
<div style="display:flex;gap:6px;min-width:0;">
<input type="password" id="cb-qrz-pass" data-i18n-placeholder="ph_qrz_pass" placeholder="hasło QRZ.com"
style="flex:1;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:11px;">
<button class="save-btn" style="flex:0 0 auto;width:auto;padding:0 10px;white-space:nowrap;"
onclick="Callbook.test('qrz')">TEST</button>
</div>
</div>
<div class="sg" style="min-width:0;">
<label><span data-i18n="settings_hamqth_login_lbl">HamQTH — login</span>
<span id="cb-hamqth-status" title="Status HamQTH"
style="margin-left:4px;font-size:9px;color:var(--dim);"></span>
</label>
<input type="text" id="cb-hamqth-user" data-i18n-placeholder="ph_hamqth_login" placeholder="login HamQTH"
style="width:100%;box-sizing:border-box;font-family:var(--mono);font-size:11px;">
</div>
<div class="sg" style="min-width:0;">
<label data-i18n="settings_hamqth_pass_lbl">HamQTH — hasło</label>
<div style="display:flex;gap:6px;min-width:0;">
<input type="password" id="cb-hamqth-pass" data-i18n-placeholder="ph_hamqth_pass" placeholder="hasło HamQTH"
style="flex:1;min-width:0;box-sizing:border-box;font-family:var(--mono);font-size:11px;">
<button class="save-btn" style="flex:0 0 auto;width:auto;padding:0 10px;white-space:nowrap;"
onclick="Callbook.test('hamqth')">TEST</button>
</div>
</div>
</div>
<div style="padding:0 12px 12px;display:flex;gap:8px;align-items:center;">
<button class="save-btn" style="flex:1;" onclick="Callbook.save()" data-i18n="settings_save_btn">ZAPISZ</button>
<span id="cb-save-msg" style="font-family:var(--mono);font-size:10px;color:var(--dim);"></span>
</div>
</div>
<!-- WIRTUALNE RADIO — emulacja rigctld dla zewnętrznych programów.
GLOBALNE/wspoldzielone (3 porty TCP dla calej stacji, nie per-user)
- w odroznieniu od CLOUDLOG/QRZ/HamQTH powyzej (kazde per-uid w
users.json). USTAWIENIA jest teraz widoczna dla kazdego usera
(data-perm="view" na zakladce), wiec ten JEDEN blok potrzebuje
wlasnego gate'u "settings" (backend juz i tak wymaga go w
/api/hamlib/config - to tylko dopasowanie widocznosci do tego,
co i tak jest wymuszane). -->
<div class="box" data-perm="settings">
<div class="box-header" data-i18n="settings_virtual_radio_hdr">WIRTUALNE RADIO — ZEWNĘTRZNE PROGRAMY</div>
<div data-i18n-html="settings_virtual_radio_desc" style="padding:8px 12px;font-family:var(--mono);font-size:9px;color:var(--dim);border-bottom:1px solid var(--border);line-height:2;">
Emulacja <b>Hamlib NET rigctl</b> (rigctld) — 3 niezależne serwery TCP.<br>
Każdy port to osobne "wirtualne radio" do którego podłączasz zewnętrzny program.<br>
<span style="color:var(--green);">WSJT-X / Log4OM / N1MM / CW Skimmer / DXKeeper / HRD:</span>
Rig → <b>Hamlib NET rigctl</b> → host: <b><IP serwera></b> → port: <b>4532</b>
</div>
<div id="hamlib-slots" style="padding:10px 12px;display:flex;flex-direction:column;gap:8px;">
<!-- Wypełniane przez JS -->
<div style="font-family:var(--mono);font-size:10px;color:var(--dim);" data-i18n="settings_loading">Ładowanie...</div>
</div>
<div style="padding:0 12px 12px;display:flex;gap:8px;align-items:center;">
<button class="save-btn" id="hamlib-save-btn" onclick="HamlibUI.save()" data-i18n="settings_virtual_radio_save_btn">ZAPISZ KONFIGURACJĘ</button>
<button class="save-btn" style="background:none;border-color:var(--border);color:var(--dim);"
onclick="HamlibUI.load()" data-i18n="settings_virtual_radio_refresh_btn">↻ ODŚWIEŻ STATUS</button>
<span id="hamlib-msg" style="font-family:var(--mono);font-size:10px;color:var(--dim);"></span>
</div>
<div style="padding:0 12px 12px;border-top:1px solid var(--border);">
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);line-height:2;margin-top:8px;">
<b style="color:var(--text);" data-i18n="settings_virtual_radio_howto_hdr">Jak podłączyć program zewnętrzny:</b><br>
WSJT-X: <span style="color:var(--amber)">Settings → Radio → Rig: Hamlib NET rigctl → Network server: <IP> Port: 4532</span><br>
Log4OM: <span style="color:var(--amber)">Settings → Rig → Hamlib NET rigctl → host: <IP> : 4533</span><br>
N1MM+: <span style="color:var(--amber)">Config → Configurer → Hamlib → NET rigctl → host:port</span><br>
CW Skimmer: <span style="color:var(--amber)">Settings → Rig → Hamlib NET → host: <IP> : 4534</span>
</div>
</div>
</div>
</div>
</div>
<!-- ─── PAGE: INTERNET ───────────────────────────────────────────────────────── -->
<div class="page-content" id="page-internet">
<div class="page-col">
<!-- ═══════════════════════════════════════════════════════════════════
KARTA #1 — STATUS (wszystko w jednym miejscu)
═══════════════════════════════════════════════════════════════════ -->
<div class="box">
<div class="box-header">
<span style="display:flex;align-items:center;gap:8px;">
<span id="tn-status-dot" style="width:10px;height:10px;border-radius:50%;background:var(--dim);display:inline-block;"></span>
<span data-i18n="in_status_hdr">STATUS DOSTĘPU ZDALNEGO</span>
</span>
<span id="tn-status-badge" style="font-family:var(--mono);font-size:11px;color:var(--dim);">○ WYŁĄCZONY</span>
</div>
<div class="box-body">
<!-- Adres publiczny (do udostępniania operatorom) -->
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<div style="flex:1;min-width:0;">
<div class="form-label" style="margin-bottom:2px;" data-i18n="in_public_addr_lbl">ADRES DO UDOSTĘPNIENIA</div>
<div id="tn-public-url" style="font-family:var(--mono);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">—</div>
</div>
<button onclick="Tunnel.copyPublic()" data-i18n-title="in_copy_title" title="Kopiuj do schowka" data-i18n="in_copy_btn"
style="font-family:var(--mono);font-size:10px;padding:6px 10px;background:var(--panel3);border:1px solid var(--border);border-radius:3px;color:var(--dim);cursor:pointer;white-space:nowrap;">
📋 KOPIUJ
</button>
</div>
<!-- Podsumowanie stanu jako pill'e (widoczne tylko gdy tryb wymaga dodatkowego stanu) -->
<div id="tn-detail-row" style="display:flex;gap:8px;flex-wrap:wrap;font-family:var(--mono);font-size:10px;">
<span id="tn-detail-cert" style="display:none;padding:3px 8px;background:var(--panel3);border-radius:3px;border:1px solid var(--border);">
<span data-i18n="in_cert_lbl">🔒 CERT:</span> <span id="tn-cert-days" style="color:var(--green);">—</span>
</span>
<span id="tn-detail-tunnel" style="display:none;padding:3px 8px;background:var(--panel3);border-radius:3px;border:1px solid var(--border);">
<span data-i18n="in_tunnel_lbl">🌐 TUNEL:</span> <span id="tn-duck-tunnel-status" style="color:var(--dim);">—</span>
</span>
<span id="tn-detail-local" style="padding:3px 8px;background:var(--panel3);border-radius:3px;border:1px solid var(--border);color:var(--dim);">
<span data-i18n="in_lan_lbl">🏠 LAN:</span> <span id="tn-local-url">—</span>
</span>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════
KARTA #2 — TRYB DOSTĘPU (wybór + konfiguracja)
═══════════════════════════════════════════════════════════════════ -->
<div class="box">
<div class="box-header">
<span data-i18n="in_mode_hdr">TRYB DOSTĘPU</span>
<span style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:1px;" data-i18n="in_autosave_note">zmiany zapisują się automatycznie</span>
</div>
<div class="box-body" style="display:flex;flex-direction:column;gap:12px;">
<input type="hidden" id="tn-mode" value="off">
<!-- Wszystkie 6 trybow na wierzchu, plaska siatka. POPRAWKA: reszta byla
schowana w zwinietym <details>"TRYBY ZAAWANSOWANE" - user (admin)
sam mial problem ze znalezieniem trybow spoza pierwszej trojki
("wyciagnij je na zewnatrz bo sam mialem problem z ich
znalezieniem"), wiec wszystkie sa teraz od razu widoczne. -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;">
<button class="tn-mode-btn" data-mode="off" onclick="Tunnel.modeChanged('off')"
style="background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:12px 10px;cursor:pointer;text-align:left;transition:all 0.15s;">
<div style="font-size:18px;margin-bottom:4px;">🏠</div>
<div style="font-family:var(--mono);font-size:11px;color:var(--text);font-weight:bold;" data-i18n="in_mode_home_title">TYLKO W DOMU</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;line-height:1.5;" data-i18n="in_mode_home_desc">
LAN. Nie wymaga konfiguracji.
</div>
</button>
<button class="tn-mode-btn" data-mode="quick" onclick="Tunnel.modeChanged('quick')"
style="background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:12px 10px;cursor:pointer;text-align:left;transition:all 0.15s;">
<div style="font-size:18px;margin-bottom:4px;">⚡</div>
<div style="font-family:var(--mono);font-size:11px;color:var(--text);font-weight:bold;" data-i18n="in_mode_quick_title">SZYBKI TUNEL</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;line-height:1.5;" data-i18n-html="in_mode_quick_desc">
Cloudflare bez rejestracji.
<span style="color:var(--amber);">Adres zmienny.</span>
</div>
</button>
<button class="tn-mode-btn" data-mode="duckdns" onclick="Tunnel.modeChanged('duckdns')"
style="background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:12px 10px;cursor:pointer;text-align:left;transition:all 0.15s;">
<div style="font-size:18px;margin-bottom:4px;">🦆</div>
<div style="font-family:var(--mono);font-size:11px;color:var(--text);font-weight:bold;" data-i18n="in_mode_duckdns_title">DUCKDNS + HTTPS</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;line-height:1.5;" data-i18n-html="in_mode_duckdns_desc">
Darmowa subdomena.
<span style="color:var(--green);">Stały adres.</span>
</div>
</button>
<button class="tn-mode-btn" data-mode="named" onclick="Tunnel.modeChanged('named')"
style="background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:12px 10px;cursor:pointer;text-align:left;transition:all 0.15s;">
<div style="font-size:18px;margin-bottom:4px;">🌐</div>
<div style="font-family:var(--mono);font-size:11px;color:var(--text);font-weight:bold;" data-i18n="in_mode_named_title">CLOUDFLARE + DOMENA</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;line-height:1.5;" data-i18n="in_mode_named_desc">
Nazwana subdomena własnej domeny.
</div>
</button>
<button class="tn-mode-btn" data-mode="staticip" onclick="Tunnel.modeChanged('staticip')"
style="background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:12px 10px;cursor:pointer;text-align:left;transition:all 0.15s;">
<div style="font-size:18px;margin-bottom:4px;">🔒</div>
<div style="font-family:var(--mono);font-size:11px;color:var(--text);font-weight:bold;" data-i18n="in_mode_staticip_title">STAŁE IP + HTTPS</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;line-height:1.5;" data-i18n="in_mode_staticip_desc">
Publiczne stałe IP + self-signed cert.
</div>
</button>
<button class="tn-mode-btn" data-mode="customcert" onclick="Tunnel.modeChanged('customcert')"
style="background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:12px 10px;cursor:pointer;text-align:left;transition:all 0.15s;">
<div style="font-size:18px;margin-bottom:4px;">🔐</div>
<div style="font-family:var(--mono);font-size:11px;color:var(--text);font-weight:bold;" data-i18n="in_mode_customcert_title">WŁASNA DOMENA + CERT</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;line-height:1.5;" data-i18n="in_mode_customcert_desc">
Masz już domenę i wykupiony certyfikat.
</div>
</button>
</div>
<!-- POLA — Cloudflare named -->
<div id="tn-named-fields" style="display:none;padding:12px;background:var(--panel2);border:1px solid var(--border);border-radius:5px;">
<div style="font-family:var(--mono);font-size:10px;color:var(--amber);margin-bottom:8px;" data-i18n="in_named_instructions">
📋 dash.cloudflare.com → Zero Trust → Tunnels → Create tunnel → skopiuj token
</div>
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_your_domain_lbl">TWOJA DOMENA</div>
<input type="text" id="tn-hostname" placeholder="radio.twojadomena.pl" oninput="Tunnel.saveTunnelConfig(false)" style="margin-bottom:8px;">
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_cf_token_lbl">TOKEN CLOUDFLARE</div>
<input type="password" id="tn-token" placeholder="eyJhbGci…" autocomplete="off" oninput="Tunnel.saveTunnelConfig(false)">
</div>
<!-- POLA — DuckDNS -->
<div id="tn-duckdns-fields" style="display:none;padding:12px;background:var(--panel2);border:1px solid var(--border);border-radius:5px;">
<div style="font-family:var(--mono);font-size:10px;color:var(--amber);margin-bottom:8px;" data-i18n-html="in_duckdns_instructions">
📋 <a href="https://www.duckdns.org" target="_blank" style="color:var(--green);">duckdns.org</a> → zaloguj się → utwórz subdomenę → skopiuj token
</div>
<div style="display:grid;grid-template-columns:1fr 2fr;gap:8px;margin-bottom:8px;">
<div>
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_subdomain_lbl">SUBDOMENA</div>
<input type="text" id="tn-duck-domain" placeholder="mojradio" oninput="Tunnel.saveTunnelConfig(false)">
</div>
<div>
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_token_lbl">TOKEN</div>
<input type="password" id="tn-duck-token" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" autocomplete="off" oninput="Tunnel.saveTunnelConfig(false)">
</div>
</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:10px;">
<span data-i18n="in_your_address_lbl">Twój adres:</span> <span id="tn-duck-preview" style="color:var(--green);">mojradio.duckdns.org</span>
</div>
<div style="display:flex;gap:8px;">
<button onclick="Tunnel.genCert()" data-i18n="in_gen_cert_btn"
style="flex:1;font-family:var(--mono);font-size:10px;padding:6px 10px;
background:rgba(var(--green-rgb),0.08);border:1px solid var(--green2);
border-radius:4px;color:var(--green);cursor:pointer;">
🔒 GENERUJ CERTYFIKAT
</button>
</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:6px;" data-i18n-html="in_letsencrypt_note">
Cert Let's Encrypt (90 dni, auto-renew). Wymaga otwartego portu 80 lub 443.
Wymaga zainstalowanego <a href="https://www.win-acme.com/" target="_blank" style="color:var(--green);">win-acme</a> (bez instalatora — rozpakuj do C:\win-acme\).
</div>
</div>
<!-- POLA — Stałe IP -->
<div id="tn-staticip-fields" style="display:none;padding:12px;background:var(--panel2);border:1px solid var(--border);border-radius:5px;">
<div style="display:grid;grid-template-columns:2fr 1fr;gap:8px;">
<div>
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_your_static_ip_lbl">TWOJE STAŁE IP</div>
<input type="text" id="tn-static-ip" placeholder="85.123.45.67" oninput="Tunnel.saveTunnelConfig(false)">
</div>
<div>
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_port_lbl">PORT</div>
<input type="text" id="tn-static-port" placeholder="8001" value="8001" oninput="Tunnel.saveTunnelConfig(false)">
</div>
</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:6px;" data-i18n="in_selfsigned_note">
Cert SSL self-signed generowany automatycznie. Przeglądarka ostrzeże raz — kliknij "Zaawansowane → Przejdź dalej".
</div>
</div>
<!-- POLA — Własna domena + własny certyfikat -->
<div id="tn-customcert-fields" style="display:none;padding:12px;background:var(--panel2);border:1px solid var(--border);border-radius:5px;">
<div style="font-family:var(--mono);font-size:10px;color:var(--amber);margin-bottom:8px;" data-i18n="in_customcert_instructions">
📋 Ten tryb NICZEGO nie generuje — musisz już mieć domenę (DNS wskazujący na Twoje stałe IP/router) i gotowy certyfikat SSL (plik cert.pem + key.pem, np. z zewnętrznego CA).
</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:8px;margin-bottom:8px;">
<div>
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_your_domain_lbl">TWOJA DOMENA</div>
<input type="text" id="tn-custom-hostname" placeholder="radio.twojadomena.pl" oninput="Tunnel.saveTunnelConfig(false)">
</div>
<div>
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_port_lbl">PORT</div>
<input type="text" id="tn-custom-port" placeholder="8001" value="8001" oninput="Tunnel.saveTunnelConfig(false)">
</div>
</div>
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_certpath_lbl">ŚCIEŻKA DO cert.pem (opcjonalnie)</div>
<input type="text" id="tn-custom-certpath" data-i18n-placeholder="in_leave_empty_ph" placeholder="zostaw puste = domyślny katalog danych serwera" oninput="Tunnel.saveTunnelConfig(false)" style="margin-bottom:8px;">
<div class="form-label" style="margin-bottom:4px;" data-i18n="in_keypath_lbl">ŚCIEŻKA DO key.pem (opcjonalnie)</div>
<input type="text" id="tn-custom-keypath" data-i18n-placeholder="in_leave_empty_ph" placeholder="zostaw puste = domyślny katalog danych serwera" oninput="Tunnel.saveTunnelConfig(false)">
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:6px;" data-i18n-html="in_customcert_note">
Zostawiając ścieżki puste, wystarczy podmienić pliki <code>cert.pem</code> i <code>key.pem</code> w katalogu danych serwera (obok EXE albo %APPDATA%\HAMCTRL) — reszta zadzieje się automatycznie.
</div>
</div>
<!-- Diagnostyka cloudflared - pokazywana tylko gdy tryb Cloudflare -->
<div id="tn-cf-status" style="display:none;padding:6px 10px;background:var(--panel2);border:1px solid var(--border);border-radius:4px;font-family:var(--mono);font-size:10px;color:var(--dim);align-items:center;gap:10px;flex-wrap:wrap;">
<span id="tn-cf-version" data-i18n="in_cf_checking">cloudflared: sprawdzam...</span>
<span id="tn-cf-stale" style="color:var(--amber);display:none;"></span>
<span id="tn-cf-svc" style="color:var(--amber);display:none;" data-i18n="in_cf_svc_active">⚠ Usługa Windows aktywna</span>
<button onclick="Tunnel.cleanup()" data-i18n="wj_clear_btn" style="margin-left:auto;font-family:var(--mono);font-size:10px;
padding:3px 12px;background:rgba(var(--red-rgb),0.1);border:1px solid rgba(var(--red-rgb),0.3);
border-radius:3px;color:var(--red);cursor:pointer;">🗑 WYCZYŚĆ</button>
<button onclick="Tunnel.checkCF()" data-i18n="in_check_btn" style="font-family:var(--mono);font-size:10px;
padding:3px 12px;background:var(--panel3);border:1px solid var(--border2);
border-radius:3px;color:var(--dim);cursor:pointer;">🔍 SPRAWDŹ</button>
</div>
<!-- Akcja i autostart w jednym rzędzie -->
<div style="display:flex;gap:8px;align-items:center;">
<button id="tn-start-btn" onclick="Tunnel.startTunnel()" data-i18n="in_start_btn"
style="flex:1;background:rgba(var(--green-rgb),0.12);border:1px solid var(--green2);border-radius:5px;color:var(--green);font-family:var(--mono);font-size:12px;padding:12px;cursor:pointer;letter-spacing:1px;font-weight:bold;">
▶ URUCHOM
</button>
<button onclick="Tunnel.stopTunnel()" data-i18n="in_stop_btn"
style="background:rgba(var(--red-rgb),0.08);border:1px solid rgba(var(--red-rgb),0.3);border-radius:5px;color:var(--red);font-family:var(--mono);font-size:12px;padding:12px 20px;cursor:pointer;">
■ ZATRZYMAJ
</button>
<label style="display:flex;align-items:center;gap:6px;padding:0 10px;font-family:var(--mono);font-size:10px;color:var(--dim);cursor:pointer;white-space:nowrap;">
<input type="checkbox" id="tn-autostart" style="width:14px;height:14px;cursor:pointer;accent-color:var(--green);" onchange="Tunnel.saveTunnelConfig(false)">
Auto-start
</label>
</div>
</div>
</div>
</div>
</div>
<!-- ─── PAGE: ADMIN ──────────────────────────────────────────────────────────── -->
<!-- ─── PAGE: DX CLUSTER ─────────────────────────────────────────────────────── -->
<div class="page-content" id="page-dxcluster" style="height:100%;">
<div class="page-col" style="max-width:100%;height:100%;display:flex;flex-direction:column;gap:6px;overflow:hidden;">
<!-- PASEK GÓRNY: status + filtry + akcje (jeden wiersz) -->
<div class="box" style="flex-shrink:0;">
<div style="padding:6px 10px;display:flex;flex-wrap:wrap;align-items:center;gap:8px;">
<span style="font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:1px;" data-i18n="dx_hdr">📡 DX CLUSTER</span>
<span id="dx-status-badge" style="font-family:var(--mono);font-size:9px;padding:2px 6px;border-radius:3px;background:var(--panel3);color:var(--dim);">○ ROZŁĄCZONY</span>
<span style="width:1px;height:16px;background:var(--border);margin:0 4px;"></span>
<button id="dx-connect-btn" onclick="DXCluster.connect()" data-i18n="dx_connect_btn"
style="font-family:var(--mono);font-size:10px;padding:3px 10px;background:rgba(var(--green-rgb),0.1);border:1px solid var(--green2);color:var(--green);border-radius:3px;cursor:pointer;letter-spacing:1px;">
▶ POŁĄCZ
</button>
<button id="dx-disconnect-btn" onclick="DXCluster.disconnect()" style="display:none;
font-family:var(--mono);font-size:10px;padding:3px 10px;background:rgba(var(--red-rgb),0.1);border:1px solid rgba(var(--red-rgb),0.4);color:var(--red);border-radius:3px;cursor:pointer;letter-spacing:1px;" data-i18n="dx_disconnect_btn">
✕ ROZŁĄCZ
</button>
<button onclick="DXCluster.toggleConfig()" data-i18n-title="dx_config_title" title="Pokaż/ukryj konfigurację" data-i18n="dx_config_btn"
style="font-family:var(--mono);font-size:10px;padding:3px 8px;background:var(--panel3);border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;">
⚙ KONFIG
</button>
<button id="dx-spot-btn" onclick="DXCluster.openSpotDialog()" data-i18n-title="dx_send_spot_title" title="Wyślij spota na klaster (wypełni aktualną częstotliwość i tryb)" data-i18n="dx_send_spot_btn"
style="font-family:var(--mono);font-size:10px;padding:3px 10px;background:rgba(212,153,34,0.12);border:1px solid rgba(212,153,34,0.45);color:var(--amber);border-radius:3px;cursor:pointer;letter-spacing:1px;">
📤 WYŚLIJ SPOT
</button>
<span style="width:1px;height:16px;background:var(--border);margin:0 4px;"></span>
<label style="font-family:var(--mono);font-size:9px;color:var(--dim);" data-i18n="dx_band_lbl">PASMO:</label>
<select id="dx-filter-band" onchange="DXCluster.renderSpots()"
style="font-family:var(--mono);font-size:10px;padding:2px 4px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:2px;">
<option value="" data-i18n="dx_all_bands">wszystkie</option>
<option value="160m">160m</option><option value="80m">80m</option>
<option value="60m">60m</option><option value="40m">40m</option>
<option value="30m">30m</option><option value="20m">20m</option>
<option value="17m">17m</option><option value="15m">15m</option>
<option value="12m">12m</option><option value="10m">10m</option>
<option value="6m">6m</option><option value="2m">2m</option>
<option value="70cm">70cm</option>
</select>
<label style="font-family:var(--mono);font-size:9px;color:var(--dim);" data-i18n="dx_mode_lbl">TRYB:</label>
<select id="dx-filter-mode" onchange="DXCluster.renderSpots()"
style="font-family:var(--mono);font-size:10px;padding:2px 4px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:2px;">
<option value="" data-i18n="dx_all_bands">wszystkie</option>
<option value="SSB">SSB</option><option value="CW">CW</option>
<option value="FT8">FT8</option><option value="FT4">FT4</option>
<option value="RTTY">RTTY</option><option value="DIGI">DIGI</option>
</select>
<input type="text" id="dx-filter-call" oninput="DXCluster.renderSpots()"
data-i18n-placeholder="dx_search_ph" placeholder="szukaj (call/spotter)"
style="font-family:var(--mono);font-size:10px;padding:2px 6px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:2px;width:150px;">
<span style="margin-left:auto;font-family:var(--mono);font-size:9px;color:var(--dim);">
<span data-i18n="dx_spots_lbl">Spoty:</span> <span id="dx-spot-count" style="color:var(--green);">0</span>
</span>
<button onclick="DXCluster.clearSpots()" data-i18n-title="dx_clear_list_title" title="Wyczyść listę"
style="font-family:var(--mono);font-size:10px;padding:2px 8px;background:none;border:1px solid var(--border);color:var(--dim);border-radius:2px;cursor:pointer;">
🗑
</button>
</div>
<!-- KONFIGURACJA (zwijana - domyslnie schowana) -->
<div id="dx-config-panel" style="display:none;border-top:1px solid var(--border);padding:8px 10px;background:var(--panel2);">
<div data-i18n-html="dx_config_desc" style="font-family:var(--mono);font-size:9px;color:var(--dim);line-height:1.4;margin-bottom:6px;">
Adres serwera Telnet (np. <code>dxcluster.sq3mzm.ovh:7300</code>). Login = znak. Hasło opcjonalne.
</div>
<div style="display:grid;grid-template-columns:2fr 80px 1fr 1fr auto;gap:6px;align-items:end;">
<div>
<label style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:1px;" data-i18n="dx_address_lbl">ADRES</label>
<input type="text" id="dx-host" placeholder="dxcluster.sq3mzm.ovh"
style="width:100%;font-family:var(--mono);font-size:11px;padding:4px 6px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:2px;">
</div>
<div>
<label style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:1px;" data-i18n="dx_port_lbl">PORT</label>
<input type="number" id="dx-port" value="7300" min="1" max="65535"
style="width:100%;font-family:var(--mono);font-size:11px;padding:4px 6px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:2px;">
</div>
<div>
<label style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:1px;" data-i18n="dx_login_lbl">LOGIN</label>
<input type="text" id="dx-login" placeholder="SP3GSK" maxlength="12"
style="width:100%;font-family:var(--mono);font-size:11px;padding:4px 6px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:2px;">
</div>
<div>
<label style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:1px;" data-i18n="dx_password_lbl">HASŁO</label>
<input type="password" id="dx-password" data-i18n-placeholder="dx_optional_ph" placeholder="opcjonalne" autocomplete="new-password"
style="width:100%;font-family:var(--mono);font-size:11px;padding:4px 6px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:2px;">
</div>
<button onclick="DXCluster.save()" class="save-btn" data-i18n="dx_save_btn"
style="font-family:var(--mono);font-size:10px;padding:5px 12px;letter-spacing:1px;">💾 ZAPISZ</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;">
<label style="font-family:var(--mono);font-size:10px;color:var(--dim);display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="dx-auto-connect" style="width:12px;height:12px;">
<span data-i18n="dx_autoconnect_lbl">Auto-łącz przy logowaniu</span>
</label>
<span id="dx-save-status" style="font-family:var(--mono);font-size:9px;color:var(--dim);"></span>
</div>
</div>
</div>
<!-- LISTA SPOTÓW - flex:1 zajmuje cala pozostala przestrzen -->
<div class="box" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div style="flex:1;overflow-y:auto;">
<table class="admin-table" style="width:100%;">
<thead style="position:sticky;top:0;background:var(--panel);z-index:1;">
<tr>
<th style="width:60px;font-size:9px;">UTC</th>
<th style="width:90px;font-size:9px;">MHz</th>
<th style="width:110px;font-size:9px;" data-i18n="dx_th_call">CALL (DX)</th>
<th style="width:50px;font-size:9px;">BAND</th>
<th style="width:50px;font-size:9px;" data-i18n="dx_th_mode">TRYB</th>
<th style="font-size:9px;" data-i18n="log_th_comment">KOMENTARZ</th>
<th style="width:100px;font-size:9px;">SPOTTER</th>
<th style="width:50px;font-size:9px;">QSY</th>
</tr>
</thead>
<tbody id="dx-spot-list">
<tr><td colspan="8" style="text-align:center;padding:20px;color:var(--dim);font-family:var(--mono);font-size:10px;" data-i18n-html="dx_empty_initial">
Brak spotów. Kliknij <strong>⚙ KONFIG</strong> żeby wpisać adres serwera, potem <strong>▶ POŁĄCZ</strong>.
</td></tr>
</tbody>
</table>
</div>
<!-- CLI - kompaktowy pasek na dole -->
<div style="flex-shrink:0;border-top:1px solid var(--border);padding:4px 8px;display:flex;gap:6px;align-items:center;background:var(--panel2);">
<span style="font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:1px;" data-i18n="dx_cmd_lbl">CMD:</span>
<input type="text" id="dx-cmd" placeholder="sh/dx 20m, set/qth JO72..."
onkeydown="if(event.key==='Enter')DXCluster.sendCommand()"
style="flex:1;font-family:var(--mono);font-size:10px;padding:3px 6px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:2px;">
<button onclick="DXCluster.sendCommand()"
style="font-family:var(--mono);font-size:10px;padding:3px 10px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:2px;cursor:pointer;">▶</button>
</div>
</div>
</div>
</div>
<!-- ─── PAGE: ADMIN ─────────────────────────────────────────────────────────── -->
<div class="page-content" id="page-admin">
<div class="page-col">
<!-- STATUS SERWERA + DIAGNOSTYKA -->
<div class="box">
<div class="box-header"><span data-i18n="adm_status_hdr">📊 STATUS SERWERA</span>
<button class="admin-btn" onclick="AdminStatus.refresh()" data-i18n="adm_refresh_btn">↻ ODŚWIEŻ</button>
</div>
<div id="admin-status-body" style="padding:10px 12px;font-family:var(--mono);font-size:11px;">
<div style="color:var(--dim);text-align:center;padding:10px;" data-i18n="adm_loading_status">Ładowanie statusu...</div>
</div>
<div style="padding:0 12px 10px;display:flex;gap:8px;">
<button onclick="AdminStatus.testCiv()" data-i18n="adm_test_civ_btn"
style="font-family:var(--mono);font-size:10px;padding:5px 12px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:3px;cursor:pointer;">
🔌 TEST CI-V
</button>
<span id="admin-civ-test-result" style="font-family:var(--mono);font-size:10px;color:var(--dim);align-self:center;"></span>
</div>
</div>
<!-- BACKUP / RESTORE -->
<div class="box">
<div class="box-header" data-i18n="adm_backup_hdr">💾 BACKUP / RESTORE KONFIGURACJI</div>
<div data-i18n-html="adm_backup_desc" style="padding:8px 12px;font-family:var(--mono);font-size:9px;color:var(--dim);line-height:1.5;border-bottom:1px solid var(--border);">
Backup zawiera <b>config.json</b> (radia, pasma, przekaźniki, SMTP) oraz <b>users.json</b> (konta, uprawnienia, hasła).<br>
Trzymaj plik w bezpiecznym miejscu — zawiera hasła. Po restore zrestartuj serwer.
</div>
<div style="padding:10px 12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button onclick="AdminStatus.downloadBackup()" class="save-btn" data-i18n="adm_download_backup_btn"
style="font-family:var(--mono);font-size:11px;padding:6px 14px;">⬇ POBIERZ BACKUP</button>
<label style="font-family:var(--mono);font-size:11px;padding:6px 14px;background:var(--panel3);border:1px solid var(--border);color:var(--fg);border-radius:3px;cursor:pointer;">
<span data-i18n="adm_upload_backup_btn">⬆ WCZYTAJ BACKUP</span>
<input type="file" accept=".json" style="display:none;" onchange="AdminStatus.uploadBackup(this)">
</label>
<span id="admin-backup-status" style="font-family:var(--mono);font-size:10px;color:var(--dim);"></span>
</div>
</div>
<div class="box">
<div class="box-header"><span data-i18n="adm_users_hdr">UŻYTKOWNICY SYSTEMU</span><button class="admin-btn" onclick="Admin.showAddUser()" data-i18n="adm_new_user_btn">+ NOWY UŻYTKOWNIK</button></div>
<div style="overflow:auto;">
<table class="admin-table">
<thead><tr><th data-i18n="adm_th_login_name">LOGIN / IMIĘ</th><th data-i18n="adm_th_role">ROLA</th><th data-i18n="adm_th_callsign">ZNAK</th><th data-i18n="adm_th_perms">UPRAWNIENIA</th><th data-i18n="adm_th_status">STATUS</th><th data-i18n="log_th_actions">AKCJE</th></tr></thead>
<tbody id="users-tbody"><tr><td colspan="6" style="text-align:center;padding:20px;color:var(--dim);" data-i18n="settings_loading">Ładowanie...</td></tr></tbody>
</table>
</div>
</div>
<!-- TIMER FT8 — ustawienia per user (tylko admin) -->
<div class="box">
<div class="box-header" data-i18n="adm_ft8timer_hdr">TIMER BEZPIECZEŃSTWA FT8/HOUND — GLOBALNY</div>
<div data-i18n-html="adm_ft8timer_desc" style="padding:8px 12px;font-family:var(--mono);font-size:9px;color:var(--dim);
border-bottom:1px solid var(--border);line-height:1.8;">
Maksymalny czas ciągłego nadawania FT8/FT4/Hound bez potwierdzenia operatora.<br>
Po upływie czasu — TX zostaje automatycznie zatrzymany. Dotyczy wszystkich użytkowników. Zakres: 1–60 min.
</div>
<div style="padding:12px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<span style="font-family:var(--mono);font-size:11px;color:var(--dim);" data-i18n="adm_timer_duration_lbl">CZAS TIMERA:</span>
<input type="number" min="1" max="60" value="6" id="ft8t-global-dur"
style="width:60px;font-family:var(--mono);font-size:13px;padding:4px 8px;
background:var(--panel3);border:1px solid var(--border2);border-radius:3px;
color:var(--green);text-align:center;">
<span style="font-family:var(--mono);font-size:11px;color:var(--dim);" data-i18n="adm_minutes_word">minut</span>
<button onclick="Admin.saveGlobalFt8Timer()" data-i18n="settings_save_btn"
style="font-family:var(--mono);font-size:11px;padding:5px 16px;
background:rgba(76,219,106,0.1);border:1px solid var(--green2);
border-radius:3px;color:var(--green);cursor:pointer;">ZAPISZ</button>
<span id="ft8t-global-status" style="font-family:var(--mono);font-size:10px;color:var(--dim);"></span>
</div>
</div>
<div class="box">
<div class="box-header" data-i18n="adm_myprofile_hdr">MÓJ PROFIL</div>
<div class="box-body change-pwd-form">
<div class="field"><label data-i18n="profile_name_lbl">IMIĘ</label>
<input type="text" id="admin-profile-name" placeholder="Jan" autocomplete="name"></div>
<div class="field"><label data-i18n="profile_callsign_lbl">ZNAK WYWOŁAWCZY</label>
<input type="text" id="admin-profile-callsign" placeholder="SP0ABC" style="text-transform:uppercase;" autocomplete="off"></div>
<div class="field"><label data-i18n="profile_locator_lbl">LOKATOR QTH (np. JO82)</label>
<input type="text" id="admin-profile-locator" placeholder="JO82" maxlength="8" style="text-transform:uppercase;" autocomplete="off"></div>
<div class="field"><label>E-MAIL</label>
<input type="email" id="admin-profile-email" placeholder="user@example.com" autocomplete="email"></div>
<button class="save-btn" onclick="saveProfile('admin-profile')" data-i18n="adm_save_profile_btn">ZAPISZ PROFIL</button>
<span id="admin-profile-status" style="font-family:var(--mono);font-size:10px;color:var(--dim);margin-left:10px;"></span>
</div>
</div>
<div class="box">
<div class="box-header" data-i18n="adm_change_pwd_hdr">ZMIANA WŁASNEGO HASŁA</div>
<div class="box-body change-pwd-form">
<div class="field"><label data-i18n="profile_old_pwd_lbl">STARE HASŁO</label><input type="password" id="old-pwd" autocomplete="current-password"></div>
<div class="field"><label data-i18n="profile_new_pwd_lbl">NOWE HASŁO (min. 8 znaków)</label><input type="password" id="new-pwd" autocomplete="new-password"></div>
<div class="field"><label data-i18n="profile_new_pwd2_lbl">POWTÓRZ NOWE HASŁO</label><input type="password" id="new-pwd2" autocomplete="new-password"></div>
<button class="save-btn" onclick="changeOwnPassword()" data-i18n="adm_change_pwd_btn">ZMIEŃ HASŁO</button>
</div>
</div>
<!-- RADIO LOCK SETTINGS -->
<div class="box">
<div class="box-header" data-i18n="adm_lock_settings_hdr">BLOKADA RADIA — USTAWIENIA</div>
<div class="box-body">
<div class="sg" style="margin-bottom:12px;">
<label data-i18n="adm_lock_timeout_lbl">TIMEOUT BEZCZYNNOŚCI (minuty)</label>
<div style="display:flex;align-items:center;gap:10px;">
<input type="range" id="lock-timeout" min="1" max="480" step="1" value="20"
style="flex:1;accent-color:var(--green);"
oninput="document.getElementById('lock-timeout-val').textContent=this.value+' min'">
<span id="lock-timeout-val" style="font-family:var(--mono);font-size:12px;color:var(--green);width:60px;">20 min</span>
</div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-top:4px;line-height:1.8;" data-i18n="adm_lock_timeout_desc">
Jeśli aktywny operator nie wykona żadnej akcji przez ten czas, radio zostaje zwolnione automatycznie.
</div>
</div>
<button class="save-btn" onclick="
RadioLock.setTimeout_(parseInt(document.getElementById('lock-timeout').value))
.then(() => UI.showToast(I18n.t('adm_timeout_saved')))" data-i18n="adm_save_timeout_btn">
ZAPISZ TIMEOUT
</button>
<button class="save-btn" style="margin-left:8px;background:rgba(var(--red-rgb),0.08);border-color:rgba(var(--red-rgb),0.3);color:var(--red);"
onclick="RadioLock.forceRelease()" data-i18n="adm_force_release_btn">
⚡ WYMUŚ ZWOLNIENIE RADIA
</button>
</div>
</div>
<!-- PRZEKAŹNIKI ARDUINO -->
<div class="box">
<div class="box-header"><span data-i18n="adm_relay_hdr">🔌 PRZEKAŹNIKI ARDUINO (SP5IOU SDR220)</span>
<span id="relay-status-badge" style="font-family:var(--mono);font-size:9px;padding:2px 6px;border-radius:3px;background:var(--panel3);color:var(--dim);margin-left:8px;">--</span>
</div>
<div data-i18n-html="adm_relay_desc" style="padding:8px 12px;font-family:var(--mono);font-size:9px;color:var(--dim);line-height:1.6;border-bottom:1px solid var(--border);">
Sterowanie przekaźnikami przez Arduino z firmware SP5IOU (protokół SDR220).<br>
Każdy z 8 przekaźników można ustawić jako "manual" (toggle on/off) lub "momentary" (impuls z ustawianym czasem 0.1-10s).<br>
Uprawnienia dostępu (kto ma widoczne przyciski) przydzielasz w sekcji <em>Użytkownicy</em> checkboxami "Przekaźnik 0..7".
</div>
<div class="settings-inner">
<div class="sg" style="grid-column:1/-1;">
<label style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="relay-enabled" style="width:14px;height:14px;">
<span data-i18n="adm_relay_enabled_lbl">Włączony (podłączony do Arduino)</span>
</label>
</div>
<div class="sg">
<label data-i18n="adm_serial_port_lbl">PORT SZEREGOWY</label>
<select id="relay-port"><option value="" data-i18n="adm_choose_port">(wybierz port)</option></select>
</div>
<div class="sg">
<label data-i18n="adm_baudrate_lbl">BAUDRATE</label>
<select id="relay-baudrate">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200">115200</option>
</select>
</div>
</div>
<div style="padding:6px 12px;font-family:var(--mono);font-size:10px;color:var(--amber);letter-spacing:1px;" data-i18n="adm_relay_config_hdr">
KONFIGURACJA 8 PRZEKAŹNIKÓW
</div>
<div id="relay-list" style="padding:4px 12px 12px;display:flex;flex-direction:column;gap:6px;">
<!-- generowane przez Admin.renderRelays() -->
</div>
<div style="padding:8px 12px;display:flex;gap:8px;">
<button class="save-btn" onclick="Admin.saveRelayConfig()" data-i18n="adm_save_relay_config_btn">💾 ZAPISZ KONFIGURACJĘ</button>
<span id="relay-save-status" style="font-family:var(--mono);font-size:10px;color:var(--dim);align-self:center;"></span>
</div>
</div>
<!-- SMTP CONFIG -->
<div class="box">
<div class="box-header" data-i18n="adm_smtp_hdr">EMAIL — KONFIGURACJA SMTP</div>
<div data-i18n-html="adm_smtp_desc" style="padding:8px 12px;font-family:var(--mono);font-size:9px;color:var(--dim);line-height:1.9;border-bottom:1px solid var(--border);">
Wymagane do działania funkcji "Zapomniałem hasła". Obsługiwany STARTTLS (port 587) i SSL (port 465).<br>
Przykład dla Gmail: host=smtp.gmail.com, port=587, user=twoj@gmail.com, hasło = App Password z Google.
</div>
<div class="settings-inner">
<div class="sg"><label data-i18n="adm_smtp_host_lbl">SERWER SMTP (host)</label><input type="text" id="smtp-host" placeholder="smtp.gmail.com"></div>
<div class="sg"><label data-i18n="in_port_lbl">PORT</label><input type="number" id="smtp-port" value="587" min="1" max="65535"></div>
<div class="sg"><label data-i18n="adm_smtp_user_lbl">UŻYTKOWNIK (login SMTP)</label><input type="text" id="smtp-user" placeholder="twoj@gmail.com" autocomplete="off"></div>
<div class="sg"><label data-i18n="adm_smtp_pass_lbl">HASŁO SMTP</label><input type="password" id="smtp-pass" placeholder="••••••••" autocomplete="new-password"></div>
<div class="sg"><label data-i18n="adm_smtp_from_lbl">ADRES NADAWCY (From)</label><input type="text" id="smtp-from" placeholder="noreply@twoja-domena.pl"></div>
<div class="sg"><label data-i18n="adm_smtp_encryption_lbl">SZYFROWANIE</label>
<select id="smtp-tls">
<option value="1" data-i18n="adm_smtp_starttls_opt">STARTTLS (port 587, zalecane)</option>
<option value="0" data-i18n="adm_smtp_ssl_opt">SSL/TLS (port 465)</option>
</select>
</div>
<div class="sg"><label data-i18n="adm_smtp_test_lbl">EMAIL TESTOWY — WYŚLIJ NA</label>
<div style="display:flex;gap:6px;">
<input type="email" id="smtp-test-email" placeholder="test@gmail.com" style="flex:1;">
<button class="admin-btn" onclick="AdminSmtp.test()" data-i18n="adm_send_test_btn">WYŚLIJ TEST</button>
</div>
</div>
</div>
<div style="padding:0 12px 12px;display:flex;gap:8px;">
<button class="save-btn" onclick="AdminSmtp.save()" data-i18n="adm_save_smtp_btn">ZAPISZ SMTP</button>
</div>
<div id="smtp-msg" style="padding:0 12px 12px;font-family:var(--mono);font-size:10px;min-height:20px;"></div>
</div>
<!-- AKTUALIZACJE — sprawdzanie nowej wersji na GitHub Releases -->
<div class="box">
<div class="box-header" data-i18n="adm_update_hdr">AKTUALIZACJE</div>
<div data-i18n="adm_update_desc" style="padding:8px 12px;font-family:var(--mono);font-size:9px;color:var(--dim);line-height:1.9;border-bottom:1px solid var(--border);">
Sprawdza GitHub Releases raz przy starcie serwera (albo na żądanie poniżej). Niczego nie pobiera ani nie instaluje — tylko informuje.
</div>
<div class="settings-inner">
<div class="sg"><label data-i18n="adm_update_current_lbl">TWOJA WERSJA</label><div id="adm-update-current" style="font-family:var(--mono);font-size:12px;"></div></div>
<div class="sg"><label data-i18n="adm_update_status_lbl">STATUS</label><div id="adm-update-status" style="font-family:var(--mono);font-size:12px;"></div></div>
<div class="sg" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="adm-update-check-toggle" onchange="AdminUpdate.saveToggles()">
<label for="adm-update-check-toggle" data-i18n="adm_update_check_lbl" style="margin:0;">Automatyczne sprawdzanie przy starcie</label>
</div>
<div class="sg" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="adm-update-email-toggle" onchange="AdminUpdate.saveToggles()">
<label for="adm-update-email-toggle" data-i18n="adm_update_email_lbl" style="margin:0;">Powiadom adminów mailem (wymaga skonfigurowanego SMTP powyżej)</label>
</div>
<div id="adm-update-email-diag" style="font-family:var(--mono);font-size:9px;color:var(--dim);display:none;"></div>
</div>
<div style="padding:0 12px 12px;display:flex;gap:8px;">
<button class="admin-btn" onclick="AdminUpdate.checkNow()" data-i18n="adm_update_check_now_btn">SPRAWDŹ TERAZ</button>
</div>
<div id="adm-update-msg" style="padding:0 12px 12px;font-family:var(--mono);font-size:10px;min-height:20px;"></div>
</div>
</div>
</div>
<!-- MODAL: Nowy/edytuj użytkownik -->
<!-- ── MODAL: wyslij spota na DX Cluster ────────────────────────────────────── -->
<div class="modal-overlay" id="dx-spot-modal" onclick="if(event.target===this)DXCluster.closeSpotDialog()">
<div class="modal-box" style="max-width:440px;">
<div class="modal-header">
<span data-i18n="dx_spot_modal_hdr">📤 WYŚLIJ SPOT NA KLASTER</span>
<button class="modal-close" onclick="DXCluster.closeSpotDialog()">✕</button>
</div>
<div style="padding:0 4px 8px;font-family:var(--mono);font-size:10px;color:var(--dim);line-height:1.5;" data-i18n="dx_spot_modal_desc">
Spot zostanie wysłany pod Twoim znakiem (loginem klastra) i będzie
widoczny dla wszystkich operatorów na świecie.
</div>
<div class="modal-grid">
<div class="sg">
<label data-i18n="dx_spot_call_lbl">ZNAK STACJI *</label>
<input type="text" id="dx-spot-call" placeholder="SP1ABC" maxlength="16"
style="text-transform:uppercase;font-family:var(--mono);letter-spacing:1px;"
oninput="this.value=this.value.toUpperCase()">
</div>
<div class="sg">
<label data-i18n="dx_spot_freq_lbl">CZĘSTOTLIWOŚĆ (kHz) *</label>
<input type="text" id="dx-spot-freq" placeholder="14074.0"
style="font-family:var(--mono);">
</div>
</div>
<div class="sg" style="margin-top:6px;">
<label data-i18n="dx_spot_comment_lbl">KOMENTARZ (max 30 znaków)</label>
<input type="text" id="dx-spot-comment" maxlength="30"
data-i18n-placeholder="dx_spot_comment_ph" placeholder="np. FT8 CQ DX / 599 TNX QSO"
style="font-family:var(--mono);">
</div>
<!-- Podglad komendy jaka poleci na klaster -->
<div style="margin-top:10px;padding:8px 10px;background:var(--panel3);
border:1px solid var(--border);border-radius:3px;">
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);
letter-spacing:1px;margin-bottom:4px;" data-i18n="dx_preview_lbl">PODGLĄD KOMENDY:</div>
<div id="dx-spot-preview" style="font-family:var(--mono);font-size:11px;
color:var(--green);word-break:break-all;">DX 14074.0 SP1ABC</div>
</div>
<div id="dx-spot-error" style="display:none;margin-top:8px;padding:6px 10px;
background:rgba(var(--red-rgb),0.1);border:1px solid rgba(var(--red-rgb),0.4);
border-radius:3px;font-family:var(--mono);font-size:10px;color:var(--red);"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px;">
<button onclick="DXCluster.closeSpotDialog()" data-i18n="common_cancel_btn"
style="font-family:var(--mono);font-size:10px;padding:6px 14px;background:var(--panel3);
border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer;
letter-spacing:1px;">ANULUJ</button>
<button id="dx-spot-send-btn" onclick="DXCluster.sendSpot()"
style="font-family:var(--mono);font-size:10px;padding:6px 16px;background:rgba(var(--green-rgb),0.12);
border:1px solid var(--green2);color:var(--green);border-radius:3px;cursor:pointer;
letter-spacing:1px;font-weight:600;">📤 WYŚLIJ SPOT</button>
</div>
</div>
</div>
<div class="modal-overlay" id="user-modal" onclick="if(event.target===this)Admin.closeModal()">
<div class="modal-box" style="max-width:560px;">
<div class="modal-header"><span id="modal-title" data-i18n="adm_new_user_title">NOWY UŻYTKOWNIK</span><button class="modal-close" onclick="Admin.closeModal()">✕</button></div>
<input type="hidden" id="user-id">
<!-- Dane podstawowe -->
<div class="modal-grid">
<div class="sg"><label data-i18n="adm_login_lbl">LOGIN *</label><input type="text" id="uname" placeholder="jan_sp2abc"></div>
<div class="sg"><label data-i18n="adm_role_lbl">ROLA *</label>
<select id="urole" onchange="Admin._updatePermsByRole(this.value)">
<option value="viewer">VIEWER</option>
<option value="operator" selected>OPERATOR</option>
<option value="admin">ADMIN</option>
</select>
</div>
<div class="sg" id="upass-row"><label data-i18n="adm_pass_lbl">HASŁO *</label><input type="password" id="upass" data-i18n-placeholder="adm_min_chars_ph" placeholder="min. 8 znaków" autocomplete="new-password"></div>
<div class="sg"><label data-i18n="profile_callsign_lbl">ZNAK WYWOŁAWCZY</label><input type="text" id="ucallsign" placeholder="SP2ABC" style="text-transform:uppercase"></div>
<div class="sg"><label data-i18n="adm_locator_grid_lbl">LOKATOR (grid)</label><input type="text" id="ulocator" placeholder="JO72" maxlength="8" style="text-transform:uppercase"></div>
<div class="sg full-row"><label data-i18n="profile_name_lbl">IMIĘ</label><input type="text" id="uname-full" placeholder="Jan"></div>
<div class="sg full-row"><label>EMAIL</label><input type="email" id="uemail" placeholder="jan@example.com"></div>
</div>
<!-- Uprawnienia granularne -->
<div style="margin-top:16px;border:1px solid var(--border);border-radius:5px;overflow:hidden;">
<div style="background:var(--panel2);padding:6px 12px;font-family:var(--mono);font-size:9px;color:var(--dim);letter-spacing:2px;border-bottom:1px solid var(--border);" data-i18n="adm_perms_hdr">
UPRAWNIENIA GRANULARNE
</div>
<div style="padding:10px 12px;display:grid;grid-template-columns:1fr 1fr;gap:0;">
<!-- RADIO -->
<div style="padding-right:10px;border-right:1px solid var(--border);">
<div style="font-family:var(--mono);font-size:8px;color:var(--amber);letter-spacing:2px;margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);" data-i18n="adm_group_radio">RADIO</div>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);">
<input type="checkbox" id="perm-ptt" style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_ptt">PTT — nadawanie</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);">
<input type="checkbox" id="perm-rfPower" style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_rfpower">RF Power — moc TX</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);">
<input type="checkbox" id="perm-rfGain" style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_rfgain">RF/AF Gain, SQL</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);">
<input type="checkbox" id="perm-mode" style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_mode">Zmiana trybu (USB/CW…)</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);">
<input type="checkbox" id="perm-band" style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_band">Zmiana pasma</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);">
<input type="checkbox" id="perm-freq" style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_freq">Strojenie częstotliwości</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;">
<input type="checkbox" id="perm-cw" style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_cw">CW Keyer — wysyłanie</span>
</label>
</div>
<!-- SPRZĘT + LOG + SYSTEM -->
<div style="padding-left:10px;">
<div style="font-family:var(--mono);font-size:8px;color:var(--amber);letter-spacing:2px;margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);" data-i18n="adm_group_hw">SPRZĘT</div>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);">
<input type="checkbox" id="perm-rotator" style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_rotator">Rotator — sterowanie</span>
</label>
<div style="font-family:var(--mono);font-size:8px;color:var(--amber);letter-spacing:2px;margin:10px 0 8px;padding-bottom:4px;border-bottom:1px solid var(--border);" data-i18n="adm_group_system">SYSTEM</div>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);">
<input type="checkbox" id="perm-log" style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_log">Log QSO — zapis</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);">
<input type="checkbox" id="perm-settings" style="width:14px;height:14px;accent-color:var(--amber);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_settings">Ustawienia serwera</span>
</label>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;">
<input type="checkbox" id="perm-admin" style="width:14px;height:14px;accent-color:var(--amber);cursor:pointer;">
<span style="font-family:var(--mono);font-size:11px;color:var(--text);" data-i18n="perm_admin">Panel administratora</span>
</label>
<!-- PRZEKAŹNIKI - generowane dynamicznie z konfiguracji (relay_0..7) -->
<div id="perm-relay-section" style="display:none;">
<div style="font-family:var(--mono);font-size:8px;color:var(--amber);letter-spacing:2px;margin:10px 0 8px;padding-bottom:4px;border-bottom:1px solid var(--border);" data-i18n="adm_group_relay">PRZEKAŹNIKI</div>
<div id="perm-relay-list"><!-- checkboxy relay wstawiane przez admin.js --></div>
</div>
</div>
</div>
</div>
<div style="margin-top:16px;display:flex;gap:10px;">
<button class="save-btn" onclick="Admin.saveUser()" data-i18n="settings_save_btn">ZAPISZ</button>
<button class="save-btn" style="background:transparent;border-color:var(--border);" onclick="Admin.closeModal()" data-i18n="common_cancel_btn">ANULUJ</button>
</div>
</div>
</div>
<!-- MODAL: Edycja makra CW -->
<!-- ─── Moduły JS ─────────────────────────────────────────────────────────────── -->
<script>
// Sets window.__HAM_INITIAL_LANG__ from the server-wide default language
// (chosen in the installer at setup time - see HAMCTRL-installer.iss and
// data.py::get_cfg) BEFORE i18n.js runs, so the app renders in the right
// language on the very first paint instead of flashing Polish and then
// switching. Synchronous XHR is deliberate: this is a same-origin call to
// the local server (resolves in single-digit ms) and i18n.js needs the
// value available synchronously at load time, not after an async fetch
// resolves later (i18n.js's own module-load-time init() would already
// have run and picked the fallback by then). A per-browser choice saved
// in localStorage always takes priority over this once someone actually
// toggles the language - see the priority order in i18n.js.
try {
var _xhr = new XMLHttpRequest();
_xhr.open('GET', '/api/config/lang', false);
_xhr.send(null);
if (_xhr.status === 200) {
var _langResp = JSON.parse(_xhr.responseText);
if (_langResp && _langResp.lang) window.__HAM_INITIAL_LANG__ = _langResp.lang;
}
} catch (e) { /* fall through to i18n.js's own 'pl' default */ }
</script>
<script src="js/i18n.js?v=202610272000"></script>
<script src="js/theme.js?v=202610161000"></script>
<script src="js/auth.js?v=202610181800"></script>
<script src="js/state.js?v=202606180504"></script>
<script src="js/ws.js?v=202610221100"></script>
<script src="js/radiofunctions.js?v=202609040002"></script>
<script src="js/ui.js?v=202610210900"></script>
<script src="js/advradio.js?v=202610181650"></script>
<script src="js/radiolock.js?v=202610162130"></script>
<script src="js/serial_ports.js?v=202609020001"></script>
<script src="js/settings.js?v=202610162130"></script>
<script src="js/tx_eq.js?v=202608210003"></script>
<script src="js/cw_sidetone.js?v=202608210001"></script>
<script src="js/relay_ui.js?v=202610162130"></script>
<script src="js/dxcluster.js?v=202610180002"></script>
<script src="js/audio_autodetect.js?v=202610162130"></script>
<script src="js/com_bridge.js?v=202608180001"></script>
<script src="js/rotator.js?v=202610180003"></script>
<script src="js/rotormini.js?v=202610161030"></script>
<script src="js/admin.js?v=202610181800"></script>
<script src="js/admin_status.js?v=202608180002"></script>
<script src="js/profile_audio.js?v=202608243200"></script>
<script src="js/relay_admin.js?v=202610162130"></script>
<script src="js/tunnel.js?v=202610162130"></script>
<script src="js/dxcc.js?v=202610202000"></script>
<script src="js/grid_math.js?v=202607030001"></script>
<script src="js/beamheading.js?v=202610202000"></script>
<script src="js/wsjtx.js?v=202610312200"></script>
<script src="js/wsjtx_scope.js?v=202610222330"></script>
<script src="js/chat.js?v=202609030004"></script>
<script src="js/vfo.js?v=202610172903"></script>
<script src="js/waterfall.js?v=202610201930"></script>
<div class="modal-overlay" id="cw-edit-modal" onclick="if(event.target===this)CW.closeEdit()" style="display:none;">
<div class="modal-box" style="min-width:280px;max-width:360px;">
<div class="modal-header"><span id="cw-edit-title">MAKRO CW</span><button class="modal-close" onclick="CW.closeEdit()">✕</button></div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;padding:16px;">
<input type="hidden" id="cw-edit-id">
<div>
<label style="font-family:var(--mono);font-size:10px;color:var(--dim);display:block;margin-bottom:4px;">ETYKIETA</label>
<input type="text" id="cw-edit-label" placeholder="np. CQ, RST, 73..." maxlength="12"
style="width:100%;font-family:var(--mono);font-size:12px;background:var(--input-bg,#0a0a0a);border:1px solid var(--border);color:var(--fg);padding:6px 8px;border-radius:3px;">
</div>
<div>
<label style="font-family:var(--mono);font-size:10px;color:var(--dim);display:block;margin-bottom:4px;">TEKST CW</label>
<input type="text" id="cw-edit-text" style="width:100%;font-family:var(--mono);font-size:13px;text-transform:uppercase;background:var(--input-bg,#0a0a0a);border:1px solid var(--border);color:var(--fg);padding:6px 8px;border-radius:3px;"
onkeydown="if(event.key==='Enter')CW.saveEditMacro()">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="save-btn" onclick="CW.saveEditMacro()">ZAPISZ</button>
<button class="save-btn" style="background:transparent;border-color:var(--border);" onclick="CW.closeEdit()">ANULUJ</button>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="confirm-modal" onclick="if(event.target===this)UI._confirmModalCancel()" style="display:none;">
<div class="modal-box" style="min-width:280px;max-width:400px;">
<div class="modal-header"><span id="confirm-modal-title" data-i18n="common_confirm_title">POTWIERDŹ</span><button class="modal-close" onclick="UI._confirmModalCancel()">✕</button></div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px;padding:16px;">
<div id="confirm-modal-msg" style="font-family:var(--mono);font-size:13px;color:var(--fg);white-space:pre-line;"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button id="confirm-modal-ok" class="save-btn" style="width:auto;" onclick="UI._confirmModalSubmit()">OK</button>
<button class="save-btn" style="width:auto;background:transparent;border-color:var(--border);" onclick="UI._confirmModalCancel()" data-i18n="common_cancel_btn">ANULUJ</button>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="text-prompt-modal" onclick="if(event.target===this)UI._textPromptCancel()" style="display:none;">
<div class="modal-box" style="min-width:280px;max-width:360px;">
<div class="modal-header"><span id="text-prompt-title">WPISZ WARTOŚĆ</span><button class="modal-close" onclick="UI._textPromptCancel()">✕</button></div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;padding:16px;">
<input type="text" id="text-prompt-input"
style="width:100%;font-family:var(--mono);font-size:13px;background:var(--input-bg,#0a0a0a);border:1px solid var(--border);color:var(--fg);padding:6px 8px;border-radius:3px;box-sizing:border-box;"
onkeydown="if(event.key==='Enter')UI._textPromptSubmit(); if(event.key==='Escape')UI._textPromptCancel();">
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="save-btn" style="width:auto;" onclick="UI._textPromptSubmit()">OK</button>
<button class="save-btn" style="width:auto;background:transparent;border-color:var(--border);" onclick="UI._textPromptCancel()">ANULUJ</button>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="rotor-manual-modal" onclick="if(event.target===this)WSJTX.rotorManualClose()" style="display:none;">
<div class="modal-box" style="min-width:280px;max-width:360px;">
<div class="modal-header"><span data-i18n="wj_rotor_modal_hdr">ROTOR — RĘCZNA POZYCJA</span><button class="modal-close" onclick="WSJTX.rotorManualClose()">✕</button></div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;padding:16px;">
<div>
<label style="font-family:var(--mono);font-size:10px;color:var(--dim);display:block;margin-bottom:4px;" data-i18n="wj_rotor_azimuth_lbl">AZYMUT (°) LUB LOKATOR</label>
<input type="text" id="rotor-manual-input" data-i18n-placeholder="wj_rotor_input_ph" placeholder="np. 272 lub KO02"
style="width:100%;font-family:var(--mono);font-size:13px;text-transform:uppercase;background:var(--input-bg,#0a0a0a);border:1px solid var(--border);color:var(--fg);padding:6px 8px;border-radius:3px;box-sizing:border-box;"
onkeydown="if(event.key==='Enter')WSJTX.rotorManualSubmit()">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="save-btn" style="width:auto;" onclick="WSJTX.rotorManualSubmit()" data-i18n="wj_set_btn">USTAW</button>
<button class="save-btn" style="width:auto;background:transparent;border-color:var(--border);" onclick="WSJTX.rotorManualClose()" data-i18n="common_cancel_btn">ANULUJ</button>
</div>
</div>
</div>
</div>
<script src="js/cw.js?v=202610202100"></script>
<script src="js/cloudlog.js?v=202608170001"></script>
<script src="js/hamlib_ui.js?v=202608170001"></script>
<script src="js/callbook.js?v=202608170001"></script>
<script src="js/qsolog.js?v=202610172902"></script>
<script>
// ── HamlibUI — panel wirtualnych radii (rigctld emulator) ────────────────────
window.HamlibUI = {
_data: [],
// s.label is an admin-typed value="..." attribute - hamlib_ui.js's own
// _esc() already covers this field, but THIS inline block is what
// actually runs (it redefines window.HamlibUI right after hamlib_ui.js
// loads, shadowing it entirely) - fix applied here too.
_esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
},
async load() {
try {
const r = await fetch('/api/hamlib/status');
const d = await r.json();
this._data = d.servers || [];
this._render();
} catch(e) { console.warn('[hamlib] load error', e); }
},
_render() {
const el = document.getElementById('hamlib-slots');
if (!el) return;
if (!this._data.length) {
el.innerHTML = '<div style="font-family:var(--mono);font-size:10px;color:var(--dim);">Brak danych</div>';
return;
}
el.innerHTML = this._data.map((s, i) => `
<div style="background:var(--panel2);border:1px solid var(--border);
border-radius:4px;padding:10px 12px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<!-- Dot statusu -->
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;
background:${s.running ? 'var(--green)' : 'var(--dim)'}"></span>
<span style="font-family:var(--mono);font-size:10px;color:${s.running ? 'var(--green)' : 'var(--dim)'};
letter-spacing:1px;">${s.running ? '● AKTYWNY' : '○ ZATRZYMANY'}</span>
${s.clients > 0
? `<span style="font-family:var(--mono);font-size:9px;color:var(--amber);
background:rgba(var(--amber-rgb),0.1);padding:1px 6px;border-radius:10px;">
${s.clients} klient${s.clients > 1 ? 'ów' : ''} połączony</span>`
: ''}
<span style="flex:1"></span>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="hamlib-enabled-${i}"
${s.enabled ? 'checked' : ''}
style="width:14px;height:14px;accent-color:var(--green);cursor:pointer;">
<span style="font-family:var(--mono);font-size:10px;color:var(--dim);">Włączone</span>
</label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:3px;">
NAZWA / OPIS
</div>
<input type="text" id="hamlib-label-${i}" value="${this._esc(s.label || '')}"
placeholder="np. WSJT-X, Log4OM, CW Skimmer"
style="width:100%;font-family:var(--mono);font-size:11px;
background:var(--panel3);border:1px solid var(--border);
color:var(--text);border-radius:3px;padding:4px 7px;">
</div>
<div>
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:3px;">
PORT TCP
</div>
<div style="display:flex;align-items:center;gap:6px;">
<input type="number" id="hamlib-port-${i}" value="${s.port || 4532+i}"
min="1024" max="65535"
style="width:80px;font-family:var(--mono);font-size:12px;
background:var(--panel3);border:1px solid var(--border);
color:var(--green);border-radius:3px;padding:4px 7px;">
<span style="font-family:var(--mono);font-size:9px;color:var(--dim);">
host: <b>${location.hostname}</b>
</span>
</div>
</div>
</div>
</div>
`).join('');
},
async save() {
const servers = this._data.map((s, i) => ({
port: parseInt(document.getElementById(`hamlib-port-${i}`)?.value || 4532+i),
enabled: document.getElementById(`hamlib-enabled-${i}`)?.checked ?? false,
label: document.getElementById(`hamlib-label-${i}`)?.value || `Radio ${i+1}`,
}));
const msg = document.getElementById('hamlib-msg');
try {
const r = await fetch('/api/hamlib/config', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({servers})
});
const d = await r.json();
if (msg) {
msg.textContent = d.message || (d.ok ? '✓ Zapisano' : '✗ Błąd');
msg.style.color = d.ok ? 'var(--green)' : 'var(--red)';
}
if (d.ok) {
this._data = servers.map((s,i) => ({...s, slot:i, running:false, clients:0}));
this._render();
}
} catch(e) {
if (msg) { msg.textContent='✗ Błąd: '+e.message; msg.style.color='var(--red)'; }
}
},
};
// ── AdminBands — zarządzanie pasmami i trybami ────────────────────────────────
// Stub — prawdziwa implementacja w admin.js (laduje sie pozniej)
function _attachTransferEvents(id) {
if (typeof window._adminAttachTransferEvents === 'function') {
window._adminAttachTransferEvents(id);
} else {
setTimeout(() => window._adminAttachTransferEvents?.(id), 200);
}
}
function _twUpdateCounts(id) {
if (typeof window._adminTwUpdateCounts === 'function') {
window._adminTwUpdateCounts(id);
}
}
window.AdminBands = {
async load() {
await Promise.all([this._loadBands(), this._loadModes()]);
},
async _loadBands() {
try {
const r = await fetch('/api/config/bands');
const d = await r.json();
const allBands = Object.keys(d.allBands || {});
const enabledBands = d.enabledBands && d.enabledBands.length
? d.enabledBands : allBands;
const selList = document.getElementById('bands-transfer-selected');
const avlList = document.getElementById('bands-transfer-available');
if (!selList || !avlList) return;
selList.innerHTML = '';
avlList.innerHTML = '';
// Zachowaj kolejność z enabledBands, potem reszta
const enabledSet = new Set(enabledBands);
// Enabled — w kolejności z config
enabledBands.forEach(b => {
if (allBands.includes(b)) selList.appendChild(this._bandItem(b, 'selected'));
});
// Disabled
allBands.forEach(b => {
if (!enabledSet.has(b)) avlList.appendChild(this._bandItem(b, 'available'));
});
setTimeout(() => { _attachTransferEvents?.('bands-transfer'); _twUpdateCounts?.('bands-transfer'); }, 0);
} catch(e) { console.warn('[admin] bands load error', e); }
},
_bandItem(name, side) {
const el = document.createElement('div');
el.className = 'tw-item';
el.dataset.band = name;
el.dataset.side = side;
el.draggable = true;
el.innerHTML = `${name}<span class="tw-sub">${this._bandFreq(name)}</span>`;
return el;
},
_bandFreq(name) {
const map = {
'160m':'1.8 MHz','80m':'3.5 MHz','60m':'5.35 MHz','40m':'7 MHz',
'30m':'10.1 MHz','20m':'14 MHz','17m':'18 MHz','15m':'21 MHz',
'12m':'24.9 MHz','10m':'28 MHz','6m':'50 MHz','4m':'70 MHz',
'2m':'144 MHz','70cm':'432 MHz',
};
return map[name] || '';
},
async save() {
const selList = document.getElementById('bands-transfer-selected');
const enabledBands = [...(selList?.querySelectorAll('.tw-item') || [])]
.map(el => el.dataset.band);
try {
const r = await fetch('/api/config/bands', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({enabledBands})
});
const d = await r.json();
if (d.ok) {
window.UI?.showToast(`✓ Pasma zaktualizowane (${enabledBands.length} aktywnych)`);
// Zaktualizuj AppState lokalnie
if (window.AppState) window.AppState.enabledBands = enabledBands;
window.UI?.buildBandGrid?.();
}
} catch(e) { window.UI?.showToast('✗ Błąd zapisu pasm', 'error'); }
},
async _loadModes() {
try {
const r = await fetch('/api/config/modes');
const d = await r.json();
const allModes = d.allModes || [];
const enabledModes = d.enabledModes && d.enabledModes.length
? d.enabledModes : allModes;
const modeFilters = d.modeFilters || {};
const selList = document.getElementById('modes-transfer-selected');
const avlList = document.getElementById('modes-transfer-available');
if (!selList || !avlList) return;
selList.innerHTML = '';
avlList.innerHTML = '';
const enabledSet = new Set(enabledModes);
enabledModes.forEach(m => {
if (allModes.includes(m)) selList.appendChild(this._modeItem(m, 'selected'));
});
allModes.forEach(m => {
if (!enabledSet.has(m)) avlList.appendChild(this._modeItem(m, 'available'));
});
setTimeout(() => { _attachTransferEvents?.('modes-transfer'); _twUpdateCounts?.('modes-transfer'); }, 0);
// Wypełnij grid filtrów
this._renderFilterGrid(allModes, modeFilters);
} catch(e) { console.warn('[admin] modes load error', e); }
},
_modeItem(name, side) {
const el = document.createElement('div');
el.className = 'tw-item';
el.dataset.mode = name;
el.dataset.side = side;
el.draggable = true;
const isDigital = ['USB-D','LSB-D','PKTUSB','PKTLSB'].includes(name);
el.innerHTML = `<span style="${isDigital?'color:var(--amber)':''}">${name}</span>`;
return el;
},
_renderFilterGrid(allModes, modeFilters) {
const grid = document.getElementById('mode-filter-grid');
if (!grid) return;
// Pokaż tylko tryby dla których filtr ma sens
const modesWithFilter = allModes.filter(m =>
!['WFM','DV'].includes(m)
);
grid.innerHTML = modesWithFilter.map(m => {
const val = modeFilters[m] || '';
const isDigital = ['USB-D','LSB-D','PKTUSB','PKTLSB'].includes(m);
return `<div style="display:flex;align-items:center;gap:6px;">
<span style="font-family:var(--mono);font-size:10px;min-width:60px;
color:${isDigital?'var(--amber)':'var(--dim)'};">${m}</span>
<select data-mode-filter="${m}"
style="flex:1;font-family:var(--mono);font-size:10px;
background:var(--panel2);border:1px solid var(--border);
color:var(--text);border-radius:3px;padding:3px 5px;">
<option value="">— brak —</option>
<option value="1" ${val==='1'?'selected':''}>FIL1 (szeroki)</option>
<option value="2" ${val==='2'?'selected':''}>FIL2 (średni)</option>
<option value="3" ${val==='3'?'selected':''}>FIL3 (wąski)</option>
</select>
</div>`;
}).join('');
},
async saveModes() {
const selList = document.getElementById('modes-transfer-selected');
const enabledModes = [...(selList?.querySelectorAll('.tw-item') || [])]
.map(el => el.dataset.mode);
// Zbierz filtry
const modeFilters = {};
document.querySelectorAll('[data-mode-filter]').forEach(sel => {
if (sel.value) modeFilters[sel.dataset.modeFilter] = sel.value;
});
try {
const r = await fetch('/api/config/modes', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({enabledModes, modeFilters})
});
const d = await r.json();
if (d.ok) {
window.UI?.showToast(`✓ Tryby zaktualizowane (${enabledModes.length} aktywnych)`);
if (window.AppState) {
window.AppState.enabledModes = enabledModes;
window.AppState.modeFilters = modeFilters;
}
window.UI?.buildModeGrid?.();
}
} catch(e) { window.UI?.showToast('✗ Błąd zapisu trybów', 'error'); }
},
};
// ── AdminSmtp — konfiguracja SMTP dla resetu hasla ───────────────────────────
window.AdminSmtp = {
async load() {
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token');
const r = await fetch('/api/smtp/config', {headers:{'Authorization':`Bearer ${token}`}});
const d = await r.json();
document.getElementById('smtp-host').value = d.host || '';
document.getElementById('smtp-port').value = d.port || 587;
document.getElementById('smtp-user').value = d.user || '';
document.getElementById('smtp-from').value = d.from || '';
document.getElementById('smtp-tls').value = d.use_tls ? '1' : '0';
// Nie wczytuj hasla — pokazujemy placeholder jesli ustawione
if (d.has_password) document.getElementById('smtp-pass').placeholder = I18n.t('adm_smtp_pass_saved_ph');
} catch(e) {}
},
async save() {
const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token');
const body = {
host: document.getElementById('smtp-host').value.trim(),
port: parseInt(document.getElementById('smtp-port').value),
user: document.getElementById('smtp-user').value.trim(),
from: document.getElementById('smtp-from').value.trim(),
use_tls: document.getElementById('smtp-tls').value === '1',
password: document.getElementById('smtp-pass').value,
};
const r = await fetch('/api/smtp/config', {
method:'POST', headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},
body: JSON.stringify(body)
});
const d = await r.json();
const msg = document.getElementById('smtp-msg');
msg.textContent = d.ok ? I18n.t('adm_smtp_saved') : '✗ ' + (d.error||I18n.t('profile_error_fallback'));
msg.style.color = d.ok ? 'var(--green)' : 'var(--red)';
if (d.ok) document.getElementById('smtp-pass').value = '';
},
async test() {
const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token');
const email = document.getElementById('smtp-test-email').value.trim();
const msg = document.getElementById('smtp-msg');
if (!email) {
msg.textContent = I18n.t('adm_smtp_enter_test_email');
msg.style.color = 'var(--red)';
return;
}
msg.textContent = I18n.t('adm_smtp_sending_test');
msg.style.color = 'var(--dim)';
try {
const r = await fetch('/api/smtp/test', {
method: 'POST',
headers: {'Content-Type':'application/json', 'Authorization': `Bearer ${token}`},
body: JSON.stringify({ email, base_url: location.origin })
});
const d = await r.json();
msg.textContent = d.message || (d.ok ? I18n.t('adm_smtp_sent') : I18n.t('profile_error_fallback'));
msg.style.color = d.ok ? 'var(--green)' : 'var(--red)';
} catch(e) {
msg.textContent = '✗ ' + I18n.t('cfg_conn_error') + ': ' + e.message;
msg.style.color = 'var(--red)';
}
}
};
// ── AdminUpdate — status/ustawienia sprawdzania nowej wersji na GitHub ──────
// Backend (self._run_update_check, /api/update/config, /api/update/check)
// istniał juz od dawna, ale nigdy nie mial frontu - stad "mam ustawiony
// mail, a i tak nic nie przychodzi": updateEmail (osobny flag od samego
// SMTP) byl domyslnie wylaczony i nie bylo jak go wlaczyc, a _update_info
// nigdzie sie nie wyswietlal.
window.AdminUpdate = {
async load() {
try {
const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token');
const r = await fetch('/api/update/config', {headers:{'Authorization':`Bearer ${token}`}});
const d = await r.json();
this._render(d);
} catch(e) {}
},
_render(d) {
document.getElementById('adm-update-current').textContent = d.current || '?';
document.getElementById('adm-update-check-toggle').checked = !!d.enabled;
document.getElementById('adm-update-email-toggle').checked = !!d.email;
const statusEl = document.getElementById('adm-update-status');
if (!d.repo_configured) {
statusEl.textContent = I18n.t('adm_update_no_repo');
statusEl.style.color = 'var(--dim)';
} else if (d.update && d.update.latest) {
statusEl.innerHTML = `⚠ ${I18n.t('adm_update_available').replace('{v}', d.update.latest)} — <a href="${d.update.url}" target="_blank" style="color:var(--green);">${I18n.t('adm_update_open_release')}</a>`;
statusEl.style.color = 'var(--amber)';
} else {
statusEl.textContent = I18n.t('adm_update_current_ok');
statusEl.style.color = 'var(--green)';
}
// FIX (reported live 2026-08-26: "widze ze jest nowa wersja ale zaden
// mail nie przyszedl" - the admin had no way to see WHY, only a
// server-console print). email_diag comes straight from the backend's
// LAST actual send attempt (or non-attempt) - show it whenever present,
// regardless of whether it was success or failure, so this box is
// never silently wrong again.
const diagEl = document.getElementById('adm-update-email-diag');
if (diagEl) {
if (d.email_diag) {
diagEl.textContent = d.email_diag;
diagEl.style.display = '';
} else {
diagEl.style.display = 'none';
}
}
},
async saveToggles() {
const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token');
const body = {
enabled: document.getElementById('adm-update-check-toggle').checked,
email: document.getElementById('adm-update-email-toggle').checked,
};
await fetch('/api/update/config', {
method:'POST', headers:{'Content-Type':'application/json','Authorization':`Bearer ${token}`},
body: JSON.stringify(body)
});
},
async checkNow() {
const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token');
const msg = document.getElementById('adm-update-msg');
msg.textContent = I18n.t('adm_update_checking');
msg.style.color = 'var(--dim)';
try {
const r = await fetch('/api/update/check', {
method: 'POST', headers: {'Authorization': `Bearer ${token}`}
});
const d = await r.json();
if (!d.ok) {
msg.textContent = '✗ ' + (d.error || I18n.t('profile_error_fallback'));
msg.style.color = 'var(--red)';
return;
}
this._render({ current: d.current, update: d.update,
enabled: document.getElementById('adm-update-check-toggle').checked,
email: document.getElementById('adm-update-email-toggle').checked,
email_diag: d.email_diag,
repo_configured: true });
msg.textContent = I18n.t('adm_update_checked');
msg.style.color = 'var(--green)';
} catch(e) {
msg.textContent = '✗ ' + I18n.t('cfg_conn_error') + ': ' + e.message;
msg.style.color = 'var(--red)';
}
}
};
// ── RadioLock — wydzielone do public/js/radiolock.js (2026-08-24), żeby
// telefon mógł reużyć dokładnie tę samą logikę zamiast duplikować ją.
// ── setPage — jeden punkt wywołania ─────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Oblicz dostepna wysokosc dla wsjtx-layout w jednostkach referencyjnych (#app-scale)
function _setWsjtxH() {
const appScale = document.getElementById('app-scale');
const topbar = document.querySelector('.topbar');
if (!appScale || !topbar) return;
// Pobierz aktualny scale z transform
const transform = appScale.style.transform || '';
const match = transform.match(/scale\(([\d.]+)\)/);
const scale = match ? parseFloat(match[1]) : 1;
if (scale < 0.01) return;
// Oblicz dostepna wysokosc w px referencyjnych
const availPx = window.innerHeight / scale;
const topbarRef = topbar.getBoundingClientRect().height / scale;
const wsjtxH = Math.floor(availPx - topbarRef);
document.documentElement.style.setProperty('--wsjtx-h', wsjtxH + 'px');
}
_setWsjtxH();
window.addEventListener('resize', _setWsjtxH);
// Ponow po zaladowaniu scale
setTimeout(_setWsjtxH, 300);
if (typeof RotorMini !== 'undefined') setTimeout(() => RotorMini.init(), 2000);
const origFn = UI.setPage.bind(UI);
let _lastPage = '';
UI.setPage = function(name) {
origFn(name);
if (name === _lastPage) return;
_lastPage = name;
if (name === 'internet') { Tunnel.load(); Tunnel.checkCF(); }
if (name === 'admin') { Admin.loadUsers(); Admin.loadFt8Timers?.(); AdminSmtp.load(); AdminUpdate.load(); }
if (name === 'log') { window.QSOLog?.load(); }
// Odswiez stan radia przy kazdej zmianie strony
fetch('/api/radio/state').then(r=>r.json()).then(msg=>window.OpPanel?.handleWS(msg)).catch(()=>{});
if (name === 'wsjtx') { window.FT8Timer?.init?.(); }
if (name === 'config') {
Admin.loadRotatorConfig?.(); Admin.loadRigFeatures?.(); AdminBands?.load?.();
// _initCwStatus wypelnia select CW KEYER (AUTO/CAT/DTR/RTS) + port/linie
// DTR - te pola zyja w #rigs-settings-container, ktory jest w zakladce
// KONFIGURACJA, nie USTAWIENIA. Bylo tu zle dopiete (jak wczesniej
// _audioDeviceLoad) - metoda CW Keyer zawsze pokazywala sie na
// statycznym HTML "AUTO" niezaleznie od realnie zapisanej.
setTimeout(() => { window.Settings?._initCwStatus?.(); }, 500);
}
if (name === 'settings') { setTimeout(() => { window.HamlibUI?.load?.(); }, 500); }
if (name === 'wsjtx') {
// Ustaw wysokosc wsjtx-layout i zainicjuj po renderze
setTimeout(() => {
if (typeof _setWsjtxH === 'function') _setWsjtxH();
window.WSJTX?.init?.();
}, 50);
}
};
});
// ── Zmiana własnego hasła ─────────────────────────────────────────────────────
// Dwa miejsca pokazuja/zapisuja TE SAME dane wlasnego profilu: zakladka
// PROFIL (id="profile-*", dla kazdej roli) i box "MOJ PROFIL" w zakladce
// ADMIN (id="admin-profile-*", wygodny skrot dla admina zeby nie przelaczac
// zakladki). Oba prefiksy licza sie jeden po drugim - jesli dany element nie
// istnieje w DOM (bo user nie jest adminem, wiec box w ADMIN nie jest
// wyrenderowany), po prostu jest pomijany.
const _PROFILE_PREFIXES = ['profile', 'admin-profile'];
async function loadProfile() {
try {
const r = await fetch('/api/auth/me');
const d = await r.json();
const u = d.user || d;
for (const pfx of _PROFILE_PREFIXES) {
const _s = (field, val) => { const el = document.getElementById(`${pfx}-${field}`); if (el) el.value = val || ''; };
_s('name', u.name || '');
_s('callsign', u.callsign || '');
_s('locator', u.locator || '');
_s('email', u.email || '');
const st = document.getElementById(`${pfx}-status`);
if (st) { st.textContent = I18n.t('profile_logged_in_as').replace('{user}', u.username).replace('{role}', u.role); st.style.color='var(--dim)'; }
}
} catch(e) {}
}
async function saveProfile(prefix = 'profile') {
const _g = field => document.getElementById(`${prefix}-${field}`)?.value?.trim() || '';
const data = {
name: _g('name'),
callsign: _g('callsign').toUpperCase(),
locator: _g('locator').toUpperCase(),
email: _g('email'),
};
const st = document.getElementById(`${prefix}-status`);
try {
const r = await fetch('/api/user/profile', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(data),
});
const res = await r.json();
if (res.ok) {
if (st) { st.textContent = I18n.t('profile_saved_short'); st.style.color='var(--green)'; }
window.UI?.showToast(I18n.t('profile_saved_short'));
if (data.callsign) {
const el = document.getElementById('callsign-display');
if (el) el.textContent = data.callsign;
if (window.CurrentUser) window.CurrentUser.callsign = data.callsign;
if (window.AppState) window.AppState.callsign = data.callsign;
}
// FIX (2026-09-03, live report: "widze usera na FT8 ale inny grid
// w makro a inny sie nadaje"): this block updated the cached
// CurrentUser.callsign above but never .locator - so an operator
// changing their own grid here left the FT8 macro PREVIEW
// (wsjtx.js's _myGrid, cached at page load) stale until a full
// reload, even though the backend already transmitted with the
// newly-saved value every time (it re-reads the account fresh,
// never caches it). WSJTX.refreshOperatorInfo() re-derives
// _myCall/_myGrid from the now-updated CurrentUser and refreshes
// the macro texts immediately, no reload needed.
if (data.locator && window.CurrentUser) {
window.CurrentUser.locator = data.locator;
window.WSJTX?.refreshOperatorInfo?.();
}
// Drugie miejsce (PROFIL <-> ADMIN) tez trzyma te dane - przeladuj
// oba zeby od razu pokazywaly to co wlasnie zapisano.
loadProfile();
} else {
if (st) { st.textContent = '✗ ' + (res.error||I18n.t('profile_error_fallback')); st.style.color='var(--red)'; }
}
} catch(e) { window.UI?.showToast('✗ ' + e.message, 'error'); }
}
async function changeProfilePassword() {
const old = document.getElementById('profile-old-pwd')?.value;
const np = document.getElementById('profile-new-pwd')?.value;
const np2 = document.getElementById('profile-new-pwd2')?.value;
const st = document.getElementById('profile-pwd-status');
if (!old || !np) { if(st){st.textContent=I18n.t('profile_fill_fields');st.style.color='var(--red)';} return; }
if (np !== np2) { if(st){st.textContent=I18n.t('profile_pwd_mismatch');st.style.color='var(--red)';} return; }
if (np.length < 8){ if(st){st.textContent=I18n.t('profile_pwd_min_len');st.style.color='var(--red)';} return; }
try {
const r = await fetch('/api/auth/change-password', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({old_password: old, new_password: np}),
});
const res = await r.json();
if (res.ok) {
// Backend zwraca nowy token (stare uniewaznione przez pw_ver).
// Zapisz go, inaczej biezaca sesja przestanie dzialac.
if (res.token) {
localStorage.setItem('token', res.token);
sessionStorage.setItem('ham_token', res.token);
}
if(st){st.textContent=I18n.t('profile_pwd_changed_short');st.style.color='var(--green)';}
['profile-old-pwd','profile-new-pwd','profile-new-pwd2'].forEach(id=>{
const el=document.getElementById(id); if(el) el.value='';
});
window.UI?.showToast(I18n.t('profile_pwd_changed_toast'));
} else {
if(st){st.textContent='✗ '+(res.error||I18n.t('profile_error_fallback'));st.style.color='var(--red)';}
}
} catch(e) { window.UI?.showToast('✗ '+e.message,'error'); }
}
async function changeOwnPassword() {
const old = document.getElementById('old-pwd')?.value;
const n1 = document.getElementById('new-pwd')?.value;
const n2 = document.getElementById('new-pwd2')?.value;
if (!old || !n1) { UI.showToast('Wypełnij wszystkie pola', 'error'); return; }
if (n1 !== n2) { UI.showToast('Nowe hasła nie są identyczne', 'error'); return; }
if (n1.length < 8) { UI.showToast('Min. 8 znaków', 'error'); return; }
const r = await fetch('/api/auth/change-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_password: old, new_password: n1 }),
});
const res = await r.json();
if (res.ok) { UI.showToast('✓ Hasło zmienione — zaloguj się ponownie'); setTimeout(() => Auth.logout(), 2000); }
else UI.showToast('✗ ' + (res.error || 'Błąd'), 'error');
}
// ── Debounce dla tunelu ───────────────────────────────────────────────────────
let _tnSaveTimer = null;
function debounceSave() {
clearTimeout(_tnSaveTimer);
_tnSaveTimer = setTimeout(() => typeof Tunnel !== 'undefined' && Tunnel.saveTunnelConfig(false), 1500);
}
// ── CW: wymuszenie siatki 2×3 (max 6 makr widocznych) ────────────────────────
// ── #app-scale: skalowanie calej strony do viewportu ─────────────────────────
// Referencyjna szerokosc projektu (1440px) — wszystkie elementy zachowuja
// identyczne proporcje/pozycje wzgledem tej szerokosci, niezaleznie od
// faktycznej rozdzielczosci ekranu. transform:scale() jest jednolite (ten
// sam wspolczynnik dla X i Y, liczony z szerokosci), wiec wysokosc elementow
// skaluje sie proporcjonalnie razem z szerokoscia — zachowuje wzgledne
// pozycje "px-perfect" w obu osiach.
//
// Strony dluzsze niz REF_H (1080px) w skali 1:1 po przeskalowaniu nadal
// przewijaja sie wertykalnie — liczymy naturalna (nieskalowana) wysokosc
// #app-scale (scrollHeight) i ustawiamy body.height = scrollHeight*scale,
// zeby scrollbar mial wlasciwy zakres.
(function () {
// REF_W=1440 (nie 1920): layout byl projektowany dla ~1400-1600px
// szerokosci (typowy laptop). Przy REF_W=1920 na typowych ekranach
// (1366-1600px) scale<1, wiec wszystko (fonty, ikony, slidery) wydawalo
// sie zbyt male/nieczytelne. Z REF_W=1440: na 1920px monitorze scale≈1.33
// (wieksze, czytelne), na 1366px laptopie scale≈0.95 (blisko 1:1, jak w
// oryginalnym projekcie). Proporcje/pozycje wzgledne pozostaja identyczne
// na kazdym ekranie — zmienia sie tylko OGOLNY rozmiar (skala).
const REF_W = 1440;
const REF_H = 1080;
// MIN_SCALE history: a 0.75 floor was added 2026-09-03 so baseline
// 8-11px labels wouldn't shrink past ~5-6 physical px on a narrow
// window - but that traded away the thing users actually wanted more:
// live report 2026-10-15 ("resize tez nie dziala, po zmniejszeniu
// przegladarka dostaje paski do przesuwania a powinno sie zawsze
// miescic w oknie") confirmed the floor is what was causing the
// horizontal scrollbar/"sliding" feel on a shrunk window - below
// 1080px wide, scale stopped shrinking and the page started
// overflowing sideways instead. Dropped to 0.2 - low enough to never
// actually engage on any real window size, kept only as a last-resort
// guard against scale hitting 0 (undrawable) on a pathological vw.
// Shrinking all the way down with the window, text size and all, is
// the explicitly requested behavior now, not a fallback to avoid.
const MIN_SCALE = 0.2;
const el = document.getElementById('app-scale');
if (!el) return;
function updateScale() {
// "Opcja 2" (2026-10-16): Radio i FT8 (wsjtx) sa teraz widokami o
// STALEJ wysokosci, bez przewijania strony — przewijaja sie wylacznie
// poszczegolne kolumny/listy w ich wnetrzu (np. tile-left, tile-right),
// a srodek (VFO, Waterfall / scope) pozostaje w 100% nieruchomy.
// Pozostale zakladki (USTAWIENIA, LOG, itp.) scrolluja sie normalnie,
// jak dotychczas.
const isRadio = !!document.getElementById('page-radio')?.classList.contains('active');
const isWsjtx = !!document.getElementById('page-wsjtx')?.classList.contains('active');
if (isRadio || isWsjtx) {
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
} else {
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
}
// clientWidth (nie innerWidth) — wylacza szerokosc pionowego scrollbara
// z obliczenia.
// FIX (2026-10-16, live report: "prawy panel w zakladce RADIO nie jest
// spasowany z przegladarka, za nim jest puste miejsce"): RADIO used to
// get its own scale formula here - min(scaleW, scaleH) - to keep the
// waterfall from looking squished-tall on 16:9 monitors. But browser
// chrome (tabs/address bar) always eats into innerHeight without
// touching innerWidth, so on almost any real window the height-based
// scale ends up smaller than the width-based one and wins the min() -
// body.style.width below is then set to REF_W*scale, which is LESS
// than the actual viewport width, leaving a dead gap on the right
// (exactly where .tile-right/rotator lives). FT8 never had this bug
// because it always used plain vw/REF_W (the else branch) - RADIO now
// matches it, so every tab fills the window width with no gap.
const vw = document.documentElement.clientWidth;
const scale = Math.max(MIN_SCALE, vw / REF_W);
el.style.transform = `scale(${scale})`;
// Dostepna wysokosc referencyjna (ponizej topbara 52px) — udostepniana
// jako zmienna CSS, zeby .tile-grid / #page-wsjtx mogly dokladnie
// wypelnic reszte okna bez przewijania strony.
const availRefH = window.innerHeight / scale;
const gridH = Math.max(500, Math.floor(availRefH - 52));
document.documentElement.style.setProperty('--radio-grid-h', gridH + 'px');
document.documentElement.style.setProperty('--wsjtx-h', gridH + 'px');
document.documentElement.classList.toggle('page-radio-active', isRadio);
document.documentElement.classList.toggle('page-wsjtx-active', isWsjtx);
document.body.classList.toggle('page-radio-active', isRadio);
document.body.classList.toggle('page-wsjtx-active', isWsjtx);
let contentH;
if (isRadio || isWsjtx) {
contentH = availRefH;
document.body.style.height = window.innerHeight + 'px';
} else {
// Naturalna (nieskalowana) wysokosc zawartosci #app-scale (scrollHeight
// nie jest wplywany przez transform) — pozostale zakladki moga byc
// dluzsze niz viewport i wtedy przewijaja sie wertykalnie.
contentH = Math.max(el.scrollHeight, REF_H);
document.body.style.height = (contentH * scale) + 'px';
}
document.body.style.width = (REF_W * scale) + 'px';
if (isRadio && typeof window.Waterfall?.resize === 'function') {
window.Waterfall.resize();
}
}
window.updateScale = updateScale;
window.addEventListener('resize', updateScale);
window.addEventListener('orientationchange', updateScale);
// Re-mierz przy zmianie zakladki (page-content active/hidden) — to
// jedyny przypadek gdy calkowita wysokosc strony realnie sie zmienia.
// NIE obserwujemy calego #app-scale (subtree) — telemetria (VFO, S-meter,
// log) mutuje DOM co ~0.3s i wywolalaby reflow (scrollHeight) bez potrzeby.
let _raf = null;
const scheduleUpdate = () => {
if (_raf) return;
_raf = requestAnimationFrame(() => { _raf = null; updateScale(); });
};
window.scheduleScaleUpdate = scheduleUpdate;
document.querySelectorAll('.page-content').forEach(p => {
new MutationObserver(scheduleUpdate).observe(p, { attributes: true, attributeFilter: ['class'] });
});
updateScale();
})();
</script>
</div><!-- /app-scale -->
<!-- TOAST — DELIBERATELY outside #app-scale (2026-10-18, live report:
"jak zjade oknem na dol to toastu nie zobaczysz"). #app-scale has
transform:scale() applied (see updateScale() above) - CSS spec: any
ancestor with `transform` set becomes the containing block for its
fixed-position descendants, so .toast's `position:fixed` was anchored
to #app-scale's own (tall, scrollable) box instead of the real browser
viewport, scrolling out of view along with the page content instead of
staying pinned to the visible window. Moving it here (a direct body
child, no transformed ancestor) restores real fixed-to-viewport
behavior - always visible regardless of scroll position. -->
<div class="toast" id="toast"></div>
<!-- ─── AUDIO DEVICE MANAGEMENT ──────────────────────────────────────────────── -->
<script>
// Dopasowanie DOKLADNE ma pierwszenstwo; jesli nie ma - szukaj opcji ktorej
// nazwa ZAWIERA sie w "wanted" albo odwrotnie (ten sam wzorzec co backend juz
// uzywa przy weryfikacji hot-swap karty w /api/audio/config, webapp.py:
// "rx in n or n in rx"). Windows potrafi zwrocic nazwe karty USB troche
// inaczej zapisana miedzy kolejnymi odpytaniami (numeracja portu, kolejnosc
// WASAPI) - select.value przy dokladnym niedopasowaniu CICHO wraca na
// pierwsza opcje ("domyslna"), mimo ze w configu nadal jest zapisana
// wlasciwa karta. Std HTML behavior, nie blad przegladarki.
function _selectByFuzzyName(selectEl, wanted) {
if (!selectEl || !wanted) return;
for (const opt of selectEl.options) {
if (opt.value === wanted) { selectEl.value = wanted; return; }
}
for (const opt of selectEl.options) {
if (opt.value && (opt.value.includes(wanted) || wanted.includes(opt.value))) {
selectEl.value = opt.value;
return;
}
}
}
async function _audioDeviceRefresh() {
console.log('[audio-cfg] _audioDeviceRefresh() start');
const st = document.getElementById('cfg-audio-status');
if (st) st.textContent = I18n.t('cfg_loading_cards');
try {
const r = await fetch('/api/audio/enumerate');
const d = await r.json();
console.log('[audio-cfg] /api/audio/enumerate ->', d);
const rx = document.getElementById('cfg-audio-rx');
const tx = document.getElementById('cfg-audio-tx');
if (!rx || !tx) {
console.warn('[audio-cfg] _audioDeviceRefresh: brak elementow #cfg-audio-rx/#cfg-audio-tx w DOM, przerywam');
return;
}
const rxList = d.devices?.rx || [], txList = d.devices?.tx || [];
// ZABEZPIECZENIE: backend potrafi (rzadko, mimo wewnetrznego retry —
// patrz audio_rust_bridge.py list_devices) oddac chwilowo PUSTA liste
// (WASAPI/cpal zajete). Jesli lista juz ma prawdziwe opcje (nie tylko
// placeholder "domyslna"), NIE czyscimy jej pusta odpowiedzia - to by
// wygladalo jak "zapomniana" karta mimo ze config.json ma ja poprawnie.
if (!rxList.length && !txList.length && (rx.options.length > 1 || tx.options.length > 1)) {
console.warn('[audio-cfg] _audioDeviceRefresh: pusta odpowiedz enumerate mimo istniejacej listy - zachowuje poprzednia liste, pomijam wyczyszczenie');
if (st) st.textContent = I18n.t('cfg_empty_response_kept').replace('{source}', d.source);
return;
}
const curRx = rx.value, curTx = tx.value;
rx.innerHTML = `<option value="">${I18n.t('cfg_default_dash')}</option>`;
tx.innerHTML = `<option value="">${I18n.t('cfg_default_dash')}</option>`;
(d.devices?.rx || []).forEach(name => {
const o = document.createElement('option');
o.value = o.textContent = name;
rx.appendChild(o);
});
(d.devices?.tx || []).forEach(name => {
const o = document.createElement('option');
o.value = o.textContent = name;
tx.appendChild(o);
});
_selectByFuzzyName(rx, curRx);
_selectByFuzzyName(tx, curTx);
if (st) st.textContent = I18n.t('cfg_cards_summary').replace('{source}', d.source).replace('{rx}', (d.devices?.rx||[]).length).replace('{tx}', (d.devices?.tx||[]).length);
console.log('[audio-cfg] _audioDeviceRefresh() koniec: rx.value=', rx.value, 'tx.value=', tx.value,
'opcje rx=', Array.from(rx.options).map(o=>o.value), 'opcje tx=', Array.from(tx.options).map(o=>o.value));
} catch(e) {
console.warn('[audio-cfg] _audioDeviceRefresh() blad:', e);
if (st) st.textContent = '✗ ' + e.message;
}
}
async function _audioDeviceLoad() {
console.log('[audio-cfg] _audioDeviceLoad() start');
// Załaduj listę kart najpierw
await _audioDeviceRefresh();
// Zaznacz zapisane urządzenia z audio/status
try {
const r = await fetch('/api/audio/status');
const d = await r.json();
console.log('[audio-cfg] /api/audio/status ->', 'rx_device=', d.rx_device, 'tx_device=', d.tx_device,
'txVolume=', d.txVolume, 'txVolumeSsb=', d.txVolumeSsb);
const rx = document.getElementById('cfg-audio-rx');
const tx = document.getElementById('cfg-audio-tx');
const br = document.getElementById('cfg-audio-bitrate');
_selectByFuzzyName(rx, d.rx_device);
_selectByFuzzyName(tx, d.tx_device);
console.log('[audio-cfg] _audioDeviceLoad() po selectByFuzzyName: rx.value=', rx?.value, 'tx.value=', tx?.value);
if (br && d.opus_bitrate) br.value = d.opus_bitrate;
// TX Volume — brakowalo tego (mimo komentarza wyzej ze "robi jedno i
// drugie"). Suwak zostawal na statycznym HTML "4" niezaleznie od
// realnie zapisanej wartosci (serwer ja poprawnie zwraca, patrz
// /api/audio/status w webapp.py - front po prostu jej nie czytal).
const tv = document.getElementById('cfg-tx-volume');
const tvVal = document.getElementById('cfg-tx-volume-val');
if (tv && typeof d.txVolume === 'number') {
tv.value = d.txVolume;
if (tvVal) tvVal.textContent = d.txVolume + 'x';
}
const tvSsb = document.getElementById('cfg-tx-volume-ssb');
const tvSsbVal = document.getElementById('cfg-tx-volume-ssb-val');
if (tvSsb && typeof d.txVolumeSsb === 'number') {
tvSsb.value = d.txVolumeSsb;
if (tvSsbVal) tvSsbVal.textContent = d.txVolumeSsb + 'x';
}
const gm = document.getElementById('cfg-tx-gain-mode-ssb');
if (gm && (d.txGainModeSsb === 'manual' || d.txGainModeSsb === 'agc')) {
gm.value = d.txGainModeSsb;
}
_txGainModeSsbToggle();
} catch(e) {}
}
// Tryb "agc" i suwak TX Volume SSB są wzajemnie wykluczające się (either/or) -
// gdy AGC wybrane, suwak jest tylko wygaszony wizualnie (żeby nie wyglądał,
// jakby wciąż coś robił), sama wartość zostaje w configu nietknięta na
// wypadek powrotu do trybu "manual".
function _txGainModeSsbToggle() {
const gm = document.getElementById('cfg-tx-gain-mode-ssb');
const tvSsb = document.getElementById('cfg-tx-volume-ssb');
if (!gm || !tvSsb) return;
const isAgc = gm.value === 'agc';
tvSsb.disabled = isAgc;
tvSsb.style.opacity = isAgc ? '0.4' : '1';
}
async function _audioDeviceSave() {
const rx = document.getElementById('cfg-audio-rx')?.value || '';
const tx = document.getElementById('cfg-audio-tx')?.value || '';
const br = parseInt(document.getElementById('cfg-audio-bitrate')?.value || '24000');
const tvEl = document.getElementById('cfg-tx-volume');
const tv = tvEl ? parseFloat(tvEl.value) : null;
const tvSsbEl = document.getElementById('cfg-tx-volume-ssb');
const tvSsb = tvSsbEl ? parseFloat(tvSsbEl.value) : null;
const gainModeSsb = document.getElementById('cfg-tx-gain-mode-ssb')?.value || 'manual';
const st = document.getElementById('cfg-audio-status');
if (st) st.textContent = I18n.t('cfg_saving');
try {
const payload = {rxDevice: rx, txDevice: tx, bitrate: br, txGainModeSsb: gainModeSsb};
if (tv !== null && !isNaN(tv)) payload.txVolume = tv;
if (tvSsb !== null && !isNaN(tvSsb)) payload.txVolumeSsb = tvSsb;
const r = await fetch('/api/audio/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const d = await r.json();
if (st) st.textContent = d.ok ? I18n.t('cfg_saved_applied') : '✗ ' + d.error;
if (d.ok) UI.showToast(I18n.t('cfg_toast_audio_saved'));
} catch(e) {
if (st) st.textContent = '✗ ' + e.message;
}
}
// Zaladuj przy wejsciu na zakladke KONFIGURACJA (nie USTAWIENIA - pola
// cfg-audio-rx/tx/cfg-tx-volume zyja w #page-config, nie w #page-settings;
// bylo tu zle dopiete, wiec ten hak nigdy realnie nie odswiezal wlasciwej
// zakladki - ratowal to tylko rowlolegly _audioDeviceLoad() wolany z
// toggleExpert() w audio_autodetect.js po kliknieciu TRYB EKSPERT).
document.addEventListener('DOMContentLoaded', () => {
const origBtn = document.querySelector('[onclick="UI.setPage(\'config\')"]');
if (origBtn) {
const orig = origBtn.getAttribute('onclick');
origBtn.setAttribute('onclick', orig + '; console.log("[audio-cfg] tab KONFIGURACJA kliknieta"); _audioDeviceLoad()');
console.log('[audio-cfg] hak na przycisk KONFIGURACJA podpiety poprawnie');
} else {
console.warn('[audio-cfg] NIE znaleziono przycisku KONFIGURACJA do podpiecia haka _audioDeviceLoad()');
}
});
</script>
<!-- ── DeepCW — plywajace okno dekodera CW ─────────────────────────────────── -->
<div id="deepcw-modal" style="display:none;position:fixed;right:20px;bottom:20px;
width:420px;z-index:900;background:#0d0d0d;border:1px solid #2a2a2a;
border-radius:4px;box-shadow:0 6px 24px rgba(0,0,0,.6);font-family:var(--mono);">
<div id="deepcw-drag-bar" style="display:flex;align-items:center;justify-content:space-between;
padding:6px 10px;background:#141414;border-bottom:1px solid #2a2a2a;
cursor:move;user-select:none;">
<span style="font-size:11px;color:#4cf;letter-spacing:1px;">◈ DEKODER CW</span>
<div style="display:flex;align-items:center;gap:6px;">
<button onclick="DeepCW.capture()" title="Nagraj 15 s audio tak, jak slyszy je model (diagnostyka)"
style="background:none;border:1px solid #333;border-radius:2px;color:#888;
font-family:var(--mono);font-size:9px;padding:1px 6px;cursor:pointer;">REC</button>
<button onclick="DeepCW.clearOutput()" title="Wyczysc"
style="background:none;border:1px solid #333;border-radius:2px;color:#888;
font-family:var(--mono);font-size:9px;padding:1px 6px;cursor:pointer;">CLR</button>
<button onclick="DeepCW.closeModal()" title="Zamknij (zatrzymuje dekoder)"
style="background:none;border:1px solid #333;border-radius:2px;color:#888;
font-family:var(--mono);font-size:11px;padding:0 6px;cursor:pointer;">×</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:6px;padding:6px 10px;
border-bottom:1px solid #1a1a1a;">
<select id="deepcw-source" style="background:#0a0a0a;border:1px solid #2a2a2a;
color:var(--green);font-family:var(--mono);font-size:10px;padding:3px 6px;
border-radius:2px;">
<option value="radio">Radio (audio RX)</option>
<option value="mic">Mikrofon</option>
</select>
<button id="deepcw-start-btn" onclick="DeepCW.toggleStart()"
style="background:none;border:1px solid var(--green2);border-radius:3px;
color:var(--green);font-family:var(--mono);font-size:10px;padding:3px 10px;
cursor:pointer;letter-spacing:1px;">▶ START</button>
<canvas id="deepcw-vu" width="60" height="8"
style="border:1px solid #222;border-radius:2px;background:#080808;"></canvas>
</div>
<div id="deepcw-output" style="height:150px;overflow-y:auto;padding:8px 10px;
font-size:12px;line-height:1.55;letter-spacing:.5px;word-break:break-word;
background:#080808;"></div>
<!-- Linia podgladu — to, co model slyszy TERAZ, jeszcze bez potwierdzenia
glosowaniem. Wyprzedza zapis powyzej o ok. 2 s. Tresc przesuwa sie przez
nia i osiada w oknie glownym, gdy konsensus ja potwierdzi. -->
<div style="display:flex;align-items:center;gap:6px;padding:4px 10px;
border-top:1px solid #1a1a1a;background:#0a0a0a;">
<span style="font-size:9px;color:#444;letter-spacing:1px;flex-shrink:0;">NA ŻYWO</span>
<span id="deepcw-preview" style="font-size:12px;color:#888;font-style:italic;
letter-spacing:.5px;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;flex:1;min-width:0;"></span>
</div>
<div id="deepcw-log" style="padding:4px 10px;font-size:9px;color:#666;
border-top:1px solid #1a1a1a;">Kliknij START zeby zaczac dekodowanie</div>
</div>
<script src="js/deepcw.js?v=202608130001"></script>
</body>
</html>