import { useEffect, useMemo, useState } from 'react'; import { ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ChevronDown, ChevronRight, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, } from 'lucide-react'; import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetListsSettings, SaveListsSettings, GetCATSettings, SaveCATSettings, DiscoverFlexRadios, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, GetAntGeniusSettings, SaveAntGeniusSettings, GetPGXLSettings, SavePGXLSettings, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, GetSecretStatus, SetPassphrase, RemovePassphrase, GetEmailSettings, SaveEmailSettings, TestEmail, QSLGetEmailTemplates, QSLSaveEmailTemplates, GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus, AudioStartMonitor, AudioStopMonitor, AudioMonitorActive, AudioStartTX, AudioStopTX, AudioTXActive, ListClusterServers, SaveClusterServer, DeleteClusterServer, GetClusterAutoConnect, SetClusterAutoConnect, ConnectClusterServer, DisconnectClusterServer, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram, GetTelemetryEnabled, SetTelemetryEnabled, GetLiveStatusEnabled, SetLiveStatusEnabled, GetQSLDefaults, SaveQSLDefaults, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, GetPOTAToken, SavePOTAToken, TestLoTWUpload, ListTQSLStationLocations, ComputeStationInfo, GetUIPref, SetUIPref, GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { writeUiPref } from '@/lib/uiPref'; import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme'; import { OperatingPanel } from '@/components/OperatingPanel'; import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel'; type LookupSettings = LookupSettingsForm; type StationSettings = StationSettingsForm; type ListsSettings = ListsSettingsForm; type ModePreset = ModePresetForm; type CATSettings = Omit; type RotatorSettings = Omit; type ClusterServer = Omit; type ClusterServerStatus = Omit; type Profile = Omit; // Catalog of all standard ADIF bands, in natural frequency order. The user // picks a subset on the right; everything else in the UI (entry strip, // band-slot grid, band-map switcher) iterates that subset. const BAND_CATALOG = [ '2190m','630m','560m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m', '8m','6m','5m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm', '6mm','4mm','2.5mm','2mm','1mm', ]; // Catalog of common ADIF modes with sensible RST defaults. When the user // picks one on the right, the RSTs are pre-filled but stay editable. const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [ { name: 'SSB', sent: '59', rcvd: '59' }, { name: 'CW', sent: '599', rcvd: '599' }, { name: 'AM', sent: '59', rcvd: '59' }, { name: 'FM', sent: '59', rcvd: '59' }, { name: 'DIGITALVOICE', sent: '59', rcvd: '59' }, { name: 'FT8', sent: '-10', rcvd: '-10' }, { name: 'FT4', sent: '-10', rcvd: '-10' }, { name: 'JS8', sent: '-10', rcvd: '-10' }, { name: 'MSK144', sent: '+00', rcvd: '+00' }, { name: 'JT65', sent: '-15', rcvd: '-15' }, { name: 'JT9', sent: '-15', rcvd: '-15' }, { name: 'Q65', sent: '-15', rcvd: '-15' }, { name: 'FST4', sent: '-15', rcvd: '-15' }, { name: 'FST4W', sent: '-15', rcvd: '-15' }, { name: 'WSPR', sent: '-20', rcvd: '-20' }, { name: 'RTTY', sent: '599', rcvd: '599' }, { name: 'PSK31', sent: '599', rcvd: '599' }, { name: 'PSK63', sent: '599', rcvd: '599' }, { name: 'PSK125', sent: '599', rcvd: '599' }, { name: 'OLIVIA', sent: '599', rcvd: '599' }, { name: 'CONTESTI', sent: '599', rcvd: '599' }, { name: 'MFSK', sent: '599', rcvd: '599' }, { name: 'THROB', sent: '599', rcvd: '599' }, { name: 'HELL', sent: '599', rcvd: '599' }, { name: 'PACKET', sent: '599', rcvd: '599' }, { name: 'PACTOR', sent: '599', rcvd: '599' }, { name: 'VARA', sent: '599', rcvd: '599' }, { name: 'VARA HF', sent: '599', rcvd: '599' }, { name: 'ARDOP', sent: '599', rcvd: '599' }, { name: 'ATV', sent: '59', rcvd: '59' }, { name: 'SSTV', sent: '59', rcvd: '59' }, { name: 'C4FM', sent: '59', rcvd: '59' }, { name: 'DSTAR', sent: '59', rcvd: '59' }, { name: 'DMR', sent: '59', rcvd: '59' }, { name: 'FUSION', sent: '59', rcvd: '59' }, ]; const emptyProfile = (): Profile => ({ id: 0, name: '', callsign: '', operator: '', op_name: '', owner_callsign: '', my_grid: '', my_country: '', my_state: '', my_cnty: '', my_street: '', my_city: '', my_postal_code: '', my_sota_ref: '', my_pota_ref: '', my_rig: '', my_antenna: '', tx_pwr: undefined, is_active: false, sort_order: 0, db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' }, // Server-managed timestamps — send null (NOT "") so Go's time.Time unmarshal // leaves the zero value instead of failing to parse an empty RFC3339 string. created_at: null as any, updated_at: null as any, }); interface Props { initialSection?: string; onClose: () => void; onSaved: () => void; onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane } // Pretty little card showing what OpsLog will stamp on each QSO based on // the callsign + grid in the Station Information form. Debounces the // backend resolver so we don't fire on every keystroke; refreshes when // inputs change. Empty card when no callsign yet. /* ====== Tree definition ====== Section IDs are stable strings — adding new ones means adding a panel below. `disabled: true` greys them out and shows the "coming soon" placeholder. */ type SectionId = | 'general' | 'email' | 'station' | 'profiles' | 'operating' | 'confirmations' | 'external-services' | 'udp' | 'lookup' | 'lists-bands' | 'lists-modes' | 'cluster' | 'backup' | 'database' | 'autostart' | 'awards' | 'cat' | 'rotator' | 'winkeyer' | 'antenna' | 'antgenius' | 'pgxl' | 'flex' | 'audio'; type TreeNode = | { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] } | { kind: 'item'; label: string; id: SectionId; disabled?: boolean }; // buildTree returns the settings sidebar. The FlexRadio item only appears when // the active CAT backend is a Flex (per-band antenna config is Flex-specific). function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] { const hardware: TreeNode[] = [ { kind: 'item', label: t('sec.cat'), id: 'cat' }, { kind: 'item', label: t('sec.rotator'), id: 'rotator' }, { kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' }, { kind: 'item', label: t('sec.antenna'), id: 'antenna' }, { kind: 'item', label: t('sec.antgenius'), id: 'antgenius' }, { kind: 'item', label: t('sec.pgxl'), id: 'pgxl' }, ...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []), { kind: 'item', label: t('sec.audio'), id: 'audio' }, ]; return [ { kind: 'group', label: t('nav.user'), icon: User, defaultOpen: true, children: [ { kind: 'item', label: t('sec.station'), id: 'station' }, { kind: 'item', label: t('sec.profiles'), id: 'profiles' }, { kind: 'item', label: t('sec.operating'), id: 'operating' }, { kind: 'item', label: t('sec.confirmations'), id: 'confirmations' }, { kind: 'item', label: t('sec.external'), id: 'external-services' }, ], }, { kind: 'group', label: t('nav.software'), icon: Cog, defaultOpen: true, children: [ { kind: 'item', label: t('sec.general'), id: 'general' }, { kind: 'item', label: t('sec.email'), id: 'email' }, { kind: 'item', label: t('sec.lookup'), id: 'lookup' }, { kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [ { kind: 'item', label: t('sec.bands'), id: 'lists-bands' }, { kind: 'item', label: t('sec.modes'), id: 'lists-modes' }, ]}, { kind: 'item', label: t('sec.cluster'), id: 'cluster' }, { kind: 'item', label: t('sec.udp'), id: 'udp' }, { kind: 'item', label: t('sec.database'), id: 'database' }, { kind: 'item', label: t('sec.autostart'), id: 'autostart' }, ], }, { kind: 'group', label: t('nav.hardware'), icon: Server, defaultOpen: true, children: hardware, }, ]; } // Map section id → i18n key (breadcrumb / placeholders). const SECTION_KEY: Partial> = { station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations', 'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes', cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp', awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna', antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email', }; // Map section id → friendly name (used in breadcrumb / placeholders). const SECTION_LABELS: Partial> = { station: 'Station Information', profiles: 'Profiles', operating: 'Operating conditions', confirmations: 'Confirmations', 'external-services': 'External services', lookup: 'Callsign Lookup', 'lists-bands': 'Bands', 'lists-modes': 'Modes & default RST', cluster: 'DX Cluster', backup: 'Database backup', database: 'Database', autostart: 'Autostart', udp: 'UDP integrations', awards: 'Awards', cat: 'CAT interface', rotator: 'PstRotator', winkeyer: 'CW Keyer', antenna: 'UltraBeam', antgenius: 'Antenna Genius', pgxl: 'Power Genius', flex: 'FlexRadio', audio: 'Audio devices', }; // ===== Tree component ===== interface TreeProps { selected: SectionId; onSelect: (id: SectionId) => void; flexAvailable?: boolean; } function Tree({ selected, onSelect, flexAvailable }: TreeProps) { const { t } = useI18n(); return ( ); } function TreeNodeView({ node, depth, selected, onSelect, }: { node: TreeNode; depth: number; selected: SectionId; onSelect: (id: SectionId) => void }) { if (node.kind === 'item') { const isActive = selected === node.id; return ( ); } // group const [open, setOpen] = useState(node.defaultOpen ?? false); const Icon = node.icon; return (
{open && (
{node.children.map((c, i) => ( ))}
)}
); } // ===== Section content panels ===== // Representative surface/card/accent swatch for each theme (picker preview — // the theme is not applied until selected). Module-scope so their component // identity is STABLE across SettingsModal re-renders; defining them inside the // component would give each render a fresh function, remounting the Radix // Select and slamming the open dropdown shut on any ambient re-render. const THEME_SWATCH: Record, { bg: string; card: string; accent: string }> = { 'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' }, 'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' }, 'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' }, 'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' }, }; function ThemeSwatch({ theme }: { theme: Exclude }) { const s = THEME_SWATCH[theme]; return ( ); } // Auto swatch = split light/dark, since it follows the OS preference. function ThemeOptionSwatch({ theme }: { theme: ThemeChoice }) { if (theme === 'auto') { return ( ); } return ; } function ThemeSelector() { const { theme, setTheme } = useTheme(); const { t } = useI18n(); const options: ThemeChoice[] = ['auto', ...CONCRETE_THEMES]; return (
); } function SectionHeader({ title, hint }: { title: string; hint?: string }) { return (

{title}

{hint &&

{hint}

}
); } // ProfileScopeNote flags that the panel's settings are saved per-profile, so // the user knows which operating identity (F4BPO / TM2Q / …) they're editing. function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: string } }) { const { t } = useI18n(); return (
{t('profileScope.saved')} {profile?.name || '—'} {profile?.callsign ? ({profile.callsign}) : null} {' '}{t('profileScope.switch')}
); } // AutostartPanelComponent manages the per-profile list of external programs to // launch when OpsLog starts. It's a self-contained component (its own state) so // it can use hooks — rendered via the `() => ` wrapper // in PANELS. Changes persist immediately (config is local SQLite, cheap writes). type AutostartProg = { id: string; name: string; path: string; args: string; enabled: boolean }; function AutostartPanelComponent() { const { t } = useI18n(); const [progs, setProgs] = useState([]); const [loaded, setLoaded] = useState(false); const [err, setErr] = useState(''); const [launchMsg, setLaunchMsg] = useState>({}); async function load() { try { setProgs(((await GetAutostartPrograms()) ?? []) as any); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setLoaded(true); } } useEffect(() => { load(); }, []); useEffect(() => { const off = EventsOn('profile:changed', () => load()); return () => { if (typeof off === 'function') off(); }; }, []); async function commit(next: AutostartProg[]) { setProgs(next); try { await SaveAutostartPrograms(next as any); setErr(''); } catch (e: any) { setErr(String(e?.message ?? e)); } } const patch = (id: string, p: Partial) => commit(progs.map((x) => (x.id === id ? { ...x, ...p } : x))); const remove = (id: string) => commit(progs.filter((x) => x.id !== id)); async function addProgram() { try { const path = await BrowseExecutable(); if (!path) return; const base = path.split(/[\\/]/).pop() || path; const name = base.replace(/\.(exe|bat|cmd)$/i, ''); const id = (crypto as any)?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; commit([...progs, { id, name, path, args: '', enabled: true }]); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function rebrowse(id: string) { try { const path = await BrowseExecutable(); if (path) patch(id, { path }); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function launchNow(id: string) { try { const r: any = await LaunchAutostartProgram(id); const txt = r?.status === 'launched' ? '✓ launched' : r?.status === 'already_running' ? 'already running — not started again' : r?.status === 'missing' ? '✗ executable not found' : (r?.message || r?.status || 'done'); setLaunchMsg((m) => ({ ...m, [id]: txt })); } catch (e: any) { setLaunchMsg((m) => ({ ...m, [id]: String(e?.message ?? e) })); } } return ( <>
{loaded && progs.length === 0 && (

No programs yet — add one below.

)} {progs.map((p) => (
patch(p.id, { enabled: !!c })} title="Launch at startup" /> patch(p.id, { name: e.target.value })} />
patch(p.id, { args: e.target.value })} />
{launchMsg[p.id] &&
{launchMsg[p.id]}
}
))} {err &&
{err}
}
); } // TelemetryToggle is a self-contained opt-out for the anonymous usage heartbeat // (a random install ID + version + OS, sent once a day). Real component so it // can own its state; embedded inside GeneralPanel. function TelemetryToggle() { const [on, setOn] = useState(true); const [loaded, setLoaded] = useState(false); useEffect(() => { GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true)); }, []); return ( ); } // LiveStatusToggle publishes this operator's current activity (call + band + // freq + mode) to the shared MySQL `live_status` table every ~15s, for multi-op // events — a small web script on your server renders it for the QRZ page. Only // useful on a MySQL logbook. Self-contained component (owns its async state). function LiveStatusToggle() { const [on, setOn] = useState(false); const [loaded, setLoaded] = useState(false); useEffect(() => { GetLiveStatusEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true)); }, []); return ( ); } // MainViewPanes lets the operator choose what the Main tab's left and right // panes show, independently: the great-circle map, the locator street map, the // cluster grid or the worked-before grid. Per-profile (stored via SetUIPref, // which is profile-prefixed). Self-contained so it owns its async-loaded state. const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [ { value: 'map1', label: 'Map — great-circle + beam' }, { value: 'map2', label: 'Map — locator (street)' }, { value: 'cluster', label: 'Cluster spots' }, { value: 'worked', label: 'Worked before' }, { value: 'recent', label: 'Recent QSOs' }, { value: 'netcontrol', label: 'Net control' }, ]; function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) { const [left, setLeft] = useState('map1'); const [right, setRight] = useState('map2'); // Radio-control panes are only offered when that CAT backend is active. Sorted A→Z. const options = [ ...MAIN_PANE_OPTIONS, ...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []), ...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []), ].sort((a, b) => a.label.localeCompare(b.label)); useEffect(() => { const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v); Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')]) .then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); }); }, []); const pick = (side: 'left' | 'right', v: string) => { if (side === 'left') setLeft(v); else setRight(v); // Persist (per-profile) AND tell the parent the new value directly, so the // Main view updates from the chosen value — never a stale DB re-read. SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {}); onChanged?.(side, v); }; return (

