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, } from 'lucide-react'; import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetListsSettings, SaveListsSettings, GetCATSettings, SaveCATSettings, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, ListClusterServers, SaveClusterServer, DeleteClusterServer, GetClusterAutoConnect, SetClusterAutoConnect, ConnectClusterServer, DisconnectClusterServer, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, GetQSLDefaults, SaveQSLDefaults, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestLoTWUpload, ListTQSLStationLocations, ComputeStationInfo, } 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 { 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: '', 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, created_at: '' as any, updated_at: '' as any, }); interface Props { initialSection?: string; onClose: () => void; onSaved: () => void; } // 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 = | 'station' | 'profiles' | 'operating' | 'confirmations' | 'external-services' | 'udp' | 'lookup' | 'lists-bands' | 'lists-modes' | 'cluster' | 'backup' | 'database' | 'awards' | 'cat' | 'rotator' | 'antenna' | 'audio'; type TreeNode = | { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] } | { kind: 'item'; label: string; id: SectionId; disabled?: boolean }; const TREE: TreeNode[] = [ { kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [ { kind: 'item', label: 'Station Information', id: 'station' }, { kind: 'item', label: 'Profiles', id: 'profiles' }, { kind: 'item', label: 'Operating conditions', id: 'operating' }, { kind: 'item', label: 'Confirmations', id: 'confirmations' }, { kind: 'item', label: 'External services', id: 'external-services' }, ], }, { kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [ { kind: 'item', label: 'Callsign Lookup', id: 'lookup' }, { kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [ { kind: 'item', label: 'Bands', id: 'lists-bands' }, { kind: 'item', label: 'Modes & default RST', id: 'lists-modes' }, ]}, { kind: 'item', label: 'DX Cluster', id: 'cluster' }, { kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' }, { kind: 'item', label: 'Database backup', id: 'backup' }, { kind: 'item', label: 'Database location', id: 'database' }, { kind: 'item', label: 'Awards', id: 'awards', disabled: true }, ], }, { kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [ { kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' }, { kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' }, { kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true }, { kind: 'item', label: 'Audio devices', id: 'audio', disabled: true }, ], }, ]; // 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 location', udp: 'UDP integrations', awards: 'Awards', cat: 'CAT interface', rotator: 'Rotator', antenna: 'Antenna', audio: 'Audio devices', }; // ===== Tree component ===== interface TreeProps { selected: SectionId; onSelect: (id: SectionId) => void; } function Tree({ selected, onSelect }: TreeProps) { 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 ===== function SectionHeader({ title, hint }: { title: string; hint?: string }) { return (

{title}

{hint &&

{hint}

}
); } function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) { const label = SECTION_LABELS[id] ?? id; const IconCmp = Icon ?? Construction; return (
{label}
Module coming soon.
); } export function SettingsModal({ onClose, onSaved, initialSection }: Props) { 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, 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); 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; }; const [qslDefaults, setQslDefaults] = useState({ qsl_sent: '', qsl_rcvd: '', lotw_sent: '', lotw_rcvd: '', eqsl_sent: '', eqsl_rcvd: '', clublog_status: '', hrdlog_status: '', qrzcom_status: '', }); // 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; force_station_callsign: string; tqsl_path: string; station_location: string; key_password: string; upload_flag: string; write_log: boolean; auto_upload: boolean; upload_mode: string; }; type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg }; const emptyExtCfg = (): ExtServiceCfg => ({ api_key: '', email: '', username: '', password: '', callsign: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', upload_flag: 'R', write_log: false, auto_upload: false, upload_mode: 'immediate', }); const [extSvc, setExtSvc] = useState({ qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: 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 [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' | 'hamqth' | 'lotw'>('qrz'); 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(''); 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); setBackupCfg(b as any); setQslDefaults(qd as any); setExtSvc(es as any); try { setDbSettings(await GetDatabaseSettings() 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 */ } } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setLoading(false); } })(); }, []); // 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() { 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 SaveBackupSettings(backupCfg as any); await SaveQSLDefaults(qslDefaults as any); await SaveExternalServices(extSvc as any); await SetClusterAutoConnect(clusterAutoConnect); setMsg('Settings saved.'); onSaved(); setTimeout(onClose, 500); } 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(() => SECTION_LABELS[selected] ?? selected, [selected]); // === Section content renderers === function StationPanel() { if (!activeProfile) { return
Loading profile…
; } const p = activeProfile; return ( <>
updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
What's transmitted (ADIF STATION_CALLSIGN).
updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
Who's at the radio (ADIF OPERATOR).
updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).
Auto-filled from the callsign — editable (stamped as MY_* on each QSO)
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(); onSaved(); } catch (e: any) { setErr(String(e?.message ?? e)); } } async function profileRemove() { if (!currentProfile) return; if (!confirm(`Delete profile "${currentProfile.name}"? All its settings will be lost.`)) 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(`Name for the new profile (copy of "${currentProfile.name}"):`, `${currentProfile.name} Copy`); 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('Name for the new profile:', 'New profile'); 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="Profile name" disabled={!current} /> {current?.is_active && ( ACTIVE )}
{current && !current.is_active && (
You're viewing {current.name}. The active profile is {active?.name} — its values are stamped on new QSOs. Click Set active to switch.
)}
); } 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="User" autoComplete="off" /> setLookup((s) => ({ ...s, [pwdField]: e.target.value }))} placeholder="Password" autoComplete="off" /> {test?.msg} ); }; return ( <>
{row('qrz', 'QRZ.com', 'qrz_user', 'qrz_password')} {row('hamqth', 'HamQTH', 'hamqth_user', 'hamqth_password')}
Provider Primary Failsafe User Password Result

Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely.

Display

Cache

Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.

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 */}
Available
{available.length === 0 ? (
All catalog bands selected.
) : ( available.map((b) => ( )) )}
setBandDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { addBand(bandDraft); setBandDraft(''); } }} placeholder="Custom band (e.g. 4m)" className="font-mono h-7 text-xs" />
{/* Center: shuttle hint */}
{/* Right: selected */}
Selected ({selected.length})
{selected.length === 0 ? (
No band selected — pick from the left.
) : ( 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 */}
Available
{available.length === 0 ? (
All catalog modes selected.
) : ( available.map((m) => ( )) )}
setModeDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { addCustomMode(modeDraft); setModeDraft(''); } }} placeholder="Custom mode" className="font-mono uppercase h-7 text-xs" />
{/* Center: shuttle hint */}
{/* Right: selected with editable RST */}
Order Mode RST snt RST rcv
{selected.length === 0 ? (
No mode selected — pick from the left.
) : ( 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. */}
RST report lists
The choices offered in the entry form's RST dropdowns, per mode family. One value per line (or space-separated). The first one is the top of the list.