import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ChevronDown, ChevronRight, User, Database, Radio, Cog, Server, Antenna as AntennaIcon, Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil, Minimize2, } from 'lucide-react'; import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetListsSettings, SaveListsSettings, GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete, GetChaseSettings, SaveChaseSettings, GetAudioMonitorPref, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, GetRotorPresets, SaveRotorPresets, ResetRotorPresets, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources, ApplyRDAChoices, GetAntGeniusSettings, SaveAntGeniusSettings, GetTunerGeniusSettings, SaveTunerGeniusSettings, GetPSUSettings, SavePSUSettings, GetSatSettings, SaveSatSettings, TestSatelliteRotator, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, GetSatelliteBirds, GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted, 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, GetSelfSpotSettings, SaveSelfSpotSettings, GetWorkedCallVariants, SetWorkedCallVariants, ConnectClusterServer, DisconnectClusterServer, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, RevealDataFolder, RenameLogbook, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram, GetTelemetryEnabled, SetTelemetryEnabled, GetQSLDefaults, SaveQSLDefaults, SetWinkeyerTrace, SetCIVTrace, CIVTraceEnabled, WinkeyerTraceEnabled, GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload, GetPOTAToken, SavePOTAToken, TestLoTWUpload, ListTQSLStationLocations, DownloadLoTWUsers, GetLoTWUsersStatus, GetScpStatus, SetScpEnabled, SetScpClublogEnabled, DownloadScp, DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount, GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList, GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom, ComputeStationInfo, GetUIPref, SetUIPref, GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower, GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile, GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow, GetRelayAuto, SaveRelayAuto, GetStationDevices, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetChaseNewBands, SetChaseNewBands, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, } 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, BrowserOpenURL } from '../../wailsjs/runtime/runtime'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { WebPublishPanel } from '@/components/WebPublishPanel'; import { Button } from '@/components/ui/button'; // Every text field in Preferences types into itself and hands the value up a // moment later. This dialog is one component with two hundred pieces of state // and panels eight hundred lines long, so a plain controlled input re-rendered // the whole thing per character — see BufferedInput. import { BufferedInput as Input } from '@/components/ui/buffered-input'; import { Combobox } from '@/components/ui/combobox'; 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 { setUseMiles } from '@/lib/units'; import { rotorStyle, setRotorStyle, type RotorStyle } from '@/lib/rotorStyle'; import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan'; import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat'; import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme'; import { OperatingPanel } from '@/components/OperatingPanel'; import { AppearancePanel } from '@/components/AppearancePanel'; import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel'; import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros'; import { CLUSTER_PRESETS } from '@/lib/clusterPresets'; import { MATRIX_DIGI_KEY, PHONE_MODES as MATRIX_PHONE_MODES } from '@/components/BandSlotGrid'; type LookupSettings = LookupSettingsForm; type StationSettings = StationSettingsForm; type ListsSettings = ListsSettingsForm; type ModePreset = ModePresetForm; type CATSettings = Omit; type RotatorDevice = 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: 'FT2', 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_iota: '', 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' | 'p3' | 'p4' | 'layout', 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 yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane elecraftAvailable?: boolean; // CAT backend is Elecraft/Kenwood → the K3/K4 console tciAvailable?: boolean; // CAT backend is TCI → the SunSDR console // Opens a QSO in the editor. Settings is not where a log is edited — but the // RDA comparison lists contacts whose district is in dispute, and a list of // things to fix that cannot be acted on is a list to write down and look up // again later. onEditQSO?: (id: number) => 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 = | 'general' | 'appearance' | 'email' | 'station' | 'profiles' | 'operating' | 'confirmations' | 'external-services' | 'udp' | 'adifmon' | 'foldersync' | 'webpublish' | 'lookup' | 'lists-bands' | 'lists-modes' | 'cluster' | 'dxhunter' | 'backup' | 'database' | 'autostart' | 'uscounties' | 'databases' | 'awards' | 'cat' | 'rotator' | 'winkeyer' | 'antenna' | 'antgenius' | 'tunergenius' | 'psu' | 'satellite' | 'pgxl' | 'flex' | 'relayauto' | 'audio'; type TreeNode = | { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] } | { kind: 'item'; label: string; id: SectionId; disabled?: boolean; vendor?: 'o3a' }; // A compact 4O3A wordmark for the sidebar, drawn here rather than shipped as an // image: an inline SVG scales with the row, follows the theme (currentColor), and // adds no binary asset. It marks which panels drive 4O3A hardware — the same // reason the language picker draws its flags inline. // // This is a plain wordmark, NOT the manufacturer's logo artwork. If you would // rather show the official mark, drop the SVG in and swap this component out. function VendorMark({ vendor }: { vendor: 'o3a' }) { if (vendor !== 'o3a') return null; // The 4O3A mark: "4", the green waveform, "3A". Drawn inline rather than // shipped as an image — it scales with the row, keeps the sidebar's own type // for the digits, and adds no binary asset. The waveform is the part that // carries the brand, so only it is SVG; the characters stay HTML text so they // match the surrounding menu at any theme or zoom. const lens = [3.2, 5.2, 7.4, 9.5, 7.4, 5.2, 3.2]; // half-heights, tallest in the middle return ( 4 3A ); } // PanelHost renders the selected settings panel, and exists so a panel may hold // hooks of its own. // // The panels are nested inside SettingsModal, closing over its state — so they // cannot be rendered as : a nested function is a NEW component type on // every parent render, which would unmount and remount the panel on each // keystroke. They were therefore CALLED, `PANELS[selected]()`, and a call runs // any hook inside them in SettingsModal's own hook list — conditionally, since // only the open section is called. React counts those, and the window stopped // drawing the moment such a section was opened (error #310, "rendered more // hooks than during the previous render"). It had happened once and was headed // off by a comment; the comment did not survive contact with the next panel. // // This host is module-scope, so its identity is stable and calling `render()` // inside it puts those hooks in a component context that persists. `key` is the // section, so switching sections REMOUNTS it — a fresh, consistent hook list per // section, and no panel state leaking into the next one. Within a section the // render prop is a new closure each parent render, which is how the panel keeps // seeing current values. function PanelHost({ render }: { render?: () => JSX.Element }) { return render ? render() : null; } // 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', vendor: 'o3a' }, { kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius', vendor: 'o3a' }, { kind: 'item', label: t('sec.pgxl'), id: 'pgxl' }, { kind: 'item', label: t('sec.psu'), id: 'psu' }, ...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []), { kind: 'item', label: t('sec.relayauto'), id: 'relayauto' }, { 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' }, // Not hardware, whatever the rotator block inside it suggests: which // satellites you chase, where your antenna stands and how old your // elements are is operating, and it sits with the rest of it. { kind: 'item', label: t('sec.satellite'), id: 'satellite' }, { kind: 'item', label: t('sec.confirmations'), id: 'confirmations' }, { kind: 'item', label: t('sec.awards'), id: 'awards' }, { 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.appearance'), id: 'appearance' }, { 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.dxhunter'), id: 'dxhunter' }, { kind: 'item', label: t('sec.udp'), id: 'udp' }, { kind: 'item', label: t('sec.adifmon'), id: 'adifmon' }, { kind: 'item', label: t('sec.foldersync'), id: 'foldersync' }, { kind: 'item', label: t('sec.webpublish'), id: 'webpublish' }, { kind: 'item', label: t('sec.uscounties'), id: 'uscounties' }, { kind: 'item', label: t('sec.database'), id: 'database' }, { kind: 'item', label: t('sec.autostart'), id: 'autostart' }, ], }, { // Maintenance is about DATA rather than settings: nothing in here changes // how OpsLog behaves, it refreshes what it knows. That is why the reference // lists moved out of the Awards manager — an operator updating SOTA is not // editing an award, and had to open one to do it. kind: 'group', label: t('nav.maintenance'), icon: Database, defaultOpen: true, children: [ { kind: 'item', label: t('sec.databases'), id: 'databases' }, ], }, { 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', appearance: 'sec.appearance', 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', adifmon: 'sec.adifmon', foldersync: 'sec.foldersync', webpublish: 'sec.webpublish', uscounties: 'sec.uscounties', databases: 'sec.databases', awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna', antgenius: 'sec.antgenius', tunergenius: 'sec.tunergenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email', relayauto: 'sec.relayauto', }; // 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', adifmon: 'ADIF monitor', webpublish: 'Web publishing', awards: 'Awards', cat: 'CAT interface', rotator: 'Rotator', winkeyer: 'CW Keyer', antenna: 'Ultrabeam / Steppir', antgenius: 'Antenna Genius', tunergenius: 'Tuner Genius', pgxl: 'Amplifier', flex: 'FlexRadio', relayauto: 'Relay auto-control', audio: 'Audio devices', }; // ===== Tree component ===== interface TreeProps { selected: SectionId; onSelect: (id: SectionId) => void; flexAvailable?: boolean; } // The sidebar. Memoised and its tree built once per (language, radio): it is // sixty-odd items that depend on nothing an operator types, and it was rebuilt // and re-rendered on every keystroke in every field of every panel — the whole // dialog holds its state in one component, so one character redraws all of it. const Tree = memo(function Tree({ selected, onSelect, flexAvailable }: TreeProps) { const { t } = useI18n(); const nodes = useMemo(() => buildTree(!!flexAvailable, t), [flexAvailable, t]); 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. // The bands the Chase new filter offers, in band-plan order. Mirrors // ChaseNewBands in pskchase.go, which is what actually filters the feed — // a band added there and not here is simply one nobody can switch off. const CHASE_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm']; const THEME_SWATCH: Record, { bg: string; card: string; accent: string }> = { 'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' }, 'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' }, 'light-sage': { bg: '#eef1ec', card: '#f8faf6', accent: '#2f855a' }, 'sahara': { bg: '#e6ded0', card: '#f3eee2', accent: '#c4501e' }, 'light-nordic': { bg: '#eef1f7', card: '#ffffff', accent: '#4f46e5' }, 'dim-slate': { bg: '#343b47', card: '#3d4552', accent: '#fb923c' }, 'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' }, 'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' }, 'dark-indigo': { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' }, 'dark-teal': { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' }, 'dark-plum': { bg: '#180f1e', card: '#251830', accent: '#f472b6' }, 'dxhunter': { bg: '#0f172a', card: '#1e293b', accent: '#3b82f6' }, 'dxhunter-orange': { bg: '#0f172a', card: '#1e293b', 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 })} />
{/* Only what OpsLog started is ever closed — a copy the operator opened themselves is theirs. The hint says so, because the difference is invisible from here. */} {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 { t } = useI18n(); const [on, setOn] = useState(true); const [loaded, setLoaded] = useState(false); useEffect(() => { GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true)); }, []); return ( ); } // ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM, // VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a // contact that arrives is imported and uploaded automatically like any log entry. type ADIFWatchFileUI = { path: string; enabled: boolean; offset: number }; type ADIFMonitorCfgUI = { enabled: boolean; files: ADIFWatchFileUI[] }; function ADIFMonitorPanel() { const { t } = useI18n(); const [cfg, setCfg] = useState({ enabled: false, files: [] }); const [loaded, setLoaded] = useState(false); useEffect(() => { GetADIFMonitor() .then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, files: (c.files ?? []) as ADIFWatchFileUI[] }); }) .catch(() => {}) .finally(() => setLoaded(true)); }, []); // Offsets are managed backend-side; SaveADIFMonitor ignores the ones we send and // keeps each existing file's read position (a new file starts at end-of-file). const persist = (next: ADIFMonitorCfgUI) => { setCfg(next); SaveADIFMonitor(next as any).catch(() => {}); }; const addFile = async () => { try { const p = await PickADIFMonitorFile(); if (!p || cfg.files.some((f) => f.path === p)) return; persist({ ...cfg, files: [...cfg.files, { path: p, enabled: true, offset: -1 }] }); } catch { /* dialog cancelled */ } }; return (