Main view

Choose what the Main tab shows on each side (per profile).

); } // FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one // (fills the IP/port). Self-contained so it can own its state (rendered inside // the hook-less CATPanel). function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }) { const [busy, setBusy] = useState(false); const [found, setFound] = useState>([]); const [msg, setMsg] = useState(''); async function scan() { setBusy(true); setMsg(''); try { const r = ((await DiscoverFlexRadios()) ?? []) as any[]; setFound(r as any); if (r.length === 0) setMsg('No radio found — check it\'s on the same network, or enter the IP manually.'); } catch (e: any) { setMsg(String(e?.message ?? e)); } finally { setBusy(false); } } return (
listens for FlexRadio broadcast on the LAN
{found.map((r) => ( ))} {msg &&
{msg}
}
); } function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) { const label = SECTION_LABELS[id] ?? id; const IconCmp = Icon ?? Construction; return (
{label}
Module coming soon.
); } // FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically // when the band changes (frequency change / spot click). Antennas come live from // the connected FlexRadio; bands come from Lists → Bands. function FlexBandAntennasPanel({ bands }: { bands: string[] }) { const [rxList, setRxList] = useState([]); const [txList, setTxList] = useState([]); const [map, setMap] = useState>({}); const [msg, setMsg] = useState(''); useEffect(() => { GetFlexState().then((s: any) => { setRxList((s?.ant_list ?? []) as string[]); setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]); }).catch(() => {}); GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {}); }, []); const set = (band: string, side: 'rx' | 'tx', v: string) => { const key = band.toUpperCase(); setMap((m) => { const cur = m[key] ?? { rx: '', tx: '' }; const next = { ...m, [key]: { ...cur, [side]: v } }; SaveFlexBandAntennas(next as any) .then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); }) .catch((e: any) => setMsg(String(e?.message ?? e))); return next; }); }; return (

