import { useEffect, useMemo, useState } from 'react'; import { ArrowDown, ArrowUp, Copy, Plus, Star, StarOff, Trash2, ChevronDown, ChevronRight, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, Compass, Wifi, Construction, } 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, } 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, EventsOff } 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'; 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; const emptyProfile = (): Profile => ({ id: 0, name: '', callsign: '', operator: '', 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; } /* ====== 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' | 'lookup' | 'lists-bands' | 'lists-modes' | 'cluster' | 'backup' | '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 (portable, home, contest)', id: 'profiles' }, ], }, { 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: 'Backup / Export', id: 'backup', disabled: true }, { 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', lookup: 'Callsign Lookup', 'lists-bands': 'Bands', 'lists-modes': 'Modes & default RST', cluster: 'DX Cluster', backup: 'Backup / Export', 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: [] }); const [bandsText, setBandsText] = 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); 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(() => { EventsOn('cluster:state', async (st: any) => { setClusterStatuses((st ?? []) as ClusterServerStatus[]); try { const list = await ListClusterServers(); setClusterServers((list ?? []) as ClusterServer[]); } catch {} }); return () => { EventsOff('cluster:state'); }; }, []); 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] = await Promise.all([ GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(), GetRotatorSettings(), ]); setLookup(l); setActiveProfile(ap as Profile); setLists(ls); await reloadProfiles(); await reloadClusterServers(); setBandsText((ls.bands ?? []).join('\n')); setCatCfg(c); setRotator(r); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setLoading(false); } })(); }, []); function addMode() { setLists((l) => ({ ...l, modes: [...(l.modes ?? []), { name: '', 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. const seen = new Set(); const bands: string[] = []; for (const line of bandsText.split('\n')) { const b = line.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 !== ''); await SaveListsSettings({ bands, modes } 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 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" />
updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
updateActive({ my_country: e.target.value })} placeholder="France" />
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" />
updateActive({ my_rig: e.target.value })} placeholder="Flex 8600" />
updateActive({ my_antenna: e.target.value })} placeholder="Ultrabeam UB40" />
updateActive({ tx_pwr: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) })} placeholder="100" />
); } // 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() { return ( <>