{t('adifmon.hint')}

{cfg.files.length === 0 &&

{t('adifmon.empty')}

} {cfg.files.map((f, i) => (
persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} /> {f.path}
))}

{t('adifmon.note')}

); } // FolderSyncPanel: one operator, several PCs, one logbook through a folder they // already synchronise (Seafile, OneDrive, Dropbox, a NAS share). // // The status half is the point of the panel. Every part of this runs on someone // else's machine and on a sync client OpsLog cannot see, so "it is not working" // has to be answerable from here: which other PCs have written to the folder, // when each last did, and whether anything is sitting there unread. function FolderSyncPanel() { const { t } = useI18n(); const [cfg, setCfg] = useState<{ enabled: boolean; folder: string; machine: string }>({ enabled: false, folder: '', machine: '' }); const [st, setSt] = useState(null); const [loaded, setLoaded] = useState(false); const [err, setErr] = useState(''); const [msg, setMsg] = useState(''); const [busy, setBusy] = useState(false); useEffect(() => { GetFolderSync() .then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, folder: c.folder ?? '', machine: c.machine ?? '' }); }) .catch(() => {}) .finally(() => setLoaded(true)); }, []); // Polled while the panel is open: a first setup is verified by watching the // other PC appear in this list, and that happens on the sync client's clock, // not on any action taken here. useEffect(() => { let alive = true; const tick = () => GetFolderSyncStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {}); tick(); const h = window.setInterval(tick, 3000); return () => { alive = false; window.clearInterval(h); }; }, []); const save = async (next: typeof cfg) => { setCfg(next); setErr(''); setMsg(''); try { await SaveFolderSync(next as any); setMsg(t('sync.saved')); GetFolderSyncStatus().then(setSt).catch(() => {}); } catch (e: any) { setErr(String(e?.message ?? e)); // The switch goes back off rather than sitting on while nothing is // written: a folder that refused the write test would lose every contact // in silence, which is the one outcome this feature must never have. setCfg({ ...next, enabled: false }); } }; const pick = async () => { try { const p = await PickFolderSyncFolder(); if (p) save({ ...cfg, folder: p }); } catch { /* dialog cancelled */ } }; const syncNow = async () => { setBusy(true); setErr(''); setMsg(''); try { const n = await SyncFolderNow(); setMsg(t('sync.applied').replace('{n}', String(n))); GetFolderSyncStatus().then(setSt).catch(() => {}); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setBusy(false); } }; const when = (iso: string) => (iso ? new Date(iso).toLocaleString() : t('sync.never')); return (