FlexRadio — per-band antennas

Choose the RX and TX antenna for each band. They're applied automatically when the band changes (frequency change or clicking a spot).

{rxList.length === 0 && (
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
)} {bands.length === 0 ? (

No bands configured — add them in Lists → Bands.

) : (
{bands.map((b) => { const e = map[b.toUpperCase()] ?? { rx: '', tx: '' }; return ( ); })}
Band RX antenna TX antenna
{b}
)} {msg && {msg}}
); } // Known Icom models paired with their FACTORY default CI-V address. Picking a // model sets icom_addr so the backend identifies it (civ.ModelName) and the UI // adapts (e.g. the attenuator steps differ by model). Keep in lockstep with // civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal. const ICOM_MODELS: { name: string; addr: number }[] = [ { name: 'IC-705', addr: 0xA4 }, { name: 'IC-7300', addr: 0x94 }, { name: 'IC-7610', addr: 0x98 }, { name: 'IC-7700', addr: 0x88 }, { name: 'IC-7800', addr: 0x80 }, { name: 'IC-9100', addr: 0x7C }, { name: 'IC-9700', addr: 0xA2 }, ]; export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) { const { t } = useI18n(); const [selected, setSelected] = useState((initialSection as SectionId) || 'station'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [clearing, setClearing] = useState(false); const [msg, setMsg] = useState(''); const [err, setErr] = useState(''); const [lookup, setLookup] = useState({ qrz_user: '', qrz_password: '', hamqth_user: '', hamqth_password: '', primary: '', failsafe: '', download_images: false, cache_ttl_days: 30, }); // Per-provider Test state — keeps the success/error feedback adjacent // to the button. Cleared on the next test run for that provider. type TestResult = { ok: boolean; msg: string }; const [lookupTest, setLookupTest] = useState>({}); const [lookupTesting, setLookupTesting] = useState>({}); // The Station Information panel now edits the full active profile // (not a flat 6-field StationSettings). Profile selection happens in // the Profiles panel; any edit here saves back to whichever profile // is currently active. const [activeProfile, setActiveProfile] = useState(null); const updateActive = (patch: Partial) => setActiveProfile((p) => (p ? { ...p, ...patch } : p)); const [lists, setLists] = useState({ bands: [], modes: [], rst_phone: [], rst_cw: [], rst_digital: [] }); // RST report lists edited as free text (one/space-separated values). const [rstText, setRstText] = useState({ phone: '', cw: '', digital: '' }); // Custom band drafts (catalog covers ADIF spec but the user may have // exotic or experimental bands not listed). const [bandDraft, setBandDraft] = useState(''); const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, digital_default: 'FT8', }); const [rotator, setRotator] = useState({ enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false, }); const [rotatorTesting, setRotatorTesting] = useState(false); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); // Ultrabeam antenna (TCP) settings. const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({ enabled: false, host: '', port: 23, follow: false, step_khz: 50, }); const [ubTesting, setUbTesting] = useState(false); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); // Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007. const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' }); // PowerGenius XL (4O3A) amp fan-control settings. const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 }); // WinKeyer CW keyer settings + macro editor. type WKMac = { label: string; text: string }; type WKSettings = { enabled: boolean; engine: string; esc_clears_call: boolean; port: string; baud: number; wpm: number; weight: number; lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number; sidetone_hz: number; mode: string; swap: boolean; autospace: boolean; use_ptt: boolean; serial_echo: boolean; macros: WKMac[]; }; const [wk, setWk] = useState({ enabled: false, engine: 'winkeyer', esc_clears_call: true, port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10, tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b', swap: false, autospace: true, use_ptt: false, serial_echo: true, macros: [], }); const [wkPorts, setWkPorts] = useState([]); const setWkField = (patch: Partial) => setWk((s) => ({ ...s, ...patch })); // ── Audio (DVK + QSO recorder) ── type AudioSettings = { from_radio: string; to_radio: string; recording_device: string; listening_device: string; qso_record: boolean; qso_dir: string; preroll_seconds: number; ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3'; from_gain: number; mic_gain: number; }; type AudioDev = { id: string; name: string; default: boolean }; const [audioCfg, setAudioCfg] = useState({ from_radio: '', to_radio: '', recording_device: '', listening_device: '', qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav', from_gain: 100, mic_gain: 100, }); const [audioInputs, setAudioInputs] = useState([]); const [audioOutputs, setAudioOutputs] = useState([]); const setAudioField = (patch: Partial) => setAudioCfg((s) => ({ ...s, ...patch })); const reloadAudioDevices = () => { ListAudioInputDevices().then((d) => setAudioInputs((d ?? []) as AudioDev[])).catch(() => {}); ListAudioOutputDevices().then((d) => setAudioOutputs((d ?? []) as AudioDev[])).catch(() => {}); }; // DVK voice-keyer messages (F1–F6). type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number }; type DVKStat = { recording: boolean; playing: boolean; rec_slot: number }; const [dvkMsgs, setDvkMsgs] = useState([]); const [dvkStat, setDvkStat] = useState({ recording: false, playing: false, rec_slot: 0 }); const [dvkErr, setDvkErr] = useState(''); const [monitorOn, setMonitorOn] = useState(false); const [txOn, setTxOn] = useState(false); useEffect(() => { AudioMonitorActive().then(setMonitorOn).catch(() => {}); AudioTXActive().then(setTxOn).catch(() => {}); }, []); const toggleMonitor = async () => { try { if (monitorOn) { await AudioStopMonitor(); setMonitorOn(false); } else { await AudioStartMonitor(); setMonitorOn(true); } } catch (err: any) { setDvkErr(String(err?.message ?? err)); } }; const toggleTX = async () => { try { if (txOn) { await AudioStopTX(); setTxOn(false); } else { await AudioStartTX(); setTxOn(true); } } catch (err: any) { setDvkErr(String(err?.message ?? err)); } }; // General behaviour prefs (mirrored to the DB so they travel with data/). const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0'); const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1'); // Password-encryption (secret vault) state. const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false }); const [ppNew, setPpNew] = useState(''); const [ppConfirm, setPpConfirm] = useState(''); const [ppErr, setPpErr] = useState(''); const [ppBusy, setPpBusy] = useState(false); const refreshSecret = async () => { try { setSecret(await GetSecretStatus() as any); } catch {} }; useEffect(() => { refreshSecret(); }, []); const applyPassphrase = async () => { if (ppNew !== ppConfirm) { setPpErr('Passphrases do not match'); return; } setPpBusy(true); setPpErr(''); try { await SetPassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); } catch (e: any) { setPpErr(String(e?.message ?? e)); } finally { setPpBusy(false); } }; const removePassphrase = async () => { setPpBusy(true); setPpErr(''); try { await RemovePassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); } catch (e: any) { setPpErr(String(e?.message ?? e)); } finally { setPpBusy(false); } }; // E-mail / SMTP (send QSO recordings). type EmailCfg = { enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string; from: string; reply_to: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string; }; const [emailCfg, setEmailCfg] = useState({ enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '', from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '', }); const [emailMsg, setEmailMsg] = useState(''); const setEmailField = (patch: Partial) => setEmailCfg((s) => ({ ...s, ...patch })); // eQSL card e-mail (subject/body templates + auto-send on log). type EQSLCfg = { subject: string; body: string; auto_send: boolean }; const [eqslCfg, setEqslCfg] = useState({ subject: '', body: '', auto_send: false }); const setEqslField = (patch: Partial) => setEqslCfg((s) => ({ ...s, ...patch })); // ClubLog Country File (cty.xml) exception status. type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number }; const [clubInfo, setClubInfo] = useState({ enabled: false, loaded: false, date: '', count: 0 }); const [clubBusy, setClubBusy] = useState(false); const [clubErr, setClubErr] = useState(''); useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []); const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); }; useEffect(() => { GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {}); const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat)); return () => { off?.(); }; }, []); type QSLDefaults = { qsl_sent: string; qsl_rcvd: string; lotw_sent: string; lotw_rcvd: string; eqsl_sent: string; eqsl_rcvd: string; clublog_status: string; hrdlog_status: string; qrzcom_status: string; qrzcom_confirmed: string; }; const [qslDefaults, setQslDefaults] = useState({ qsl_sent: '', qsl_rcvd: '', lotw_sent: '', lotw_rcvd: '', eqsl_sent: '', eqsl_rcvd: '', clublog_status: '', hrdlog_status: '', qrzcom_status: '', qrzcom_confirmed: '', }); // External services (logbook upload). One block per service; only QRZ is // wired today. upload_mode is 'immediate' | 'delayed' (per-service). type ExtServiceCfg = { api_key: string; email: string; username: string; password: string; callsign: string; code: string; qth_nickname: string; force_station_callsign: string; tqsl_path: string; station_location: string; key_password: string; upload_flags: string[]; write_log: boolean; auto_upload: boolean; upload_mode: string; }; type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg }; const emptyExtCfg = (): ExtServiceCfg => ({ api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', upload_flags: ['N', 'R'], write_log: false, auto_upload: false, upload_mode: 'immediate', }); const [extSvc, setExtSvc] = useState({ qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), }); const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null); const [qrzTesting, setQrzTesting] = useState(false); const [clublogTest, setClublogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [clublogTesting, setClublogTesting] = useState(false); const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null); const [lotwTesting, setLotwTesting] = useState(false); const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hrdlogTesting, setHrdlogTesting] = useState(false); const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); const [eqslTesting, setEqslTesting] = useState(false); const [stationLocations, setStationLocations] = useState([]); // Active tab in the External Services panel — lifted here because // PANELS[selected]() is called as a function, so panels can't hold hooks. const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz'); // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log). const [potaToken, setPotaToken] = useState(''); const [potaBusy, setPotaBusy] = useState(false); const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string; unmatched?: any[] } | null>(null); useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []); const [backupCfg, setBackupCfg] = useState({ enabled: false, folder: '', rotation: 5, zip: false, last_backup_at: '', default_folder: '', } as any); const [backupRunning, setBackupRunning] = useState(false); const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null); const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false }); const [dbMsg, setDbMsg] = useState(''); type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string }; const [mysqlCfg, setMysqlCfg] = useState({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' }); const setMysqlField = (patch: Partial) => setMysqlCfg((s) => ({ ...s, ...patch })); const [mysqlMsg, setMysqlMsg] = useState(''); const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply" const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null); const [clusterServers, setClusterServers] = useState([]); const [clusterAutoConnect, setClusterAutoConnectState] = useState(false); const [clusterStatuses, setClusterStatuses] = useState([]); const [editingServer, setEditingServer] = useState(null); async function reloadClusterServers() { try { const [list, ac, st] = await Promise.all([ ListClusterServers(), GetClusterAutoConnect(), GetClusterStatus(), ]); setClusterServers((list ?? []) as ClusterServer[]); setClusterAutoConnectState(ac); setClusterStatuses((st ?? []) as ClusterServerStatus[]); } catch (e: any) { setErr(String(e?.message ?? e)); } } // Live cluster status updates while Preferences is open — the user can // click Connect/Disconnect inside the modal and see the pills change // without saving + reopening. useEffect(() => { const unsub = EventsOn('cluster:state', async (st: any) => { setClusterStatuses((st ?? []) as ClusterServerStatus[]); try { const list = await ListClusterServers(); setClusterServers((list ?? []) as ClusterServer[]); } catch {} }); return () => { unsub?.(); }; }, []); const [profiles, setProfiles] = useState([]); // State for ProfilesPanel — lifted here because PANELS[selected]() calls // the panel as a plain function, not as a JSX element, so any useState // inside the panel function would violate the Rules of Hooks. const [profileSelectedId, setProfileSelectedId] = useState(0); const [profileNameDraft, setProfileNameDraft] = useState(''); async function reloadProfiles() { try { const list = await ListProfiles(); setProfiles(list); // Refresh the active-profile editor in case activation changed. const ap = await GetActiveProfile(); setActiveProfile(ap as Profile); } catch (e: any) { setErr(String(e?.message ?? e)); } } // Keep the ProfilesPanel selector in sync with the loaded list. If the // currently-selected profile is gone (post-delete) or none is selected // yet, default to the active one. useEffect(() => { if (!profiles.length) return; const stillThere = profiles.some((p) => (p.id as number) === profileSelectedId); if (!stillThere) { const next = profiles.find((p) => p.is_active) ?? profiles[0]; setProfileSelectedId(next.id as number); setProfileNameDraft(next.name); } }, [profiles, profileSelectedId]); useEffect(() => { (async () => { try { const [l, ls, c, ap, r, b, qd, es] = await Promise.all([ GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(), GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(), ]); setLookup(l); setActiveProfile(ap as Profile); setLists(ls); setRstText({ phone: ((ls as any).rst_phone ?? []).join(' '), cw: ((ls as any).rst_cw ?? []).join(' '), digital: ((ls as any).rst_digital ?? []).join(' '), }); await reloadProfiles(); await reloadClusterServers(); setCatCfg(c); setRotator(r); try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} try { setPgxl(await GetPGXLSettings() as any); } catch {} setBackupCfg(b as any); setQslDefaults(qd as any); setExtSvc(es as any); try { setDbSettings(await GetDatabaseSettings() as any); } catch {} try { setMysqlCfg(await GetMySQLSettings() as any); } catch {} try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} try { const locs: any = await ListTQSLStationLocations(); setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean)); } catch { /* TQSL not installed — leave the dropdown empty */ } try { setWk(await GetWinkeyerSettings() as any); } catch {} try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {} try { setAudioCfg(await GetAudioSettings() as any); } catch {} try { setEmailCfg(await GetEmailSettings() as any); } catch {} try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {} reloadAudioDevices(); reloadDvk(); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setLoading(false); } })(); }, []); // Every setting is per-profile, so when the active profile changes WHILE this // dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…) // — otherwise they keep showing the previous profile's values until reopen. useEffect(() => { const off = EventsOn('profile:changed', () => { (async () => { try { setMysqlCfg(await GetMySQLSettings() as any); } catch {} try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} try { setActiveProfile(await GetActiveProfile() as Profile); } catch {} try { setLookup(await GetLookupSettings() as any); } catch {} try { setCatCfg(await GetCATSettings() as any); } catch {} try { setRotator(await GetRotatorSettings() as any); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} try { setPgxl(await GetPGXLSettings() as any); } catch {} try { setBackupCfg(await GetBackupSettings() as any); } catch {} try { setQslDefaults(await GetQSLDefaults() as any); } catch {} try { setExtSvc(await GetExternalServices() as any); } catch {} try { setWk(await GetWinkeyerSettings() as any); } catch {} try { setAudioCfg(await GetAudioSettings() as any); } catch {} try { setEmailCfg(await GetEmailSettings() as any); } catch {} try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {} try { const ls: any = await GetListsSettings(); setLists(ls); setRstText({ phone: (ls.rst_phone ?? []).join(' '), cw: (ls.rst_cw ?? []).join(' '), digital: (ls.rst_digital ?? []).join(' '), }); } catch {} })(); }); return () => { off(); }; }, []); // Auto-fill the active profile's MY_* DXCC metadata from the station // callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These // are derived values, so they always recompute when the callsign or grid // changes — the user can still edit a field, it just re-populates when the // source changes. Debounced so we don't hammer cty.dat while typing. useEffect(() => { const call = (activeProfile?.callsign ?? '').trim(); if (!call) return; const grid = (activeProfile?.my_grid ?? '').trim(); const t = window.setTimeout(async () => { try { const i: any = await ComputeStationInfo(call, grid); setActiveProfile((p) => { if (!p) return p; const patch: any = {}; if (i.country) patch.my_country = i.country; if (i.dxcc) patch.my_dxcc = i.dxcc; if (i.cqz) patch.my_cqz = i.cqz; if (i.ituz) patch.my_ituz = i.ituz; if (i.lat) patch.my_lat = i.lat; if (i.lon) patch.my_lon = i.lon; // Only re-render when a value actually changed (prevents loops). const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]); return changed ? { ...p, ...patch } : p; }); } catch { /* offline / unknown prefix — leave fields as-is */ } }, 250); return () => window.clearTimeout(t); }, [activeProfile?.callsign, activeProfile?.my_grid]); // ── Band selection helpers (dual-list shuttle) ────────────────────────── function addBand(tag: string) { const b = tag.trim().toLowerCase(); if (!b) return; setLists((l) => { if ((l.bands ?? []).includes(b)) return l; return { ...l, bands: [...(l.bands ?? []), b] }; }); } function removeBand(i: number) { setLists((l) => { const next = [...(l.bands ?? [])]; next.splice(i, 1); return { ...l, bands: next }; }); } function moveBand(i: number, dir: -1 | 1) { setLists((l) => { const next = [...(l.bands ?? [])]; const j = i + dir; if (j < 0 || j >= next.length) return l; [next[i], next[j]] = [next[j], next[i]]; return { ...l, bands: next }; }); } // ── Mode helpers ──────────────────────────────────────────────────────── function addMode() { setLists((l) => ({ ...l, modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }], })); } function addModeFromCatalog(m: { name: string; sent: string; rcvd: string }) { setLists((l) => { if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === m.name)) return l; return { ...l, modes: [...(l.modes ?? []), { name: m.name, default_rst_sent: m.sent, default_rst_rcvd: m.rcvd }], }; }); } function addCustomMode(name: string) { const n = name.trim().toUpperCase(); if (!n) return; setLists((l) => { if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === n)) return l; return { ...l, modes: [...(l.modes ?? []), { name: n, default_rst_sent: '59', default_rst_rcvd: '59' }], }; }); } function removeMode(i: number) { setLists((l) => { const next = [...(l.modes ?? [])]; next.splice(i, 1); return { ...l, modes: next }; }); } function moveMode(i: number, dir: -1 | 1) { setLists((l) => { const next = [...(l.modes ?? [])]; const j = i + dir; if (j < 0 || j >= next.length) return l; [next[i], next[j]] = [next[j], next[i]]; return { ...l, modes: next }; }); } function updateMode(i: number, patch: Partial) { setLists((l) => { const next = [...(l.modes ?? [])]; next[i] = { ...next[i], ...patch } as ModePreset; return { ...l, modes: next }; }); } async function save(close = true) { setSaving(true); setErr(''); setMsg(''); try { // Bands: dedup, lowercase, trim. Order = user's drag order. const seen = new Set(); const bands: string[] = []; for (const raw of lists.bands ?? []) { const b = (raw ?? '').trim().toLowerCase(); if (b && !seen.has(b)) { seen.add(b); bands.push(b); } } const modes = (lists.modes ?? []) .map((m) => ({ name: (m.name ?? '').trim().toUpperCase(), default_rst_sent: (m.default_rst_sent ?? '').trim(), default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(), })) .filter((m) => m.name !== ''); const splitList = (s: string) => s.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean); await SaveListsSettings({ bands, modes, rst_phone: splitList(rstText.phone), rst_cw: splitList(rstText.cw), rst_digital: splitList(rstText.digital), } as any); if (activeProfile) { await SaveProfile({ ...activeProfile, callsign: (activeProfile.callsign ?? '').trim().toUpperCase(), operator: (activeProfile.operator ?? '').trim().toUpperCase(), my_grid: (activeProfile.my_grid ?? '').trim().toUpperCase(), my_sota_ref: (activeProfile.my_sota_ref ?? '').trim().toUpperCase(), my_pota_ref: (activeProfile.my_pota_ref ?? '').trim().toUpperCase(), } as any); } await SaveLookupSettings(lookup as any); await SaveCATSettings(catCfg as any); await SaveRotatorSettings(rotator as any); await SaveUltrabeamSettings(ultrabeam as any); await SaveAntGeniusSettings(antgenius as any); await SavePGXLSettings(pgxl as any); await SaveWinkeyerSettings(wk as any); await SaveAudioSettings(audioCfg as any); await SaveEmailSettings(emailCfg as any); await QSLSaveEmailTemplates(eqslCfg as any); await SaveBackupSettings(backupCfg as any); await SaveQSLDefaults(qslDefaults as any); await SaveExternalServices(extSvc as any); await SetClusterAutoConnect(clusterAutoConnect); setMsg('Settings saved.'); onSaved(); if (close) setTimeout(onClose, 500); else setTimeout(() => setMsg(''), 2000); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setSaving(false); } } async function clearCache() { setClearing(true); setErr(''); setMsg(''); try { await ClearLookupCache(); setMsg('Cache cleared.'); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setClearing(false); } } async function testProvider(provider: 'qrz' | 'hamqth') { setLookupTesting((s) => ({ ...s, [provider]: true })); setLookupTest((s) => ({ ...s, [provider]: undefined })); const user = provider === 'qrz' ? lookup.qrz_user : lookup.hamqth_user; const pwd = provider === 'qrz' ? lookup.qrz_password : lookup.hamqth_password; try { const r = await TestLookupProvider(provider, '', user ?? '', pwd ?? ''); setLookupTest((s) => ({ ...s, [provider]: { ok: true, msg: `OK — ${r.callsign} (${r.name || r.country || 'no name'})` } })); } catch (e: any) { setLookupTest((s) => ({ ...s, [provider]: { ok: false, msg: String(e?.message ?? e) } })); } finally { setLookupTesting((s) => ({ ...s, [provider]: false })); } } const breadcrumb = useMemo(() => { const k = SECTION_KEY[selected]; return k ? t(k) : (SECTION_LABELS[selected] ?? selected); }, [selected, t]); // === Section content renderers === function StationPanel() { if (!activeProfile) { return
{t('station.loading')}
; } const p = activeProfile; return ( <>
updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
{t('station.stationCallHint')}
updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
{t('station.opCallHint')}
updateActive({ owner_callsign: e.target.value })} placeholder={t('station.ownerBlank')} />
{t('station.ownerHint')}
updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
{t('station.opNameHint')}
{t('station.autofill')}
updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
updateActive({ my_country: e.target.value })} placeholder="France" />
updateActive({ my_dxcc: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
updateActive({ my_cqz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
updateActive({ my_ituz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
updateActive({ my_lat: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
updateActive({ my_lon: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
updateActive({ my_state: e.target.value })} />
updateActive({ my_cnty: e.target.value })} />
updateActive({ my_street: e.target.value })} />
updateActive({ my_postal_code: e.target.value })} />
updateActive({ my_city: e.target.value })} />
updateActive({ my_sota_ref: e.target.value })} placeholder="F/AB-001" />
updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
); } // Profile actions — kept at the SettingsModal level so the ProfilesPanel // renderer can stay hooks-free (the PANELS map calls it as a plain // function, not as a JSX component). const activeProfileObj = profiles.find((p) => p.is_active) ?? profiles[0]; const currentProfile = profiles.find((p) => (p.id as number) === profileSelectedId); async function profileActivate() { if (!currentProfile) return; try { await ActivateProfile(currentProfile.id as number); await reloadProfiles(); // Per-profile settings follow the active identity — reload the panels // that are now scoped to the newly-active profile. const [ap, qd, es] = await Promise.all([GetActiveProfile(), GetQSLDefaults(), GetExternalServices()]); setActiveProfile(ap as Profile); setQslDefaults(qd as any); setExtSvc(es as any); onSaved(); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function profileRemove() { if (!currentProfile) return; if (!confirm(t('prof.deleteConfirm', { name: currentProfile.name }))) return; try { await DeleteProfile(currentProfile.id as number); await reloadProfiles(); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function profileDuplicate() { if (!currentProfile) return; const name = prompt(t('prof.dupPrompt', { name: currentProfile.name }), t('prof.dupSuffix', { name: currentProfile.name })); if (!name?.trim()) return; try { const dup = await DuplicateProfile(currentProfile.id as number, name.trim()); await reloadProfiles(); setProfileSelectedId(dup.id as number); setProfileNameDraft(dup.name); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function profileCreateBlank() { const name = prompt(t('prof.newPrompt'), t('prof.newDefault')); if (!name?.trim()) return; try { const blank = emptyProfile(); blank.name = name.trim(); const saved = await SaveProfile(blank as any); await reloadProfiles(); setProfileSelectedId(saved.id as number); setProfileNameDraft(saved.name); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function profileRenameCurrent() { if (!currentProfile || profileNameDraft.trim() === currentProfile.name) return; try { await SaveProfile({ ...currentProfile, name: profileNameDraft.trim() } as any); await reloadProfiles(); } catch (e: any) { setErr(String(e?.message ?? e)); } } function ProfilesPanel() { const current = currentProfile; const active = activeProfileObj; return ( <>
setProfileNameDraft(e.target.value)} onBlur={profileRenameCurrent} placeholder={t('prof.profileName')} disabled={!current} /> {current?.is_active && ( {t('prof.active')} )}
{current && !current.is_active && (
{t('prof.viewingNote', { name: current.name, active: active?.name ?? '' })}
)}
); } function LookupPanel() { // Per-row provider editor — kept inline because it's only used twice // and needs closure access to the parent state. const row = ( key: 'qrz' | 'hamqth', label: string, userField: 'qrz_user' | 'hamqth_user', pwdField: 'qrz_password' | 'hamqth_password', ) => { const test = lookupTest[key]; const testing = lookupTesting[key]; const hasCreds = !!(lookup[userField] && lookup[pwdField]); return ( {label} setLookup((s) => ({ ...s, primary: key, failsafe: s.failsafe === key ? '' : s.failsafe }))} disabled={!hasCreds} /> setLookup((s) => ({ ...s, failsafe: key, primary: s.primary === key ? '' : s.primary }))} disabled={!hasCreds || lookup.primary === key} /> setLookup((s) => ({ ...s, [userField]: e.target.value }))} placeholder={t('lk.user')} autoComplete="off" /> setLookup((s) => ({ ...s, [pwdField]: e.target.value }))} placeholder={t('lk.password')} autoComplete="off" /> {test?.msg} ); }; return ( <>
{row('qrz', 'QRZ.com', 'qrz_user', 'qrz_password')} {row('hamqth', 'HamQTH', 'hamqth_user', 'hamqth_password')}
{t('lk.provider')} {t('lk.primary')} {t('lk.failsafe')} {t('lk.user')} {t('lk.password')} {t('lk.result')}

{t('lk.failsafeNote')}

{t('lk.display')}

{t('lk.cache')}

{t('lk.cacheHint')}

setLookup((s) => ({ ...s, cache_ttl_days: parseInt(e.target.value) || 30 }))} />
); } function BandsPanel() { const selected = lists.bands ?? []; const selectedSet = new Set(selected.map((b) => (b ?? '').toLowerCase())); const available = BAND_CATALOG.filter((b) => !selectedSet.has(b.toLowerCase())); return ( <>
{/* Left: available catalog */}
{t('bnd.available')}
{available.length === 0 ? (
{t('bnd.allSelected')}
) : ( available.map((b) => ( )) )}
setBandDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { addBand(bandDraft); setBandDraft(''); } }} placeholder={t('bnd.customPh')} className="font-mono h-7 text-xs" />
{/* Center: shuttle hint */}
{/* Right: selected */}
{t('bnd.selected', { n: selected.length })}
{selected.length === 0 ? (
{t('bnd.none')}
) : ( selected.map((b, i) => (
{b}
)) )}
); } function ModesPanel() { const selected = lists.modes ?? []; const selectedSet = new Set(selected.map((m) => (m.name ?? '').toUpperCase())); const available = MODE_CATALOG.filter((m) => !selectedSet.has(m.name)); return ( <>
{/* Left: available catalog */}
{t('bnd.available')}
{available.length === 0 ? (
{t('mds.allSelected')}
) : ( available.map((m) => ( )) )}
setModeDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { addCustomMode(modeDraft); setModeDraft(''); } }} placeholder={t('mds.customPh')} className="font-mono uppercase h-7 text-xs" />
{/* Center: shuttle hint */}
{/* Right: selected with editable RST */}
{t('mds.order')} {t('mds.mode')} {t('mds.rstSnt')} {t('mds.rstRcv')}
{selected.length === 0 ? (
{t('mds.none')}
) : ( selected.map((m, i) => (
updateMode(i, { name: e.target.value })} placeholder="SSB" /> updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" /> updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
)) )}
{/* RST report lists — the dropdown choices in the entry form. */}
{t('mds.rstLists')}
{t('mds.rstListsHint')}