{t('sync.hint')}

{t('sync.machine')} setCfg({ ...cfg, machine: e.target.value })} onBlur={() => save(cfg)} /> {t('sync.folder')}
{cfg.folder || '—'}
{err &&

{err}

} {msg &&

{msg}

}
{t('sync.thisPc')}: {st?.machine_id || '—'} {t('sync.lastSync')}: {when(st?.last_sync ?? '')} {t('sync.sent')}: {st?.sent ?? 0} {t('sync.received')}: {st?.received ?? 0}
{st?.error &&

{st.error}

}
{(st?.peers ?? []).length === 0 &&

{t('sync.noPeers')}

} {(st?.peers ?? []).map((p: any) => (
{p.machine} {when(p.last_change)} {p.behind > 0 && {t('sync.behind')}}
))}
); } // AmpUI mirrors the backend AmpConfig — one configured amplifier. type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number; // Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's // frequency polls, pretending to be a Kenwood-format transceiver. freq_out?: boolean; freq_com_port?: string; freq_baud?: number; freq_broadcast_ms?: number }; // RelayAutoPanel configures automatic control of the Station Control relay boards // from the rig's frequency / band (PstRotator-style). Each relay carries one rule: // off, a frequency window (kHz), or a set of bands. type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] }; type StationDevUI = { id: string; type: string; name: string; labels: string[] }; const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm']; // Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow // filter is a subset of these. Must match motorBands in app.go, low → high. const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m']; // Shown as placeholders only — the backend owns these values (motorBands in // app.go) and resolves what a band button actually commands. Duplicated here // purely so an empty box can say what leaving it empty will do. const MOTOR_BAND_DEFAULT_KHZ: Record = { '40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110, '15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150, }; // Fallback only: the rule editor sizes itself from the device's labels, which // SaveStationDevices already normalises to the real relay count. This is what it // falls back to when a device somehow has none, so it only needs to be in the // right neighbourhood — but it is the third place that counts relays, and the // first two disagreeing is what pinned the HTTP board at four. const relayCountUI = (type: string) => type === 'kmtronic' || type === 'denkovi' ? 8 : type === 'httpgen' ? 4 : 5; // The twelve cluster command macros. // // Module-scoped, with its own state, and that is the point rather than tidiness: // nested inside SettingsModal every keystroke in one of these twenty-four boxes // re-rendered the WHOLE preferences dialog — every list, every form, every // panel — and the letters arrived visibly after the finger had left the key. // Here a keystroke re-renders twelve rows. // // Written on every keystroke as before. This panel has no Save button, and a // text box whose contents only take effect on some other button is how work // gets lost; the database write is what waits (see saveClusterMacros). function ClusterMacroEditor() { const { t } = useI18n(); const [macros, setMacros] = useState(loadClusterMacros); const setMacro = (i: number, patch: Partial) => { setMacros((cur) => { const next = cur.map((m, j) => (j === i ? { ...m, ...patch } : m)); saveClusterMacros(next); return next; }); }; return (
{t('clu.macros')}
{/* Two columns of six: twelve rows stacked would push everything else in this panel off the screen. */}
{macros.map((m, i) => (
{i + 1} setMacro(i, { label: e.target.value })} /> {/* 500, not 120. A DXSpider filter is a list of prefixes and an operator's own list of wanted countries runs past a hundred characters easily — the field simply stopped accepting keystrokes, with nothing to say why, and the command was saved truncated. The title shows the whole thing, since the box cannot. */} setMacro(i, { cmd: e.target.value })} />
))}
); } // Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config // id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it // isn't remounted on every parent render. Polls once a second while shown. function AmpStatusCard({ id }: { id: string }) { const [amp, setAmp] = useState(null); useEffect(() => { let alive = true; const tick = () => GetAmpStatuses().then((all: any[]) => { if (alive) setAmp((all ?? []).find((a) => a.id === id) ?? null); }).catch(() => {}); tick(); const t = window.setInterval(tick, 1000); return () => { alive = false; window.clearInterval(t); }; }, [id]); const st: any = amp?.spe ?? amp?.acom ?? amp?.kpa ?? amp?.pgxl ?? { connected: false }; const isSPE = !!amp?.spe; const isACOM = !!amp?.acom; const isKPA = !!amp?.kpa; const operate = !!st.operate; return (
{amp?.name || 'Amplifier'}{st.connected ? '' : ' — not connected'} {!st.connected && st.last_error && {st.last_error}}
{st.connected && isSPE && (
{st.tx ? 'TX' : 'RX'}
Band {st.band}
Pwr {st.power_level}
{st.output_w} W
SWR {Number(st.swr_ant ?? 0).toFixed(2)}
{st.temp_c}°C
{st.volt_pa} V
{st.curr_pa} A
{(st.warnings || st.alarms) &&
⚠ {st.warnings} {st.alarms}
}
)} {st.connected && isACOM && (
{st.state}
Band {st.band || '—'}
{st.fwd_w} W
SWR {Number(st.swr ?? 0).toFixed(2)}
Refl {st.refl_w} W
Drive {st.drive_w} W
{st.temp_c}°C
Fan {st.fan}
{st.err_text &&
⚠ {st.err_text} ({st.err_code})
}
)} {st.connected && isKPA && (
{st.power_on ? 'ON' : 'OFF'}
Band {st.band || '—'}
{st.fwd_w} W
SWR {Number(st.swr ?? 0).toFixed(1)}
{st.volt_v} V
{st.cur_a} A
{st.temp_c}°C
{st.tuning ? 'TUNING' : ''}
{/* A fault has already put the amplifier in standby by itself, so it is the one thing worth the width — and OPERATE is the way out of it, which the button above already is. */} {st.fault_text &&
⚠ {st.fault_text}
}
)} {st.connected && !isSPE && !isACOM && !isKPA && (
{st.state || ''}
Fan {st.fan_mode || '—'}
{st.temperature ? `${Math.round(st.temperature)}°C` : ''}
)}
); } function RelayAutoPanel() { const { t } = useI18n(); const [enabled, setEnabled] = useState(false); const [rules, setRules] = useState([]); const [devices, setDevices] = useState([]); const [loaded, setLoaded] = useState(false); useEffect(() => { Promise.all([GetRelayAuto(), GetStationDevices()]) .then(([cfg, devs]: any[]) => { setEnabled(!!cfg?.enabled); setRules((cfg?.rules ?? []) as RelayRuleUI[]); setDevices((devs ?? []) as StationDevUI[]); }) .catch(() => {}) .finally(() => setLoaded(true)); }, []); const save = (en: boolean, rs: RelayRuleUI[]) => { SaveRelayAuto({ enabled: en, rules: rs } as any).catch(() => {}); }; const ruleFor = (dev: string, relay: number): RelayRuleUI => rules.find((r) => r.device_id === dev && r.relay === relay) ?? { device_id: dev, relay, mode: 'off', freq_lo_khz: 0, freq_hi_khz: 0, bands: [] }; // Apply a patch and persist (commit=true) or keep local only (commit=false, for // freq inputs that persist on blur so we don't switch relays on every keystroke). const patchRule = (dev: string, relay: number, patch: Partial, commit = true) => { const next = { ...ruleFor(dev, relay), ...patch }; const others = rules.filter((r) => !(r.device_id === dev && r.relay === relay)); const all = [...others, next]; setRules(all); if (commit) save(enabled, all); }; const toggleBand = (dev: string, relay: number, band: string) => { const cur = ruleFor(dev, relay); const has = cur.bands.includes(band); patchRule(dev, relay, { bands: has ? cur.bands.filter((b) => b !== band) : [...cur.bands, band] }); }; return (

{t('relayauto.hint')}

{devices.length === 0 && loaded && (

{t('relayauto.noDevices')}

)} {devices.map((dev) => (
{dev.name || dev.id}
{Array.from({ length: (dev.labels?.length || relayCountUI(dev.type)) }, (_, i) => i + 1).map((relay) => { const r = ruleFor(dev.id, relay); const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`; return (
{label}
{r.mode === 'freq' && (
patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)} onBlur={() => save(enabled, rules)} /> patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)} onBlur={() => save(enabled, rules)} /> kHz
)} {r.mode === 'band' && (
{RELAY_BANDS.map((b) => { const on = r.bands.includes(b); return ( ); })}
)}
); })}
))}
); } // MainViewPanes lets the operator choose what the Main tab shows, in up to four // panes. The first two are the view; the third and fourth can be left empty, and // an empty one is not drawn at all. Per-profile (stored via SetUIPref, which is // profile-prefixed). Self-contained so it owns its async-loaded state. const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes', 'watchlist']; const PANE_NONE = 'none'; function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable, elecraftAvailable, tciAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean; elecraftAvailable?: boolean; tciAvailable?: boolean }) { const { t } = useI18n(); const [panes, setPanes] = useState>({ left: 'map1', right: 'map2', p3: PANE_NONE, p4: PANE_NONE }); const [layout, setLayout] = useState('quad'); // Radio-control panes are only offered when that CAT backend is active. Sorted A→Z. const options = [ ...MAIN_PANE_VALUES, ...(flexAvailable ? ['flex'] : []), ...(icomAvailable ? ['icom'] : []), ...(yaesuAvailable ? ['yaesu'] : []), // The Elecraft console could be docked from the start — App has always had // the pane — but it was never offered here, so the only way to reach it was // the tab. Listed with the others now. ...(elecraftAvailable ? ['elecraft'] : []), ...(tciAvailable ? ['tci'] : []), ].map((value) => ({ value, label: t(`settings.pane.${value}`) })) .sort((a, b) => a.label.localeCompare(b.label)); const KEYS: Record = { left: 'mainPaneLeft', right: 'mainPaneRight', p3: 'mainPane3', p4: 'mainPane4' }; useEffect(() => { const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || MAIN_PANE_VALUES.includes(v); Promise.all([ ...Object.values(KEYS).map((k) => GetUIPref(k).catch(() => '')), GetUIPref('mainPaneLayout').catch(() => ''), ]).then(([l, r, p3, p4, lay]) => { setPanes({ left: valid(l) ? l : 'map1', right: valid(r) ? r : 'map2', p3: valid(p3) ? p3 : PANE_NONE, p4: valid(p4) ? p4 : PANE_NONE, }); if (lay === 'cols' || lay === 'quad') setLayout(lay); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const pick = (side: string, v: string) => { setPanes((p) => ({ ...p, [side]: 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(KEYS[side], v).catch(() => {}); onChanged?.(side as any, v); }; const pickLayout = (v: string) => { setLayout(v); SetUIPref('mainPaneLayout', v).catch(() => {}); onChanged?.('layout', v); }; const filled = ['left', 'right', 'p3', 'p4'].filter((k) => panes[k] !== PANE_NONE).length; const SIDES = [ { key: 'left', label: t('settings.leftPane'), canBeEmpty: false }, { key: 'right', label: t('settings.rightPane'), canBeEmpty: false }, { key: 'p3', label: t('settings.thirdPane'), canBeEmpty: true }, { key: 'p4', label: t('settings.fourthPane'), canBeEmpty: true }, ]; return (

{t('settings.mainView')}

{t('settings.mainViewHint')}

{SIDES.map((sd) => ( ))}
{/* Only asked when it can matter. Three panes are columns and nothing else; four are the only case with two honest answers. */} {filled === 4 && (
{t('settings.paneLayout')}
{[{ v: 'quad', l: t('settings.paneQuad') }, { v: 'cols', l: t('settings.paneCols') }].map((o) => ( ))}
)}
); } // 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}
}
); } // AwardsSelectionPanel is a two-column transfer list: every defined award on the // left, the ones the operator follows on the right. The Awards tab shows only the // followed set (empty = all). Per-profile, saved immediately (local SQLite). function AwardsSelectionPanel({ profile }: { profile?: { name?: string; callsign?: string } }) { const { t } = useI18n(); const [all, setAll] = useState<{ code: string; name: string }[]>([]); const [tracked, setTracked] = useState([]); const [err, setErr] = useState(''); const [q, setQ] = useState(''); useEffect(() => { (async () => { try { const [defs, tr] = await Promise.all([ GetAwardDefs().then((d) => (d ?? []) as any[]), GetTrackedAwards().then((v) => (v ?? []) as string[]).catch(() => [] as string[]), ]); setAll(defs.map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))); setTracked(tr); } catch (e: any) { setErr(String(e?.message ?? e)); } })(); }, []); async function persist(next: string[]) { setTracked(next); try { await SaveTrackedAwards(next); } catch (e: any) { setErr(String(e?.message ?? e)); } } const trackedSet = new Set(tracked); const byCode = new Map(all.map((a) => [a.code, a] as const)); const needle = q.trim().toLowerCase(); const available = all.filter((a) => !trackedSet.has(a.code) && (needle === '' || `${a.code} ${a.name}`.toLowerCase().includes(needle))); const trackedItems = (tracked.map((c) => byCode.get(c)).filter(Boolean) as { code: string; name: string }[]) .sort((a, b) => a.code.localeCompare(b.code)); return (
{err &&
{err}
}
{t('awards.available')} ({available.length})
setQ(e.target.value)} placeholder={t('awards.search')} className="h-8" />
{available.map((a) => ( ))} {available.length === 0 &&
{t('awards.allTracked')}
}
{t('awards.followed')} ({tracked.length})
{trackedItems.map((a) => ( ))} {tracked.length === 0 &&
{t('awards.noneFollowed')}
}
); } // SatelliteElementsBlock is where the orbital elements are kept up to date. // // In Settings rather than in the tab because it is maintenance, not operating: // during a pass an operator wants the frequencies and the countdown, not a // download button. Module-scope so it may hold its own hooks (see PanelHost). function SatelliteElementsBlock({ autoTle, onAutoTle }: { autoTle: boolean; onAutoTle: (v: boolean) => void }) { const { t } = useI18n(); const [info, setInfo] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); const [paste, setPaste] = useState(''); const [showPaste, setShowPaste] = useState(false); const read = async () => { try { setInfo(await GetSatelliteTLEInfo() as any); } catch { /* shown as unknown */ } }; useEffect(() => { read(); }, []); const refresh = async () => { setBusy(true); setMsg(''); try { const i: any = await RefreshSatelliteTLE(); setInfo(i); setMsg(t('satset.tleFetched', { n: i?.count ?? 0 })); } catch (e: any) { setMsg(String(e?.message ?? e)); } setBusy(false); }; const addPasted = async () => { setMsg(''); try { const n: number = (await AddSatelliteElements(paste)) as any; setPaste(''); setShowPaste(false); await read(); setMsg(t('satset.tleAdded', { n })); } catch (e: any) { setMsg(String(e?.message ?? e)); } }; const age = !info ? '—' : info.count === 0 ? t('satset.tleNone') : t('satset.tleAge', { n: info.count, h: Math.round(info.age_h) }); return (

{t('satset.elements')}

{age} {!!info?.custom && {t('satset.tleCustom', { n: info.custom })}}

{t('satset.tleHint')}

{showPaste && (