The TCI section of the audio settings was an investigation: open the stream, read what arrives, record ten seconds to listen to, key a tone. It answered every question it was built for — the frame layout, the sample width, that the radio asks rather than follows a clock, that the transmit audio source decides — and confirmed 80 W on real hardware. None of that belongs in front of an operator now. The radio is simply one of the devices in the two dropdowns, and choosing it IS the configuration: one control, in the place where the question is already being asked. Keeping the tick box beside it would have been two switches for one decision, with the second one where nobody looks. Gone with it: the stream/record/probe bindings, the audio.tci_rx setting, and twenty-four translation keys. The plumbing they proved out stays and now carries the voice keyer.
7878 lines
399 KiB
TypeScript
7878 lines
399 KiB
TypeScript
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, DiscoverFlexRadios,
|
||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources,
|
||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||
GetPSUSettings, SavePSUSettings,
|
||
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, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||
GetPOTAToken, SavePOTAToken,
|
||
TestLoTWUpload, ListTQSLStationLocations,
|
||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||
GetScpStatus, SetScpEnabled, 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, 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';
|
||
import { Input } from '@/components/ui/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 { 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';
|
||
|
||
type LookupSettings = LookupSettingsForm;
|
||
type StationSettings = StationSettingsForm;
|
||
type ListsSettings = ListsSettingsForm;
|
||
type ModePreset = ModePresetForm;
|
||
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
||
type RotatorDevice = Omit<mainModels.RotatorDevice, 'convertValues'>;
|
||
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
|
||
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||
|
||
// 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
|
||
// 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'
|
||
| 'lists-satellites'
|
||
| 'cluster'
|
||
| 'backup'
|
||
| 'database'
|
||
| 'autostart'
|
||
| 'uscounties'
|
||
| 'databases'
|
||
| 'awards'
|
||
| 'cat'
|
||
| 'rotator'
|
||
| 'winkeyer'
|
||
| 'antenna'
|
||
| 'antgenius'
|
||
| 'tunergenius'
|
||
| 'psu'
|
||
| '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 (
|
||
<span className="ml-1.5 flex shrink-0 items-center gap-[1px] text-[10px] font-bold leading-none opacity-80" title="4O3A Signature">
|
||
<span>4</span>
|
||
<svg viewBox="0 0 26 22" className="h-[13px] w-[15px]" aria-hidden="true">
|
||
{lens.map((ry, i) => (
|
||
<ellipse key={i} cx={3 + i * 3.3} cy={11} rx={1.5} ry={ry} fill="#1f9d3a" />
|
||
))}
|
||
</svg>
|
||
<span>3A</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// 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 <Panel />: 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' },
|
||
{ 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.satellites'), id: 'lists-satellites' },
|
||
]},
|
||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||
{ 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<Record<SectionId, string>> = {
|
||
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', 'lists-satellites': 'sec.satellites',
|
||
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<Record<SectionId, string>> = {
|
||
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',
|
||
'lists-satellites': 'Satellites',
|
||
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;
|
||
}
|
||
|
||
function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
|
||
const { t } = useI18n();
|
||
return (
|
||
<nav className="text-sm">
|
||
{buildTree(!!flexAvailable, t).map((node, i) => (
|
||
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
|
||
))}
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<button
|
||
onClick={() => { if (!node.disabled) onSelect(node.id); }}
|
||
disabled={node.disabled}
|
||
className={cn(
|
||
'w-full text-left px-2 py-1.5 rounded-md text-[12.5px] transition-colors flex items-center',
|
||
isActive ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60',
|
||
node.disabled && 'opacity-50 cursor-not-allowed italic',
|
||
)}
|
||
style={{ paddingLeft: 8 + depth * 14 }}
|
||
>
|
||
<span className="truncate">{node.label}</span>
|
||
{node.vendor && !node.disabled && <VendorMark vendor={node.vendor} />}
|
||
{node.disabled && (
|
||
<Construction className="ml-auto size-3 shrink-0 opacity-60" />
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
// group
|
||
const [open, setOpen] = useState(node.defaultOpen ?? false);
|
||
const Icon = node.icon;
|
||
return (
|
||
<div>
|
||
<button
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="w-full text-left px-2 py-1.5 rounded-md text-[12px] uppercase tracking-wider text-muted-foreground hover:text-foreground flex items-center gap-1.5 font-semibold"
|
||
style={{ paddingLeft: 8 + depth * 14 }}
|
||
>
|
||
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||
{Icon && <Icon className="size-3.5 opacity-70" />}
|
||
<span>{node.label}</span>
|
||
</button>
|
||
{open && (
|
||
<div>
|
||
{node.children.map((c, i) => (
|
||
<TreeNodeView key={i} node={c} depth={depth + 1} selected={selected} onSelect={onSelect} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ===== Section content panels =====
|
||
|
||
// Representative surface/card/accent swatch for each theme (picker preview —
|
||
// the theme is not applied until selected). Module-scope so their component
|
||
// identity is STABLE across SettingsModal re-renders; defining them inside the
|
||
// component would give each render a fresh function, remounting the Radix
|
||
// Select and slamming the open dropdown shut on any ambient re-render.
|
||
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { 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' },
|
||
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
||
};
|
||
|
||
function ThemeSwatch({ theme }: { theme: Exclude<ThemeChoice, 'auto'> }) {
|
||
const s = THEME_SWATCH[theme];
|
||
return (
|
||
<span className="flex h-5 w-8 overflow-hidden rounded-[3px] border border-black/20 shrink-0" style={{ background: s.bg }}>
|
||
<span className="m-0.5 flex flex-1 items-center justify-center rounded-[2px]" style={{ background: s.card }}>
|
||
<span className="h-2 w-2 rounded-full" style={{ background: s.accent }} />
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// Auto swatch = split light/dark, since it follows the OS preference.
|
||
function ThemeOptionSwatch({ theme }: { theme: ThemeChoice }) {
|
||
if (theme === 'auto') {
|
||
return (
|
||
<span className="flex h-5 w-8 overflow-hidden rounded-[3px] border border-black/20 shrink-0">
|
||
<span className="flex-1" style={{ background: '#e8dfc9' }} />
|
||
<span className="flex-1" style={{ background: '#16181d' }} />
|
||
</span>
|
||
);
|
||
}
|
||
return <ThemeSwatch theme={theme} />;
|
||
}
|
||
|
||
function ThemeSelector() {
|
||
const { theme, setTheme } = useTheme();
|
||
const { t } = useI18n();
|
||
const options: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||
return (
|
||
<div className="flex items-center gap-3">
|
||
<Label className="text-sm w-40">{t('settings.theme')}</Label>
|
||
<Select value={theme} onValueChange={(v) => setTheme(v as ThemeChoice)}>
|
||
<SelectTrigger className="h-9 w-64" title={t('settings.themeHint')}>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{options.map((opt) => (
|
||
<SelectItem key={opt} value={opt}>
|
||
<span className="flex items-center gap-2.5">
|
||
<ThemeOptionSwatch theme={opt} />
|
||
{t(`theme.${opt}`)}
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionHeader({ title, hint }: { title: string; hint?: string }) {
|
||
return (
|
||
<header className="mb-4">
|
||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||
{hint && <p className="text-xs text-muted-foreground mt-0.5">{hint}</p>}
|
||
</header>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<div className="-mt-2 mb-4">
|
||
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 text-primary text-xs px-2.5 py-1">
|
||
<User className="size-3.5" />
|
||
{t('profileScope.saved')} <strong className="font-semibold">{profile?.name || '—'}</strong>
|
||
{profile?.callsign ? <span className="font-mono opacity-80">({profile.callsign})</span> : null}
|
||
{' '}{t('profileScope.switch')}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 `() => <AutostartPanelComponent/>` 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<AutostartProg[]>([]);
|
||
const [loaded, setLoaded] = useState(false);
|
||
const [err, setErr] = useState('');
|
||
const [launchMsg, setLaunchMsg] = useState<Record<string, string>>({});
|
||
|
||
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<AutostartProg>) =>
|
||
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 (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.autostart')}
|
||
hint={t('autostart.hint')}
|
||
/>
|
||
<div className="space-y-2 max-w-3xl">
|
||
{loaded && progs.length === 0 && (
|
||
<p className="text-sm text-muted-foreground italic">No programs yet — add one below.</p>
|
||
)}
|
||
{progs.map((p) => (
|
||
<div key={p.id} className="rounded-lg border border-border bg-card p-3 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox checked={p.enabled} onCheckedChange={(c) => patch(p.id, { enabled: !!c })} title="Launch at startup" />
|
||
<Input className="h-8 flex-1 font-medium" value={p.name} placeholder="Name"
|
||
onChange={(e) => patch(p.id, { name: e.target.value })} />
|
||
<Button size="sm" variant="outline" onClick={() => launchNow(p.id)} title="Launch now">
|
||
<Play className="size-3.5" /> Launch
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => remove(p.id)} title="Remove">
|
||
<Trash2 className="size-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
<div className="grid grid-cols-[78px_1fr] items-center gap-2">
|
||
<Label className="text-xs text-muted-foreground">Program</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Input className="h-8 flex-1 font-mono text-xs" value={p.path} readOnly title={p.path} />
|
||
<Button size="sm" variant="outline" onClick={() => rebrowse(p.id)}>
|
||
<FolderOpen className="size-3.5" /> Browse
|
||
</Button>
|
||
</div>
|
||
<Label className="text-xs text-muted-foreground">Arguments</Label>
|
||
<Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
|
||
onChange={(e) => patch(p.id, { args: e.target.value })} />
|
||
</div>
|
||
{/* 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. */}
|
||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||
<Checkbox className="mt-0.5" checked={!!(p as any).close_on_exit}
|
||
onCheckedChange={(c) => patch(p.id, { close_on_exit: !!c } as any)} />
|
||
<span>
|
||
Close it when OpsLog closes{' '}
|
||
<span className="text-muted-foreground">
|
||
(asks it to close, the way clicking its cross would — and only if OpsLog started it)
|
||
</span>
|
||
</span>
|
||
</label>
|
||
{launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
|
||
</div>
|
||
))}
|
||
<Button variant="outline" onClick={addProgram}>
|
||
<Plus className="size-4" /> Add program…
|
||
</Button>
|
||
{err && <div className="text-xs text-destructive">{err}</div>}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={on} disabled={!loaded}
|
||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||
{t('settings.telemetry')}
|
||
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
// 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<ADIFMonitorCfgUI>({ 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 (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<p className="text-xs text-muted-foreground">{t('adifmon.hint')}</p>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => persist({ ...cfg, enabled: !!c })} />
|
||
{t('adifmon.enable')}
|
||
</label>
|
||
<div className="space-y-1.5">
|
||
{cfg.files.length === 0 && <p className="text-xs text-muted-foreground italic">{t('adifmon.empty')}</p>}
|
||
{cfg.files.map((f, i) => (
|
||
<div key={i} className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
|
||
<Checkbox checked={f.enabled}
|
||
onCheckedChange={(c) => persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} />
|
||
<span className="flex-1 font-mono text-xs truncate" title={f.path}>{f.path}</span>
|
||
<button type="button" title={t('adifmon.remove')}
|
||
className="text-muted-foreground hover:text-destructive shrink-0"
|
||
onClick={() => persist({ ...cfg, files: cfg.files.filter((_, idx) => idx !== i) })}>
|
||
<Trash2 className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={addFile}>
|
||
<FolderOpen className="size-3.5 mr-1" /> {t('adifmon.add')}
|
||
</Button>
|
||
<p className="text-[11px] text-muted-foreground">{t('adifmon.note')}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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<any>(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 (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<p className="text-xs text-muted-foreground">{t('sync.hint')}</p>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
||
{t('sync.enable')}
|
||
</label>
|
||
|
||
<div className="grid grid-cols-[130px_1fr] items-center gap-2">
|
||
<span className="text-sm">{t('sync.machine')}</span>
|
||
<Input value={cfg.machine} placeholder="shack" className="h-8"
|
||
onChange={(e) => setCfg({ ...cfg, machine: e.target.value })}
|
||
onBlur={() => save(cfg)} />
|
||
<span className="text-sm">{t('sync.folder')}</span>
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="flex-1 font-mono text-xs truncate" title={cfg.folder}>{cfg.folder || '—'}</span>
|
||
<Button variant="outline" size="sm" onClick={pick}><FolderOpen className="size-3.5 mr-1" /> {t('sync.choose')}</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{err && <p className="text-xs text-destructive">{err}</p>}
|
||
{msg && <p className="text-xs text-success">{msg}</p>}
|
||
|
||
<SectionHeader title={t('sync.state')} />
|
||
<div className="rounded-md border border-border bg-muted/20 p-3 space-y-2 text-xs">
|
||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-muted-foreground">
|
||
<span>{t('sync.thisPc')}: <span className="font-mono text-foreground">{st?.machine_id || '—'}</span></span>
|
||
<span>{t('sync.lastSync')}: <span className="text-foreground">{when(st?.last_sync ?? '')}</span></span>
|
||
<span>{t('sync.sent')}: <span className="text-foreground">{st?.sent ?? 0}</span></span>
|
||
<span>{t('sync.received')}: <span className="text-foreground">{st?.received ?? 0}</span></span>
|
||
</div>
|
||
{st?.error && <p className="text-destructive">{st.error}</p>}
|
||
<div className="space-y-1">
|
||
{(st?.peers ?? []).length === 0 && <p className="italic text-muted-foreground">{t('sync.noPeers')}</p>}
|
||
{(st?.peers ?? []).map((p: any) => (
|
||
<div key={p.machine} className="flex items-center gap-2">
|
||
<span className="font-mono truncate">{p.machine}</span>
|
||
<span className="text-muted-foreground">{when(p.last_change)}</span>
|
||
{p.behind > 0 && <span className="text-warning">{t('sync.behind')}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={syncNow} disabled={busy || !cfg.enabled}>
|
||
{busy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||
{t('sync.now')}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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<string, number> = {
|
||
'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;
|
||
|
||
// 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<any>(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?.pgxl ?? { connected: false };
|
||
const isSPE = !!amp?.spe;
|
||
const isACOM = !!amp?.acom;
|
||
const operate = !!st.operate;
|
||
return (
|
||
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
|
||
<span className="font-semibold">{amp?.name || 'Amplifier'}{st.connected ? '' : ' — not connected'}</span>
|
||
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
|
||
<span className="flex-1" />
|
||
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
|
||
onClick={() => AmpOperate(id, !operate).catch(() => {})}
|
||
title="Toggle OPERATE / STANDBY">
|
||
{operate ? 'OPERATE' : 'STANDBY'}
|
||
</Button>
|
||
</div>
|
||
{st.connected && isSPE && (
|
||
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||
<div>{st.tx ? 'TX' : 'RX'}</div>
|
||
<div>Band {st.band}</div>
|
||
<div>Pwr {st.power_level}</div>
|
||
<div>{st.output_w} W</div>
|
||
<div>SWR {Number(st.swr_ant ?? 0).toFixed(2)}</div>
|
||
<div>{st.temp_c}°C</div>
|
||
<div>{st.volt_pa} V</div>
|
||
<div>{st.curr_pa} A</div>
|
||
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning">⚠ {st.warnings} {st.alarms}</div>}
|
||
</div>
|
||
)}
|
||
{st.connected && isACOM && (
|
||
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||
<div>{st.state}</div>
|
||
<div>Band {st.band || '—'}</div>
|
||
<div>{st.fwd_w} W</div>
|
||
<div>SWR {Number(st.swr ?? 0).toFixed(2)}</div>
|
||
<div>Refl {st.refl_w} W</div>
|
||
<div>Drive {st.drive_w} W</div>
|
||
<div>{st.temp_c}°C</div>
|
||
<div>Fan {st.fan}</div>
|
||
{st.err_text && <div className="col-span-4 text-warning">⚠ {st.err_text} ({st.err_code})</div>}
|
||
</div>
|
||
)}
|
||
{st.connected && !isSPE && !isACOM && (
|
||
<div className="grid grid-cols-3 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||
<div>{st.state || ''}</div>
|
||
<div>Fan {st.fan_mode || '—'}</div>
|
||
<div>{st.temperature ? `${Math.round(st.temperature)}°C` : ''}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
function RelayAutoPanel() {
|
||
const { t } = useI18n();
|
||
const [enabled, setEnabled] = useState(false);
|
||
const [rules, setRules] = useState<RelayRuleUI[]>([]);
|
||
const [devices, setDevices] = useState<StationDevUI[]>([]);
|
||
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<RelayRuleUI>, 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 (
|
||
<div className="space-y-4 max-w-3xl">
|
||
<p className="text-xs text-muted-foreground">{t('relayauto.hint')}</p>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={enabled} disabled={!loaded} onCheckedChange={(c) => { const v = !!c; setEnabled(v); save(v, rules); }} />
|
||
{t('relayauto.enable')}
|
||
</label>
|
||
|
||
{devices.length === 0 && loaded && (
|
||
<p className="text-xs text-muted-foreground italic">{t('relayauto.noDevices')}</p>
|
||
)}
|
||
|
||
{devices.map((dev) => (
|
||
<div key={dev.id} className="rounded-md border border-border">
|
||
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
|
||
<div className="divide-y divide-border/60">
|
||
{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 (
|
||
<div key={relay} className="flex items-start gap-3 px-3 py-2">
|
||
<span className="w-28 shrink-0 text-xs font-mono pt-1.5 truncate" title={label}>{label}</span>
|
||
<Select value={r.mode || 'off'} onValueChange={(v) => patchRule(dev.id, relay, { mode: v })}>
|
||
<SelectTrigger className="h-8 w-32 text-xs shrink-0"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="off">{t('relayauto.modeOff')}</SelectItem>
|
||
<SelectItem value="freq">{t('relayauto.modeFreq')}</SelectItem>
|
||
<SelectItem value="band">{t('relayauto.modeBand')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="flex-1 min-w-0 pt-0.5">
|
||
{r.mode === 'freq' && (
|
||
<div className="flex items-center gap-1.5 text-xs">
|
||
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.from')}
|
||
defaultValue={r.freq_lo_khz || ''}
|
||
onChange={(e) => patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)}
|
||
onBlur={() => save(enabled, rules)} />
|
||
<span className="text-muted-foreground">–</span>
|
||
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.to')}
|
||
defaultValue={r.freq_hi_khz || ''}
|
||
onChange={(e) => patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)}
|
||
onBlur={() => save(enabled, rules)} />
|
||
<span className="text-muted-foreground">kHz</span>
|
||
</div>
|
||
)}
|
||
{r.mode === 'band' && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{RELAY_BANDS.map((b) => {
|
||
const on = r.bands.includes(b);
|
||
return (
|
||
<button key={b} type="button" onClick={() => toggleBand(dev.id, relay, b)}
|
||
className={cn('px-1.5 py-0.5 rounded text-[11px] font-mono border transition-colors',
|
||
on ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||
{b}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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'];
|
||
const PANE_NONE = 'none';
|
||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
|
||
const { t } = useI18n();
|
||
const [panes, setPanes] = useState<Record<string, string>>({ 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'] : []),
|
||
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||
.sort((a, b) => a.label.localeCompare(b.label));
|
||
const KEYS: Record<string, string> = { left: 'mainPaneLeft', right: 'mainPaneRight', p3: 'mainPane3', p4: 'mainPane4' };
|
||
useEffect(() => {
|
||
const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || 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 (
|
||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
|
||
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
|
||
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||
{SIDES.map((sd) => (
|
||
<label key={sd.key} className="flex flex-col gap-1 text-xs">
|
||
<span className="text-muted-foreground">{sd.label}</span>
|
||
<Select value={panes[sd.key]} onValueChange={(v) => pick(sd.key, v)}>
|
||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{/* Only the third and fourth may be empty: the first two ARE the
|
||
Main view, and a tab with nothing in it is not a layout. */}
|
||
{sd.canBeEmpty && <SelectItem value={PANE_NONE}>{t('settings.paneNone')}</SelectItem>}
|
||
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</label>
|
||
))}
|
||
</div>
|
||
{/* Only asked when it can matter. Three panes are columns and nothing
|
||
else; four are the only case with two honest answers. */}
|
||
{filled === 4 && (
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<span className="text-xs text-muted-foreground">{t('settings.paneLayout')}</span>
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||
{[{ v: 'quad', l: t('settings.paneQuad') }, { v: 'cols', l: t('settings.paneCols') }].map((o) => (
|
||
<button key={o.v} type="button" onClick={() => pickLayout(o.v)}
|
||
className={cn('px-2.5 py-1', layout === o.v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||
{o.l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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<Array<{ ip: string; port: number; model?: string; nickname?: string }>>([]);
|
||
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 (
|
||
<div className="rounded-md border border-border bg-muted/20 p-2 space-y-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<Button size="sm" variant="outline" onClick={scan} disabled={busy}>
|
||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Wifi className="size-3.5" />} Detect radios
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">listens for FlexRadio broadcast on the LAN</span>
|
||
</div>
|
||
{found.map((r) => (
|
||
<button key={r.ip} type="button" onClick={() => onPick(r.ip, r.port || 4992)}
|
||
className="w-full text-left text-xs rounded border border-border px-2 py-1 hover:bg-accent/50">
|
||
<span className="font-mono font-semibold">{r.ip}</span>
|
||
{r.model ? <span className="text-muted-foreground"> · {r.model}</span> : ''}
|
||
{r.nickname ? <span className="text-muted-foreground"> ({r.nickname})</span> : ''}
|
||
</button>
|
||
))}
|
||
{msg && <div className="text-[11px] text-muted-foreground">{msg}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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<string[]>([]);
|
||
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 (
|
||
<div>
|
||
<SectionHeader title={t('sec.awards')} hint={t('awards.followHint')} />
|
||
<ProfileScopeNote profile={profile} />
|
||
{err && <div className="mb-2 text-xs text-destructive">{err}</div>}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="rounded-lg border border-border bg-card/40 flex flex-col">
|
||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
|
||
<span className="text-sm font-medium">{t('awards.available')} <span className="text-muted-foreground">({available.length})</span></span>
|
||
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
|
||
disabled={available.length === 0} onClick={() => persist(all.map((a) => a.code))}>{t('awards.addAll')}</button>
|
||
</div>
|
||
<div className="p-2 border-b border-border/60">
|
||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('awards.search')} className="h-8" />
|
||
</div>
|
||
<div className="max-h-[340px] overflow-y-auto p-1.5 space-y-0.5">
|
||
{available.map((a) => (
|
||
<button key={a.code} type="button" onClick={() => persist([...tracked, a.code])}
|
||
className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
|
||
<span className="font-mono text-xs shrink-0">{a.code}</span>
|
||
<span className="text-xs text-muted-foreground truncate flex-1">{a.name}</span>
|
||
<span className="text-primary opacity-0 group-hover:opacity-100">→</span>
|
||
</button>
|
||
))}
|
||
{available.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('awards.allTracked')}</div>}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-lg border border-primary/40 bg-primary/5 flex flex-col">
|
||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
|
||
<span className="text-sm font-medium">{t('awards.followed')} <span className="text-muted-foreground">({tracked.length})</span></span>
|
||
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
|
||
disabled={tracked.length === 0} onClick={() => persist([])}>{t('awards.clear')}</button>
|
||
</div>
|
||
<div className="max-h-[392px] overflow-y-auto p-1.5 space-y-0.5">
|
||
{trackedItems.map((a) => (
|
||
<button key={a.code} type="button" onClick={() => persist(tracked.filter((c) => c !== a.code))}
|
||
className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
|
||
<span className="text-muted-foreground opacity-0 group-hover:opacity-100">←</span>
|
||
<span className="font-mono text-xs shrink-0">{a.code}</span>
|
||
<span className="text-xs text-muted-foreground truncate flex-1">{a.name}</span>
|
||
</button>
|
||
))}
|
||
{tracked.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('awards.noneFollowed')}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||
const label = SECTION_LABELS[id] ?? id;
|
||
const IconCmp = Icon ?? Construction;
|
||
return (
|
||
<div className="flex flex-col items-center justify-center text-center text-muted-foreground gap-2 py-12">
|
||
<IconCmp className="size-10 opacity-40" />
|
||
<div className="text-base font-semibold text-foreground/70">{label}</div>
|
||
<div className="text-sm">Module coming soon.</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// The panadapter statuses, in the order the cluster ranks them: most wanted
|
||
// first, then the markers that are orthogonal to the entity, then the two
|
||
// "nothing here" cases. Must match spotColorOrder in spotcolors.go — the Go side
|
||
// is the authority, this list only decides the order of the rows.
|
||
const SPOT_COLOR_ROWS = [
|
||
'new', 'new-band-mode', 'new-band', 'new-mode', 'new-slot',
|
||
'new-pota', 'new-county', 'new-pfx',
|
||
'my-call', 'worked', 'none',
|
||
];
|
||
|
||
// SmartSDR colours are #AARRGGBB — the alpha FIRST, which is not what CSS or an
|
||
// <input type="color"> expects. These two convert between the radio's order and
|
||
// the browser's, so the operator picks a colour in a normal picker and the radio
|
||
// gets what it understands.
|
||
function argbToCss(argb?: string): string {
|
||
const v = (argb ?? '').trim();
|
||
if (!/^#[0-9a-fA-F]{8}$/.test(v)) return '';
|
||
return `#${v.slice(3)}${v.slice(1, 3)}`; // #AARRGGBB → #RRGGBBAA
|
||
}
|
||
function cssToArgb(rgb: string, alpha: string): string {
|
||
const v = (rgb || '#000000').trim();
|
||
if (!/^#[0-9a-fA-F]{6}$/.test(v)) return '';
|
||
return `#${alpha}${v.slice(1)}`.toUpperCase();
|
||
}
|
||
|
||
// ArgbInput edits one #AARRGGBB value as what it really is: a colour and an
|
||
// opacity. A single text field would be asking an operator to type eight hex
|
||
// digits in an order no other program uses.
|
||
function ArgbInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||
const valid = /^#[0-9a-fA-F]{8}$/.test((value ?? '').trim());
|
||
const alpha = valid ? value.slice(1, 3).toUpperCase() : 'FF';
|
||
const rgb = valid ? `#${value.slice(3)}` : '#808080';
|
||
return (
|
||
<span className="flex items-center gap-1 shrink-0" title={label}>
|
||
<input type="color" value={rgb}
|
||
onChange={(e) => onChange(cssToArgb(e.target.value, alpha))}
|
||
className="size-6 rounded border border-border bg-transparent p-0 cursor-pointer" />
|
||
<select value={alpha}
|
||
onChange={(e) => onChange(cssToArgb(rgb, e.target.value))}
|
||
className="h-6 rounded border border-border bg-background text-[10px] px-0.5">
|
||
{[['FF', '100%'], ['C0', '75%'], ['80', '50%'], ['40', '25%'], ['20', '12%'], ['00', 'off']].map(([a, l]) => (
|
||
<option key={a} value={a}>{l}</option>
|
||
))}
|
||
</select>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// FlexBandPanel — everything that follows the band, in ONE row per band:
|
||
// antennas and TX power.
|
||
//
|
||
// They were two tables listing the same bands one under the other, which made
|
||
// the operator match rows by eye between them. The two settings are stored and
|
||
// applied separately (antennas on band change, power on band or mode change) —
|
||
// that is a backend detail, and no reason to split what is one decision per
|
||
// band for the person configuring it.
|
||
function FlexBandPanel({ bands }: { bands: string[] }) {
|
||
const { t } = useI18n();
|
||
const [rxList, setRxList] = useState<string[]>([]);
|
||
const [txList, setTxList] = useState<string[]>([]);
|
||
const [ant, setAnt] = useState<Record<string, { rx: string; tx: string }>>({});
|
||
const [pwr, setPwr] = useState<Record<string, { phone: number; cw: number; digi: number }>>({});
|
||
const [msg, setMsg] = useState('');
|
||
|
||
useEffect(() => {
|
||
GetFlexState().then((s: any) => {
|
||
setRxList((s?.ant_list ?? []) as string[]);
|
||
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
|
||
}).catch(() => {});
|
||
GetFlexBandAntennas().then((m: any) => setAnt(m ?? {})).catch(() => {});
|
||
GetFlexBandPower().then((m: any) => setPwr(m ?? {})).catch(() => {});
|
||
}, []);
|
||
|
||
const saved = () => { setMsg(t('flxpw.saved')); window.setTimeout(() => setMsg(''), 1200); };
|
||
|
||
const setAntenna = (band: string, side: 'rx' | 'tx', v: string) => {
|
||
const key = band.toUpperCase();
|
||
setAnt((m) => {
|
||
const next = { ...m, [key]: { ...(m[key] ?? { rx: '', tx: '' }), [side]: v } };
|
||
SaveFlexBandAntennas(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const setPower = (band: string, kind: 'phone' | 'cw' | 'digi', v: string) => {
|
||
const key = band.toUpperCase();
|
||
const n = Math.max(0, Math.min(100, parseInt(v, 10) || 0));
|
||
setPwr((m) => {
|
||
const next = { ...m, [key]: { ...(m[key] ?? { phone: 0, cw: 0, digi: 0 }), [kind]: n } };
|
||
SaveFlexBandPower(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const antCell = (b: string, side: 'rx' | 'tx', list: string[]) => {
|
||
const e = ant[b.toUpperCase()] ?? { rx: '', tx: '' };
|
||
return (
|
||
<td className="px-2 py-1.5">
|
||
<select value={e[side] ?? ''} onChange={(ev) => setAntenna(b, side, ev.target.value)}
|
||
className="h-8 w-28 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||
<option value="">— none —</option>
|
||
{list.map((a) => <option key={a} value={a}>{a}</option>)}
|
||
</select>
|
||
</td>
|
||
);
|
||
};
|
||
|
||
// The rule marking where band-driven settings end and mode-driven ones begin
|
||
// is drawn on the FIRST power cell, header and body alike. It used to be an
|
||
// extra empty cell in the body only, which gave the two rows different column
|
||
// counts and slid every heading one column left.
|
||
const pwrCell = (b: string, kind: 'phone' | 'cw' | 'digi') => {
|
||
const e = pwr[b.toUpperCase()] ?? { phone: 0, cw: 0, digi: 0 };
|
||
return (
|
||
<td className={cn('px-2 py-1.5', kind === 'phone' && 'border-l border-border/60')}>
|
||
<Input
|
||
type="number" min={0} max={100}
|
||
className="h-8 w-16 text-xs"
|
||
value={e[kind] ? String(e[kind]) : ''}
|
||
placeholder="—"
|
||
onChange={(ev) => setPower(b, kind, ev.target.value)}
|
||
/>
|
||
</td>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-foreground">{t('flxb.title')}</h3>
|
||
<p className="text-xs text-muted-foreground">{t('flxb.hint')}</p>
|
||
</div>
|
||
{rxList.length === 0 && (
|
||
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-2xl">
|
||
{t('flxb.noRadio')}
|
||
</div>
|
||
)}
|
||
{bands.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground">{t('flxpw.noBands')}</p>
|
||
) : (
|
||
<div className="rounded-md border border-border overflow-x-auto max-w-3xl">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-muted/40 text-muted-foreground text-xs">
|
||
<tr>
|
||
<th className="text-left px-3 py-1.5 font-semibold">{t('flxpw.band')}</th>
|
||
<th className="text-left px-2 py-1.5 font-semibold">{t('flxb.rxAnt')}</th>
|
||
<th className="text-left px-2 py-1.5 font-semibold">{t('flxb.txAnt')}</th>
|
||
<th className="text-left px-2 py-1.5 font-semibold border-l border-border/60">{t('flxpw.phone')}</th>
|
||
<th className="text-left px-2 py-1.5 font-semibold">CW</th>
|
||
<th className="text-left px-2 py-1.5 font-semibold">{t('flxpw.digi')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{bands.map((b) => (
|
||
<tr key={b} className="border-t border-border/50">
|
||
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
|
||
{antCell(b, 'rx', rxList)}
|
||
{antCell(b, 'tx', txList)}
|
||
{pwrCell(b, 'phone')}
|
||
{pwrCell(b, 'cw')}
|
||
{pwrCell(b, 'digi')}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
{msg && <span className="text-[11px] text-muted-foreground">{msg}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Known Icom models paired with their FACTORY default CI-V address. Picking a
|
||
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
|
||
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
|
||
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
|
||
// The IC-7700 and IC-7800 were listed at 0x88 and 0x80 — which are the IC-7100's
|
||
// and the IC-7410's. Picking one of them set an address the rig never answers on,
|
||
// and the backend then named it as the other model.
|
||
const ICOM_MODELS: { name: string; addr: number }[] = [
|
||
{ name: 'IC-705', addr: 0xA4 },
|
||
{ name: 'IC-7100', addr: 0x88 },
|
||
{ name: 'IC-7300', addr: 0x94 },
|
||
{ name: 'IC-7300MKII', addr: 0xB6 },
|
||
{ name: 'IC-7410', addr: 0x80 },
|
||
{ name: 'IC-7600', addr: 0x7A },
|
||
{ name: 'IC-7610', addr: 0x98 },
|
||
{ name: 'IC-7700', addr: 0x74 },
|
||
{ name: 'IC-7800', addr: 0x6A },
|
||
{ name: 'IC-7851', addr: 0x8E },
|
||
{ name: 'IC-9100', addr: 0x7C },
|
||
{ name: 'IC-9700', addr: 0xA2 },
|
||
];
|
||
|
||
// The radios OpsLog talks to, by BRAND, and the ways each one can be reached.
|
||
//
|
||
// Three connections exist in the world and each brand has its own subset:
|
||
// usb — a COM port, the radio's own USB or a serial cable
|
||
// bridge — RS-232 carried over Ethernet by a serial bridge (ser2net, an
|
||
// Ethernet-serial box): the SAME CAT bytes, a socket instead of wire
|
||
// native — the manufacturer's own network protocol, a session of its own
|
||
//
|
||
// The backend NAME stored in settings is unchanged — 'icom-net' is still
|
||
// 'icom-net' — because a settings file written by an older build has to keep
|
||
// working. This table is only how the two questions map onto it.
|
||
// OmniRig leads — it is the one that works with any radio, so it is where
|
||
// someone who does not find their rig below should land. The rest are
|
||
// alphabetical, because a list of brands has no other defensible order.
|
||
const CAT_BRANDS: { id: string; label: string; links: string[]; backend: (link: string) => string }[] = [
|
||
{ id: 'omnirig', label: 'OmniRig (any rig)', links: ['usb'], backend: () => 'omnirig' },
|
||
{ id: 'elecraft', label: 'Elecraft K3 / K4', links: ['usb', 'bridge'], backend: () => 'elecraft' },
|
||
{ id: 'tci', label: 'Expert Electronics / SunSDR (TCI)', links: ['native'], backend: () => 'tci' },
|
||
{ id: 'flex', label: 'FlexRadio (SmartSDR)', links: ['native'], backend: () => 'flex' },
|
||
{ id: 'icom', label: 'Icom', links: ['usb', 'native'], backend: (l) => (l === 'native' ? 'icom-net' : 'icom') },
|
||
{ id: 'kenwood', label: 'Kenwood', links: ['usb', 'bridge'], backend: () => 'kenwood' },
|
||
{ id: 'xiegu', label: 'Xiegu', links: ['usb'], backend: () => 'xiegu' },
|
||
{ id: 'yaesu', label: 'Yaesu', links: ['usb'], backend: () => 'yaesu' },
|
||
];
|
||
|
||
// brandOfBackend reads the stored backend back into the two questions.
|
||
function brandOfBackend(backend: string, kenwoodLink?: string): { brand: string; link: string } {
|
||
switch (backend) {
|
||
case 'icom-net': return { brand: 'icom', link: 'native' };
|
||
case 'icom': return { brand: 'icom', link: 'usb' };
|
||
case 'flex': return { brand: 'flex', link: 'native' };
|
||
case 'tci': return { brand: 'tci', link: 'native' };
|
||
case 'yaesu': return { brand: 'yaesu', link: 'usb' };
|
||
case 'xiegu': return { brand: 'xiegu', link: 'usb' };
|
||
case 'kenwood': case 'elecraft':
|
||
return { brand: backend, link: kenwoodLink || 'usb' };
|
||
default: return { brand: 'omnirig', link: 'usb' };
|
||
}
|
||
}
|
||
|
||
// SETTINGS DOES NOT RE-RENDER WITH THE MAIN WINDOW.
|
||
//
|
||
// It is a child of App, and App re-renders on every cluster spot, every CAT
|
||
// status push, every decode — several times a second on a busy evening. Each of
|
||
// those re-rendered this entire panel, which is large enough that the rebuild
|
||
// takes longer than the gap between them: the page looked like it was
|
||
// refreshing ten times a second and buttons stopped responding, because the
|
||
// element under the pointer was replaced between the press and the release.
|
||
//
|
||
// memo() cuts that off. It only works if the props hold still, which is why
|
||
// App passes callbacks that do not change identity on every render — see the
|
||
// useCallback wrappers there.
|
||
function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable, onEditQSO }: Props) {
|
||
const { t } = useI18n();
|
||
const [selected, setSelected] = useState<SectionId>((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<LookupSettings>({
|
||
qrz_user: '', qrz_password: '',
|
||
hamqth_user: '', hamqth_password: '',
|
||
primary: '', failsafe: '',
|
||
download_images: false,
|
||
qrz_prefer_nickname: 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<Record<string, TestResult | undefined>>({});
|
||
const [lookupTesting, setLookupTesting] = useState<Record<string, boolean>>({});
|
||
// 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<Profile | null>(null);
|
||
const updateActive = (patch: Partial<Profile>) =>
|
||
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
|
||
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [], rst_phone: [], rst_cw: [], rst_digital: [], satellites: [] });
|
||
// 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<CATSettings>({
|
||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
||
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
||
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||
});
|
||
// Brand + connection, derived from the stored backend rather than held
|
||
// separately: two sources for one fact drift apart the first time something
|
||
// else writes the backend (loading a profile, an older settings file).
|
||
const catBrand = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).brand;
|
||
const catLink = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).link;
|
||
const applyCatBrand = (id: string) => {
|
||
const b = CAT_BRANDS.find((x) => x.id === id);
|
||
if (!b) return;
|
||
// Keep the connection when the new brand offers it, otherwise take its
|
||
// first — picking Flex from Kenwood-over-USB has to land on something.
|
||
const link = b.links.includes(catLink) ? catLink : b.links[0];
|
||
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link } as any));
|
||
};
|
||
const applyCatLink = (link: string) => {
|
||
const b = CAT_BRANDS.find((x) => x.id === catBrand);
|
||
if (!b || !b.links.includes(link)) return;
|
||
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link } as any));
|
||
};
|
||
|
||
// While true, the next key press is captured as the PTT hotkey.
|
||
const [capturingPtt, setCapturingPtt] = useState(false);
|
||
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
||
// Whether the presets have actually been READ back yet.
|
||
//
|
||
// An empty list is a legitimate answer — an operator may want no quick-turn
|
||
// buttons at all — so the backend stores it as one. That makes "not loaded
|
||
// yet" and "deliberately none" the same value, and a Save landing in the first
|
||
// state wiped the buttons for good. Saving is gated on having read them, so
|
||
// the only empty list that can ever reach the store is one the operator made.
|
||
const [rotorPresetsLoaded, setRotorPresetsLoaded] = useState(false);
|
||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
|
||
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; band_freqs: Record<string, number>; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
|
||
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', band_freqs: {}, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
|
||
});
|
||
const [ubTesting, setUbTesting] = useState(false);
|
||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
|
||
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string; use_for_my_antenna?: boolean; ant1_port?: number }>({ enabled: false, host: '', password: '', use_for_my_antenna: false, ant1_port: 1 });
|
||
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||
const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
|
||
|
||
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
||
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||
const [amps, setAmps] = useState<AmpUI[]>([]);
|
||
const [linkedAmps, setLinkedAmps] = useState<string[]>([]);
|
||
|
||
// WinKeyer CW keyer settings + macro editor.
|
||
type WKMac = { label: string; text: string };
|
||
type WKSettings = {
|
||
enabled: boolean; engine: string; esc_clears_call: boolean; esm: boolean;
|
||
port: string; baud: number; wpm: number; weight: number;
|
||
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
||
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
||
use_ptt: boolean; serial_echo: boolean; cw_key_line: string; cw_invert: boolean; macros: WKMac[];
|
||
};
|
||
const [wk, setWk] = useState<WKSettings>({
|
||
enabled: false, engine: 'winkeyer', esc_clears_call: true, esm: false,
|
||
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
||
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
||
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
|
||
});
|
||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||
// Session-only: the byte trace is a diagnostic, never a saved preference.
|
||
//
|
||
// But it must still SHOW its real state. These start false on every mount, so
|
||
// a trace already running came back unticked; the operator ticks the box to
|
||
// turn it on, which turns it off, and the log they send has no trace in it.
|
||
// Reported by a Xiegu user. Hydrated from the backend below.
|
||
const [dateFmtSel, setDateFmtSel] = useState<DateFormat>(getDateFormat);
|
||
const [wkTrace, setWkTrace] = useState(false);
|
||
const [civTrace, setCivTrace] = useState(false);
|
||
useEffect(() => {
|
||
CIVTraceEnabled().then((v) => setCivTrace(!!v)).catch(() => {});
|
||
WinkeyerTraceEnabled().then((v) => setWkTrace(!!v)).catch(() => {});
|
||
}, []);
|
||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||
|
||
// ── Audio (DVK + QSO recorder) ──
|
||
type AudioSettings = {
|
||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
||
};
|
||
type AudioDev = { id: string; name: string; default: boolean };
|
||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
||
});
|
||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
||
const setAudioField = (patch: Partial<AudioSettings>) => setAudioCfg((s) => ({ ...s, ...patch }));
|
||
const reloadAudioDevices = () => {
|
||
ListAudioInputDevices().then((d) => setAudioInputs((d ?? []) as AudioDev[])).catch(() => {});
|
||
ListAudioOutputDevices().then((d) => setAudioOutputs((d ?? []) as AudioDev[])).catch(() => {});
|
||
};
|
||
// DVK voice-keyer messages (F1–F6).
|
||
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||
const [dvkErr, setDvkErr] = useState('');
|
||
const [monitorOn, setMonitorOn] = useState(false);
|
||
const [txOn, setTxOn] = useState(false);
|
||
useEffect(() => {
|
||
AudioMonitorActive().then(setMonitorOn).catch(() => {});
|
||
AudioTXActive().then(setTxOn).catch(() => {});
|
||
}, []);
|
||
const toggleMonitor = async () => {
|
||
try {
|
||
if (monitorOn) { await AudioStopMonitor(); setMonitorOn(false); }
|
||
else { await AudioStartMonitor(); setMonitorOn(true); }
|
||
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
|
||
};
|
||
const toggleTX = async () => {
|
||
try {
|
||
if (txOn) { await AudioStopTX(); setTxOn(false); }
|
||
else { await AudioStartTX(); setTxOn(true); }
|
||
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
|
||
};
|
||
|
||
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||
const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0');
|
||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||
// Compact rotor widget: the dial and the SP/LP readout only. Off by default —
|
||
// the full widget is what the operator has today, and a setting that changes
|
||
// a panel the moment you upgrade is a setting that gets blamed for it.
|
||
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
|
||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
||
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
|
||
// function by the PANELS map, so it must stay hooks-free.
|
||
const [clusterMacros, setClusterMacros] = useState<ClusterMacro[]>(loadClusterMacros);
|
||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||
// Password-encryption (secret vault) state.
|
||
const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
|
||
const [ppNew, setPpNew] = useState('');
|
||
const [ppConfirm, setPpConfirm] = useState('');
|
||
const [ppErr, setPpErr] = useState('');
|
||
const [ppBusy, setPpBusy] = useState(false);
|
||
const refreshSecret = async () => { try { setSecret(await GetSecretStatus() as any); } catch {} };
|
||
useEffect(() => { refreshSecret(); }, []);
|
||
const applyPassphrase = async () => {
|
||
if (ppNew !== ppConfirm) { setPpErr(t('gen.pwMismatch')); return; }
|
||
setPpBusy(true); setPpErr('');
|
||
try { await SetPassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
|
||
catch (e: any) { setPpErr(String(e?.message ?? e)); }
|
||
finally { setPpBusy(false); }
|
||
};
|
||
const removePassphrase = async () => {
|
||
setPpBusy(true); setPpErr('');
|
||
try { await RemovePassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
|
||
catch (e: any) { setPpErr(String(e?.message ?? e)); }
|
||
finally { setPpBusy(false); }
|
||
};
|
||
|
||
// E-mail / SMTP (send QSO recordings).
|
||
type EmailCfg = {
|
||
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
|
||
from: string; reply_to: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
|
||
};
|
||
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
|
||
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
|
||
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||
});
|
||
const [emailMsg, setEmailMsg] = useState('');
|
||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||
type EQSLCfg = { subject: string; body: string; auto_send: boolean; default_message: string };
|
||
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false, default_message: '' });
|
||
const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch }));
|
||
// ClubLog Country File (cty.xml) exception status.
|
||
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
|
||
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
|
||
const [clubBusy, setClubBusy] = useState(false);
|
||
const [clubErr, setClubErr] = useState('');
|
||
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
|
||
// ClubLog Most Wanted (rank shown in the entry matrix).
|
||
type MWInfo = { enabled: boolean; loaded: boolean; callsign: string; date: string; count: number };
|
||
const [mwInfo, setMwInfo] = useState<MWInfo>({ enabled: false, loaded: false, callsign: '', date: '', count: 0 });
|
||
const [mwBusy, setMwBusy] = useState(false);
|
||
const [mwErr, setMwErr] = useState('');
|
||
useEffect(() => { GetClublogMostWantedInfo().then((i) => setMwInfo(i as MWInfo)).catch(() => {}); }, []);
|
||
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
|
||
useEffect(() => {
|
||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
||
return () => { off?.(); };
|
||
}, []);
|
||
|
||
type QSLDefaults = {
|
||
qsl_sent: string; qsl_rcvd: string;
|
||
lotw_sent: string; lotw_rcvd: string;
|
||
eqsl_sent: string; eqsl_rcvd: string;
|
||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
||
qrzcom_confirmed: string;
|
||
hamlog_status: string; hamlog_confirmed: string;
|
||
};
|
||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||
qsl_sent: '', qsl_rcvd: '',
|
||
lotw_sent: '', lotw_rcvd: '',
|
||
eqsl_sent: '', eqsl_rcvd: '',
|
||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||
qrzcom_confirmed: '',
|
||
hamlog_status: '', hamlog_confirmed: '',
|
||
});
|
||
|
||
// External services (logbook upload). One block per service; only QRZ is
|
||
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
|
||
type ExtServiceCfg = {
|
||
api_key: string; email: string; username: string; password: string; callsign: string;
|
||
code: string; qth_nickname: string;
|
||
url: string; station_id: string; // Cloudlog/Wavelog: own instance + station profile
|
||
force_station_callsign: string;
|
||
tqsl_path: string; station_location: string; key_password: string;
|
||
upload_flags: string[]; write_log: boolean;
|
||
auto_upload: boolean; upload_mode: string;
|
||
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
|
||
on_air?: boolean;
|
||
};
|
||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; delete_remote?: boolean };
|
||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||
upload_flags: ['N', 'R'], write_log: false,
|
||
auto_upload: false, upload_mode: 'immediate', on_air: false,
|
||
});
|
||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), delete_remote: false,
|
||
});
|
||
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);
|
||
// LoTW user-activity list (for the callsign badge): count + last refresh.
|
||
const [lotwUsers, setLotwUsers] = useState<{ count: number; updated?: string }>({ count: 0 });
|
||
const [lotwUsersBusy, setLotwUsersBusy] = useState(false);
|
||
useEffect(() => { GetLoTWUsersStatus().then((s) => setLotwUsers(s as any)).catch(() => {}); }, []);
|
||
const downloadLotwUsers = async () => {
|
||
setLotwUsersBusy(true);
|
||
try { await DownloadLoTWUsers(); const s = await GetLoTWUsersStatus(); setLotwUsers(s as any); }
|
||
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||
finally { setLotwUsersBusy(false); }
|
||
};
|
||
// Super Check Partial / N+1 callsign helper: enabled flag + master-list status.
|
||
const [scp, setScp] = useState<{ enabled: boolean; count: number; updated?: string }>({ enabled: false, count: 0 });
|
||
const [scpBusy, setScpBusy] = useState(false);
|
||
useEffect(() => { GetScpStatus().then((s) => setScp(s as any)).catch(() => {}); }, []);
|
||
const toggleScp = async (on: boolean) => {
|
||
setScp((s) => ({ ...s, enabled: on }));
|
||
setScpBusy(true);
|
||
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||
finally { setScpBusy(false); }
|
||
};
|
||
const downloadScp = async () => {
|
||
setScpBusy(true);
|
||
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||
finally { setScpBusy(false); }
|
||
};
|
||
// US Counties (offline FCC ULS) — download progress arrives via events.
|
||
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string; needs_refresh?: boolean }>({ count: 0 });
|
||
const [ulsBusy, setUlsBusy] = useState(false);
|
||
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
|
||
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||
const [backfillBusy, setBackfillBusy] = useState(false);
|
||
const [backfillMsg, setBackfillMsg] = useState<string | null>(null);
|
||
useEffect(() => { ULSStatus().then((s) => setUlsStatus(s as any)).catch(() => {}); }, []);
|
||
useEffect(() => {
|
||
const off1 = EventsOn('uls:progress', (p: any) => setUlsProgress({ stage: p?.stage ?? '', pct: p?.pct ?? 0 }));
|
||
const off2 = EventsOn('uls:done', async (r: any) => {
|
||
setUlsBusy(false); setUlsProgress(null);
|
||
setUlsMsg(r?.ok ? { ok: true, text: t('uscty.done', { n: r?.count ?? 0 }) } : { ok: false, text: r?.error || 'error' });
|
||
try { const s = await ULSStatus(); setUlsStatus(s as any); } catch {}
|
||
});
|
||
return () => { off1(); off2(); };
|
||
}, []);
|
||
const downloadUls = async () => {
|
||
setUlsBusy(true); setUlsMsg(null); setUlsProgress({ stage: '', pct: 0 });
|
||
try { await DownloadULSCounties(); }
|
||
catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
|
||
};
|
||
// cty.dat is not downloaded by OpsLog — it is shipped and reloaded from disk —
|
||
// so the page reports it rather than offering a button it cannot honour.
|
||
// Panadapter spot palette. Saved on its own — it is a table of colours, not a
|
||
// form: waiting for the modal's Save to see a colour on the waterfall would
|
||
// make picking one a guessing game.
|
||
type SpotColor = { text: string; bg?: string };
|
||
const [spotColors, setSpotColors] = useState<{ enabled: boolean; colors: Record<string, SpotColor & { hide?: boolean }> }>({ enabled: true, colors: {} });
|
||
useEffect(() => { GetSpotColors().then((c: any) => setSpotColors(c ?? { enabled: true, colors: {} })).catch(() => {}); }, []);
|
||
const saveSpotColors = (next: { enabled: boolean; colors: Record<string, SpotColor> }) => {
|
||
setSpotColors(next);
|
||
SaveSpotColors(next as any).catch(() => {});
|
||
};
|
||
const setSpotColor = (k: string, patch: Partial<SpotColor>) => saveSpotColors({
|
||
...spotColors,
|
||
colors: { ...spotColors.colors, [k]: { ...(spotColors.colors[k] ?? { text: '' }), ...patch } },
|
||
});
|
||
// Panadapter auto-zoom. Saved immediately for the same reason as the palette:
|
||
// the only way to judge a width is to click a spot and look at the radio.
|
||
const [flexZoom, setFlexZoom] = useState<{ enabled: boolean; cw_khz: number; ssb_khz: number; digi_khz: number; centre: boolean }>(
|
||
{ enabled: false, cw_khz: 25, ssb_khz: 200, digi_khz: 50, centre: false });
|
||
useEffect(() => { GetFlexZoom().then((z: any) => setFlexZoom(z)).catch(() => {}); }, []);
|
||
const saveFlexZoom = (next: typeof flexZoom) => { setFlexZoom(next); SaveFlexZoom(next as any).catch(() => {}); };
|
||
const [ctyInfo, setCtyInfo] = useState<{ entities: number; file_mod_time?: string }>({ entities: 0 });
|
||
const [ctyBusy, setCtyBusy] = useState(false);
|
||
useEffect(() => { GetCtyDatInfo().then((i) => setCtyInfo(i as any)).catch(() => {}); }, []);
|
||
const refreshCty = async () => {
|
||
setCtyBusy(true);
|
||
try { const i: any = await RefreshCtyDat(); setCtyInfo(i); } catch { /* the line keeps its old count */ }
|
||
finally { setCtyBusy(false); }
|
||
};
|
||
// Award reference lists, and which of them have an online updater.
|
||
const [refMeta, setRefMeta] = useState<Array<{ code: string; count: number; updated_at?: string; can_update: boolean }>>([]);
|
||
const [refBusy, setRefBusy] = useState('');
|
||
const [refMsg, setRefMsg] = useState('');
|
||
const reloadRefMeta = () => { GetAwardReferenceMeta().then((m) => setRefMeta((m ?? []) as any)).catch(() => {}); };
|
||
useEffect(reloadRefMeta, []);
|
||
const updateRefList = async (code: string) => {
|
||
setRefBusy(code); setRefMsg('');
|
||
try { const m: any = await UpdateAwardReferenceList(code); setRefMsg(t('db.refUpdated', { code, n: m?.count ?? 0 })); reloadRefMeta(); }
|
||
catch (e: any) { setRefMsg(String(e?.message ?? e)); }
|
||
finally { setRefBusy(''); }
|
||
};
|
||
// A date for a person: the day, not the timestamp the backend stores.
|
||
const fmtDay = (v?: string) => (v ? String(v).slice(0, 10) : '—');
|
||
// Binary units, one decimal, because the point of showing a size here is to
|
||
// compare two of them: "412.7 MB → 38.4 MB" says what a bare byte count does not.
|
||
const fmtBytes = (n: number) => {
|
||
if (!n || n < 0) return '—';
|
||
const u = ['B', 'KB', 'MB', 'GB'];
|
||
let i = 0;
|
||
let v = n;
|
||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
||
return `${i === 0 ? v : v.toFixed(1)} ${u[i]}`;
|
||
};
|
||
const [rdaCount, setRdaCount] = useState(0);
|
||
const [rdaBusy, setRdaBusy] = useState(false);
|
||
const [rdaUseCurrent, setRdaUseCurrent] = useState(true);
|
||
const [rdaMsg, setRdaMsg] = useState<string | null>(null);
|
||
useEffect(() => { RDADatabaseCount().then((n) => setRdaCount(Number(n) || 0)).catch(() => {}); }, []);
|
||
// Measuring the two sources against each other, before deciding which wins.
|
||
//
|
||
// Read-only on purpose: the question "is HAMLOG's district independent
|
||
// evidence or a copy of the same database?" has to be answered with the
|
||
// operator's own log, and answering it must not modify that log.
|
||
const [rdaCmp, setRdaCmp] = useState<any>(null);
|
||
const [rdaCmpBusy, setRdaCmpBusy] = useState(false);
|
||
// Its own message, next to its own button.
|
||
//
|
||
// A failed comparison used to write into rdaMsg — which is rendered beside
|
||
// the FILL DISTRICTS button, a row above. The error appeared under a button
|
||
// nobody had pressed, and the one that had been pressed showed nothing at
|
||
// all, which reads as "the button does nothing".
|
||
const [rdaCmpMsg, setRdaCmpMsg] = useState<string>('');
|
||
const runRDACompare = async () => {
|
||
setRdaCmpBusy(true); setRdaCmp(null); setRdaCmpMsg('');
|
||
try {
|
||
const r: any = await CompareRDASources();
|
||
setRdaCmp(r);
|
||
// A comparison that finds nothing is a RESULT, and the most likely one on
|
||
// a tidy log. Without a word for it the panel looked like it had not run.
|
||
if (!r || (r.scanned ?? 0) === 0) setRdaCmpMsg(t('rda.cmpNoRussian'));
|
||
else if ((r.disagree ?? 0) === 0) setRdaCmpMsg(t('rda.cmpNoConflict'));
|
||
} catch (e: any) { setRdaCmpMsg(String(e?.message ?? e)); }
|
||
finally { setRdaCmpBusy(false); }
|
||
};
|
||
|
||
const runRDABackfill = async () => {
|
||
setRdaBusy(true); setRdaMsg(null);
|
||
try {
|
||
const r: any = await BackfillRDA(rdaUseCurrent);
|
||
setRdaMsg(t('rda.backfillDone', {
|
||
d: r?.dated ?? 0, c: r?.current ?? 0, u: r?.unknown ?? 0, k: r?.skipped ?? 0, s: r?.scanned ?? 0,
|
||
}));
|
||
} catch (e: any) { setRdaMsg(String(e?.message ?? e)); }
|
||
finally { setRdaBusy(false); }
|
||
};
|
||
const runBackfill = async () => {
|
||
setBackfillBusy(true); setBackfillMsg(null);
|
||
try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
|
||
catch (e: any) { setBackfillMsg(String(e?.message ?? e)); }
|
||
finally { setBackfillBusy(false); }
|
||
};
|
||
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||
const [cloudlogTest, setCloudlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
||
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
const [hamlogTesting, setHamlogTesting] = useState(false);
|
||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
const [eqslTesting, setEqslTesting] = useState(false);
|
||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||
// Active tab in the External Services panel. Lifted here back when a panel
|
||
// could not hold hooks at all; PanelHost lifted that restriction, and this
|
||
// stays put because moving it down would reset the tab on every reopen —
|
||
// a choice now, not a workaround.
|
||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'pota'>('qrz');
|
||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||
const [potaToken, setPotaToken] = useState('');
|
||
const [potaBusy, setPotaBusy] = useState(false);
|
||
const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string; unmatched?: any[] } | null>(null);
|
||
useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []);
|
||
|
||
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
||
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; logbook_default_path?: string }>({ path: '', default_path: '', is_custom: false });
|
||
const [dbMsg, setDbMsg] = useState('');
|
||
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string; sqlite_path?: string };
|
||
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
|
||
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
|
||
const [mysqlMsg, setMysqlMsg] = useState('');
|
||
const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply"
|
||
const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
|
||
|
||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
||
// Defaults to true so the checkbox matches the backend before the read lands.
|
||
const [workedVariants, setWorkedVariants] = useState(true);
|
||
// Self-spot. SELF_SPOT_MIN_MIN mirrors the backend floor — the input clamps on
|
||
// blur, not per keystroke, or typing "10" would be rewritten to "5" the moment
|
||
// the "1" landed and the field would fight the operator.
|
||
const SELF_SPOT_MIN_MIN = 5;
|
||
// Band-opening watch. Saved through the backend rather than as a UI pref: it
|
||
// has side effects there — adding the RBN nodes, bringing the PSK Reporter
|
||
// feed up or down — so the write has to go where those live.
|
||
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
||
// How a worked square is matched, and what counts as still wanted.
|
||
const [gridScope, setGridScope] = useState<any>({ scope: 'mix_digi', hunt: 'new', scopes: [] });
|
||
useEffect(() => { GetGridScopeSettings().then((g) => setGridScope(g as any)).catch(() => {}); }, []);
|
||
const saveGridScope = async (next: any) => {
|
||
setGridScope(next);
|
||
try { await SaveGridScopeSettings(next); } catch { /* the panel keeps the choice either way */ }
|
||
};
|
||
const [chaseGrids, setChaseGrids] = useState(false);
|
||
const [chaseNew, setChaseNew] = useState(false);
|
||
const [spotTTL, setSpotTTL] = useState(0);
|
||
const [spotTTLText, setSpotTTLText] = useState('0');
|
||
const [spotMaxText, setSpotMaxText] = useState('1000');
|
||
// TCI receive-audio test bench. Polled only while the stream is open: a panel
|
||
// that asks the backend twice a second for a stream nobody started is work
|
||
// done for nothing.
|
||
|
||
|
||
|
||
// Whether the QSO recorder takes its audio from the radio's own stream.
|
||
const [gridStat, setGridStat] = useState<any>(null);
|
||
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
||
const saveBandOpen = async (next: any) => {
|
||
setBandOpen(next);
|
||
try { await SaveBandOpenSettings(next); } catch { /* the status line shows the result */ }
|
||
};
|
||
useEffect(() => {
|
||
(async () => {
|
||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
||
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
|
||
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||
try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ }
|
||
})();
|
||
}, []);
|
||
// Poll the feed only while the section that SHOWS it is open.
|
||
//
|
||
// A live count is the one thing that separates "connected" from "connected
|
||
// and receiving nothing", so it has to be polled — but it was polled from
|
||
// everywhere, re-rendering the whole dialog every three seconds whichever
|
||
// panel was in front. That is a heartbeat through every list and every form
|
||
// in Preferences for a number nobody is looking at.
|
||
useEffect(() => {
|
||
if (selected !== 'cluster') return;
|
||
const t = window.setInterval(async () => {
|
||
try { setPskrStatus(await GetPSKReporterStatus()); } catch { /* ignore */ }
|
||
try { setGridStat(await GetGridCacheStatus()); } catch { /* ignore */ }
|
||
}, 3000);
|
||
return () => window.clearInterval(t);
|
||
}, [selected]);
|
||
const [selfSpot, setSelfSpot] = useState({ enabled: false, minutes: SELF_SPOT_MIN_MIN });
|
||
const [selfSpotText, setSelfSpotText] = useState(String(SELF_SPOT_MIN_MIN));
|
||
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
|
||
const [editingServer, setEditingServer] = useState<ClusterServer | null>(null);
|
||
|
||
async function reloadClusterServers() {
|
||
try {
|
||
const [list, ac, st, ss] = await Promise.all([
|
||
ListClusterServers(),
|
||
GetClusterAutoConnect(),
|
||
GetClusterStatus(),
|
||
GetSelfSpotSettings(),
|
||
]);
|
||
setClusterServers((list ?? []) as ClusterServer[]);
|
||
setClusterAutoConnectState(ac);
|
||
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
|
||
setSelfSpot(ss as any);
|
||
setSelfSpotText(String((ss as any).minutes ?? SELF_SPOT_MIN_MIN));
|
||
} 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<Profile[]>([]);
|
||
// State for ProfilesPanel. Lifted here back when a panel could not hold hooks
|
||
// — PanelHost lifted that restriction — and left here because the selection
|
||
// then survives switching sections, which is what an operator expects of it.
|
||
const [profileSelectedId, setProfileSelectedId] = useState<number>(0);
|
||
const [profileNameDraft, setProfileNameDraft] = useState<string>('');
|
||
|
||
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(),
|
||
GetRotators(), 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);
|
||
setRotors((r ?? []) as any);
|
||
// Loaded HERE, in the loader that runs on mount — not only in the
|
||
// event-driven one below. Missing from this one, the state stayed empty
|
||
// on a normal open and Save then wrote an empty list over the operator's
|
||
// buttons. See rotorPresetsLoaded for the belt to this brace.
|
||
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||
setBackupCfg(b as any);
|
||
setQslDefaults(qd as any);
|
||
setExtSvc(es as any);
|
||
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
||
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
||
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
|
||
try {
|
||
const locs: any = await ListTQSLStationLocations();
|
||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||
try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {}
|
||
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||
try { setWorkedVariants(await GetWorkedCallVariants()); } catch {}
|
||
reloadAudioDevices();
|
||
reloadDvk();
|
||
} catch (e: any) {
|
||
setErr(String(e?.message ?? e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
// Every setting is per-profile, so when the active profile changes WHILE this
|
||
// dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…)
|
||
// — otherwise they keep showing the previous profile's values until reopen.
|
||
useEffect(() => {
|
||
const off = EventsOn('profile:changed', () => {
|
||
(async () => {
|
||
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
||
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
|
||
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
||
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
||
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||
try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {}
|
||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||
try {
|
||
const ls: any = await GetListsSettings();
|
||
setLists(ls);
|
||
setRstText({
|
||
phone: (ls.rst_phone ?? []).join(' '),
|
||
cw: (ls.rst_cw ?? []).join(' '),
|
||
digital: (ls.rst_digital ?? []).join(' '),
|
||
});
|
||
} catch {}
|
||
})();
|
||
});
|
||
return () => { off(); };
|
||
}, []);
|
||
|
||
// Which (profile, callsign, grid) the auto-fill below last derived from.
|
||
// Without it, simply OPENING the settings counted as "the source changed" and
|
||
// overwrote the operator's own values.
|
||
const derivedFrom = useRef<string>('');
|
||
|
||
// 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 recompute when the callsign or grid
|
||
// changes. What they must NOT do is recompute on a plain load: cty.dat gives
|
||
// the zones of the ENTITY, and a large country spans several — an operator in
|
||
// CQ 14 / ITU 27 was handed 15 and 28 and corrected them by hand, and every
|
||
// restart put the wrong pair back. cty.dat is a starting point, not an
|
||
// authority, so on a load we only fill fields that are EMPTY; a value the
|
||
// operator typed stays until the callsign or grid itself 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 source = `${activeProfile?.id ?? 0}|${call.toUpperCase()}|${grid.toUpperCase()}`;
|
||
// First sight of this profile — a load, not an edit.
|
||
const isLoad = derivedFrom.current === '' || derivedFrom.current.split('|')[0] !== String(activeProfile?.id ?? 0);
|
||
const t = window.setTimeout(async () => {
|
||
try {
|
||
const i: any = await ComputeStationInfo(call, grid);
|
||
derivedFrom.current = source;
|
||
setActiveProfile((p) => {
|
||
if (!p) return p;
|
||
const patch: any = {};
|
||
// On a load, keep in patch only what the profile does not already have.
|
||
const put = (k: string, v: any) => {
|
||
if (!v) return;
|
||
const cur = (p as any)[k];
|
||
if (isLoad && cur !== undefined && cur !== null && cur !== '' && cur !== 0) return;
|
||
patch[k] = v;
|
||
};
|
||
put('my_country', i.country);
|
||
put('my_dxcc', i.dxcc);
|
||
put('my_cqz', i.cqz);
|
||
put('my_ituz', i.ituz);
|
||
put('my_lat', i.lat);
|
||
put('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<ModePreset>) {
|
||
setLists((l) => {
|
||
const next = [...(l.modes ?? [])];
|
||
next[i] = { ...next[i], ...patch } as ModePreset;
|
||
return { ...l, modes: next };
|
||
});
|
||
}
|
||
|
||
async function save(close = true) {
|
||
setSaving(true); setErr(''); setMsg('');
|
||
try {
|
||
// Bands: dedup, lowercase, trim. Order = user's drag order.
|
||
const seen = new Set<string>();
|
||
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),
|
||
// Normalised HERE and not only on the field's blur: Save is reachable
|
||
// without ever leaving the box, and a list that depends on where the
|
||
// cursor went before the click is a list that sometimes saves.
|
||
satellites: Array.from(new Set(((lists.satellites ?? []) as string[])
|
||
.map((v) => v.trim().toUpperCase()).filter(Boolean))).sort(),
|
||
} 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(),
|
||
my_iota: (activeProfile.my_iota ?? '').trim().toUpperCase(),
|
||
} as any);
|
||
}
|
||
await SaveLookupSettings(lookup as any);
|
||
await SaveCATSettings(catCfg as any);
|
||
await SaveRotators(rotors as any);
|
||
// Only once they have been read back — see rotorPresetsLoaded.
|
||
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
|
||
await SaveUltrabeamSettings(ultrabeam as any);
|
||
await SaveAntGeniusSettings(antgenius as any);
|
||
await SaveTunerGeniusSettings(tunergenius as any);
|
||
await SavePSUSettings(psuCfg as any);
|
||
await SaveAmplifiers(amps as any);
|
||
await SaveWinkeyerSettings(wk as any);
|
||
await SaveAudioSettings(audioCfg as any);
|
||
await SaveEmailSettings(emailCfg as any);
|
||
await QSLSaveEmailTemplates(eqslCfg as any);
|
||
await SaveBackupSettings(backupCfg as any);
|
||
await SaveQSLDefaults(qslDefaults as any);
|
||
await SaveExternalServices(extSvc as any);
|
||
await SetClusterAutoConnect(clusterAutoConnect);
|
||
await SaveSelfSpotSettings(selfSpot as any);
|
||
|
||
setMsg('Settings saved.');
|
||
onSaved();
|
||
if (close) setTimeout(onClose, 500);
|
||
else setTimeout(() => setMsg(''), 2000);
|
||
} catch (e: any) {
|
||
setErr(String(e?.message ?? e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function clearCache() {
|
||
setClearing(true); setErr(''); setMsg('');
|
||
try {
|
||
await ClearLookupCache();
|
||
setMsg('Cache cleared.');
|
||
} catch (e: any) {
|
||
setErr(String(e?.message ?? e));
|
||
} finally {
|
||
setClearing(false);
|
||
}
|
||
}
|
||
|
||
async function testProvider(provider: 'qrz' | 'hamqth') {
|
||
setLookupTesting((s) => ({ ...s, [provider]: true }));
|
||
setLookupTest((s) => ({ ...s, [provider]: undefined }));
|
||
const user = provider === 'qrz' ? lookup.qrz_user : lookup.hamqth_user;
|
||
const pwd = provider === 'qrz' ? lookup.qrz_password : lookup.hamqth_password;
|
||
try {
|
||
const r = await TestLookupProvider(provider, '', user ?? '', pwd ?? '');
|
||
setLookupTest((s) => ({ ...s, [provider]: { ok: true, msg: `OK — ${r.callsign} (${r.name || r.country || 'no name'})` } }));
|
||
} catch (e: any) {
|
||
setLookupTest((s) => ({ ...s, [provider]: { ok: false, msg: String(e?.message ?? e) } }));
|
||
} finally {
|
||
setLookupTesting((s) => ({ ...s, [provider]: false }));
|
||
}
|
||
}
|
||
|
||
const breadcrumb = useMemo(() => { const k = SECTION_KEY[selected]; return k ? t(k) : (SECTION_LABELS[selected] ?? selected); }, [selected, t]);
|
||
|
||
// === Section content renderers ===
|
||
|
||
function StationPanel() {
|
||
if (!activeProfile) {
|
||
return <div className="text-muted-foreground text-sm">{t('station.loading')}</div>;
|
||
}
|
||
const p = activeProfile;
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.station')}
|
||
hint={t('station.hint', { name: p.name })}
|
||
/>
|
||
<div className="grid grid-cols-2 gap-3 max-w-2xl">
|
||
<div className="space-y-1">
|
||
<Label>{t('station.stationCall')}</Label>
|
||
<Input className="font-mono uppercase" value={p.callsign ?? ''} onChange={(e) => updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
|
||
<div className="text-[10px] text-muted-foreground">{t('station.stationCallHint')}</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.opCall')}</Label>
|
||
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
|
||
<div className="text-[10px] text-muted-foreground">{t('station.opCallHint')}</div>
|
||
</div>
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('station.ownerCall')}</Label>
|
||
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder={t('station.ownerBlank')} />
|
||
<div className="text-[10px] text-muted-foreground">{t('station.ownerHint')}</div>
|
||
</div>
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('station.opName')}</Label>
|
||
<Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
|
||
<div className="text-[10px] text-muted-foreground">{t('station.opNameHint')}</div>
|
||
</div>
|
||
<div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
|
||
{t('station.autofill')}
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.myGrid')}</Label>
|
||
<Input className="font-mono uppercase" value={p.my_grid ?? ''} onChange={(e) => updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.myCountry')}</Label>
|
||
<Input value={p.my_country ?? ''} onChange={(e) => updateActive({ my_country: e.target.value })} placeholder="France" />
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2 col-span-2">
|
||
<div className="space-y-1">
|
||
<Label>{t('station.dxcc')}</Label>
|
||
<Input type="number" className="font-mono" value={(p as any).my_dxcc ?? ''}
|
||
onChange={(e) => updateActive({ my_dxcc: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.cqz')}</Label>
|
||
<Input type="number" className="font-mono" value={(p as any).my_cqz ?? ''}
|
||
onChange={(e) => updateActive({ my_cqz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.ituz')}</Label>
|
||
<Input type="number" className="font-mono" value={(p as any).my_ituz ?? ''}
|
||
onChange={(e) => updateActive({ my_ituz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.lat')}</Label>
|
||
<Input type="number" step="0.001" className="font-mono" value={(p as any).my_lat ?? ''}
|
||
onChange={(e) => updateActive({ my_lat: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.lon')}</Label>
|
||
<Input type="number" step="0.001" className="font-mono" value={(p as any).my_lon ?? ''}
|
||
onChange={(e) => updateActive({ my_lon: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.state')}</Label>
|
||
<Input value={p.my_state ?? ''} onChange={(e) => updateActive({ my_state: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.county')}</Label>
|
||
<Input value={p.my_cnty ?? ''} onChange={(e) => updateActive({ my_cnty: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('station.street')}</Label>
|
||
<Input value={p.my_street ?? ''} onChange={(e) => updateActive({ my_street: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.postal')}</Label>
|
||
<Input value={p.my_postal_code ?? ''} onChange={(e) => updateActive({ my_postal_code: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.city')}</Label>
|
||
<Input value={p.my_city ?? ''} onChange={(e) => updateActive({ my_city: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.sota')}</Label>
|
||
<Input className="font-mono uppercase" value={p.my_sota_ref ?? ''} onChange={(e) => updateActive({ my_sota_ref: e.target.value })} placeholder="F/AB-001" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.pota')}</Label>
|
||
<Input className="font-mono uppercase" value={p.my_pota_ref ?? ''} onChange={(e) => updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.iota')}</Label>
|
||
<Input className="font-mono uppercase" value={p.my_iota ?? ''} onChange={(e) => updateActive({ my_iota: e.target.value })} placeholder="EU-005" />
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Profile actions — kept at the SettingsModal level so the ProfilesPanel
|
||
// renderer can stay hooks-free (the PANELS map calls it as a plain
|
||
// function, not as a JSX component).
|
||
const activeProfileObj = profiles.find((p) => p.is_active) ?? profiles[0];
|
||
const currentProfile = profiles.find((p) => (p.id as number) === profileSelectedId);
|
||
|
||
async function profileActivate() {
|
||
if (!currentProfile) return;
|
||
try {
|
||
await ActivateProfile(currentProfile.id as number);
|
||
await reloadProfiles();
|
||
// Per-profile settings follow the active identity — reload the panels
|
||
// that are now scoped to the newly-active profile.
|
||
const [ap, qd, es] = await Promise.all([GetActiveProfile(), GetQSLDefaults(), GetExternalServices()]);
|
||
setActiveProfile(ap as Profile);
|
||
setQslDefaults(qd as any);
|
||
setExtSvc(es as any);
|
||
onSaved();
|
||
}
|
||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function profileRemove() {
|
||
if (!currentProfile) return;
|
||
if (!confirm(t('prof.deleteConfirm', { name: currentProfile.name }))) return;
|
||
try { await DeleteProfile(currentProfile.id as number); await reloadProfiles(); }
|
||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function profileDuplicate() {
|
||
if (!currentProfile) return;
|
||
const name = prompt(t('prof.dupPrompt', { name: currentProfile.name }), t('prof.dupSuffix', { name: currentProfile.name }));
|
||
if (!name?.trim()) return;
|
||
try {
|
||
const dup = await DuplicateProfile(currentProfile.id as number, name.trim());
|
||
await reloadProfiles();
|
||
setProfileSelectedId(dup.id as number);
|
||
setProfileNameDraft(dup.name);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function profileCreateBlank() {
|
||
const name = prompt(t('prof.newPrompt'), t('prof.newDefault'));
|
||
if (!name?.trim()) return;
|
||
try {
|
||
const blank = emptyProfile();
|
||
blank.name = name.trim();
|
||
const saved = await SaveProfile(blank as any);
|
||
await reloadProfiles();
|
||
setProfileSelectedId(saved.id as number);
|
||
setProfileNameDraft(saved.name);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function profileRenameCurrent() {
|
||
if (!currentProfile || profileNameDraft.trim() === currentProfile.name) return;
|
||
try {
|
||
await SaveProfile({ ...currentProfile, name: profileNameDraft.trim() } as any);
|
||
await reloadProfiles();
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
|
||
function ProfilesPanel() {
|
||
const current = currentProfile;
|
||
const active = activeProfileObj;
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.profiles')}
|
||
hint={t('prof.hint')}
|
||
/>
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-[140px_1fr] items-center gap-x-3 gap-y-3">
|
||
<Label>{t('prof.configId')}</Label>
|
||
<Select
|
||
value={String(profileSelectedId)}
|
||
onValueChange={(v) => {
|
||
const id = parseInt(v, 10);
|
||
setProfileSelectedId(id);
|
||
setProfileNameDraft(profiles.find((p) => (p.id as number) === id)?.name ?? '');
|
||
}}
|
||
>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{profiles.map((p) => (
|
||
<SelectItem key={p.id as number} value={String(p.id)}>
|
||
{p.name}{p.callsign ? ` — ${p.callsign}` : ''}{p.is_active ? t('prof.activeSuffix') : ''}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<Label>{t('prof.description')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
value={profileNameDraft}
|
||
onChange={(e) => setProfileNameDraft(e.target.value)}
|
||
onBlur={profileRenameCurrent}
|
||
placeholder={t('prof.profileName')}
|
||
disabled={!current}
|
||
/>
|
||
{current?.is_active && (
|
||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-wider bg-success-muted text-success-muted-foreground border border-success-border">
|
||
{t('prof.active')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 pt-2 border-t border-border">
|
||
<Button variant="outline" size="sm" onClick={profileCreateBlank} title={t('prof.newTitle')}>
|
||
<Plus className="size-3.5 mr-1" /> {t('prof.new')}
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={profileDuplicate} disabled={!current} title={t('prof.dupTitle')}>
|
||
<Copy className="size-3.5 mr-1" /> {t('prof.duplicate')}
|
||
</Button>
|
||
<Button
|
||
variant="outline" size="sm"
|
||
onClick={profileActivate}
|
||
disabled={!current || current.is_active}
|
||
title={t('prof.setActiveTitle')}
|
||
>
|
||
<Star className="size-3.5 mr-1" /> {t('prof.setActive')}
|
||
</Button>
|
||
<Button
|
||
variant="outline" size="sm"
|
||
onClick={profileRemove}
|
||
disabled={!current || profiles.length <= 1}
|
||
className="text-destructive hover:text-destructive ml-auto"
|
||
title={profiles.length <= 1 ? t('prof.cantDeleteLast') : t('prof.deleteTitle')}
|
||
>
|
||
<Trash2 className="size-3.5 mr-1" /> {t('prof.delete')}
|
||
</Button>
|
||
</div>
|
||
|
||
{current && !current.is_active && (
|
||
<div className="text-xs text-muted-foreground bg-muted/30 border border-border rounded-md p-2.5">
|
||
{t('prof.viewingNote', { name: current.name, active: active?.name ?? '' })}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function LookupPanel() {
|
||
// The cache lifetime as TYPED, so the box can be emptied and re-filled.
|
||
// Re-seeded when the settings arrive from the backend — which is after the
|
||
// first render, so it cannot simply be the initial value.
|
||
const [ttlText, setTtlText] = useState(String(lookup.cache_ttl_days));
|
||
useEffect(() => { setTtlText(String(lookup.cache_ttl_days)); }, [lookup.cache_ttl_days]);
|
||
// 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 (
|
||
<tr className="border-t border-border align-middle">
|
||
<td className="px-3 py-2 font-semibold whitespace-nowrap">{label}</td>
|
||
<td className="px-2 py-2 text-center">
|
||
<input
|
||
type="radio"
|
||
name="lookup-primary"
|
||
checked={lookup.primary === key}
|
||
onChange={() => setLookup((s) => ({ ...s, primary: key, failsafe: s.failsafe === key ? '' : s.failsafe }))}
|
||
disabled={!hasCreds}
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-2 text-center">
|
||
<input
|
||
type="radio"
|
||
name="lookup-failsafe"
|
||
checked={lookup.failsafe === key}
|
||
onChange={() => setLookup((s) => ({ ...s, failsafe: key, primary: s.primary === key ? '' : s.primary }))}
|
||
disabled={!hasCreds || lookup.primary === key}
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-2">
|
||
<Input
|
||
className="h-8"
|
||
value={lookup[userField] ?? ''}
|
||
onChange={(e) => setLookup((s) => ({ ...s, [userField]: e.target.value }))}
|
||
placeholder={t('lk.user')}
|
||
autoComplete="off"
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-2">
|
||
<Input
|
||
className="h-8"
|
||
type="password"
|
||
value={lookup[pwdField] ?? ''}
|
||
onChange={(e) => setLookup((s) => ({ ...s, [pwdField]: e.target.value }))}
|
||
placeholder={t('lk.password')}
|
||
autoComplete="off"
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-2 whitespace-nowrap">
|
||
<Button
|
||
variant="outline" size="sm"
|
||
onClick={() => testProvider(key)}
|
||
disabled={!hasCreds || testing}
|
||
title={t('lk.testTitle')}
|
||
>
|
||
{testing ? t('lk.testing') : t('lk.test')}
|
||
</Button>
|
||
</td>
|
||
<td className={cn('px-2 py-2 text-xs', test?.ok ? 'text-success' : 'text-destructive')}>
|
||
{test?.msg}
|
||
</td>
|
||
</tr>
|
||
);
|
||
};
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.lookup')}
|
||
hint={t('lk.hint')}
|
||
/>
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||
<tr>
|
||
<th className="text-left px-3 py-2">{t('lk.provider')}</th>
|
||
<th className="px-2 py-2 w-20">{t('lk.primary')}</th>
|
||
<th className="px-2 py-2 w-20">{t('lk.failsafe')}</th>
|
||
<th className="text-left px-2 py-2">{t('lk.user')}</th>
|
||
<th className="text-left px-2 py-2">{t('lk.password')}</th>
|
||
<th className="px-2 py-2 w-20"></th>
|
||
<th className="text-left px-2 py-2">{t('lk.result')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{row('qrz', 'QRZ.com', 'qrz_user', 'qrz_password')}
|
||
{row('hamqth', 'HamQTH', 'hamqth_user', 'hamqth_password')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-2">
|
||
{t('lk.failsafeNote')}
|
||
</p>
|
||
|
||
<div className="mt-6 pt-4 border-t border-border">
|
||
<h3 className="text-sm font-semibold mb-2">{t('lk.display')}</h3>
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={lookup.download_images}
|
||
onCheckedChange={(c) => setLookup((s) => ({ ...s, download_images: !!c }))}
|
||
className="mt-0.5"
|
||
/>
|
||
<span>
|
||
{t('lk.showPics')}
|
||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||
{t('lk.showPicsHint')}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
{/* QRZ only. HamQTH's <nick> already fills the Name field with the
|
||
on-air name, so the same switch there would toggle a behaviour it
|
||
has no way to turn off. */}
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer mt-3">
|
||
<Checkbox
|
||
checked={!!(lookup as any).qrz_prefer_nickname}
|
||
onCheckedChange={(c) => setLookup((s) => ({ ...s, qrz_prefer_nickname: !!c } as any))}
|
||
className="mt-0.5"
|
||
/>
|
||
<span>
|
||
{t('lk.qrzNickname')}
|
||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||
{t('lk.qrzNicknameHint')}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-6 pt-4 border-t border-border">
|
||
<h3 className="text-sm font-semibold mb-2">{t('lk.cache')}</h3>
|
||
<p className="text-xs text-muted-foreground mb-3">
|
||
{t('lk.cacheHint')}
|
||
</p>
|
||
<div className="flex gap-3 items-end">
|
||
<div className="space-y-1 w-40">
|
||
<Label>{t('lk.ttl')}</Label>
|
||
{/* Raw text, not the stored number. Deriving the value from the
|
||
number on every keystroke made the box impossible to empty —
|
||
and "0" itself unreachable, since parseInt('0') || 30 is 30.
|
||
Zero is now a real setting, so it has to be typeable. */}
|
||
<Input
|
||
type="number" min={0} max={3650}
|
||
value={ttlText}
|
||
onChange={(e) => {
|
||
const raw = e.target.value;
|
||
setTtlText(raw);
|
||
const n = parseInt(raw, 10);
|
||
if (Number.isFinite(n) && n >= 0) {
|
||
setLookup((s) => ({ ...s, cache_ttl_days: Math.min(n, 3650) }));
|
||
}
|
||
}}
|
||
onBlur={() => setTtlText(String(lookup.cache_ttl_days))}
|
||
/>
|
||
</div>
|
||
<Button variant="outline" onClick={clearCache} disabled={clearing}>
|
||
{clearing ? t('lk.clearing') : t('lk.clearCache')}
|
||
</Button>
|
||
</div>
|
||
{lookup.cache_ttl_days === 0 && (
|
||
<p className="text-[11px] text-warning mt-2">{t('lk.cacheOff')}</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.bands')}
|
||
hint={t('bnd.hint')}
|
||
/>
|
||
<div className="grid grid-cols-[1fr_auto_1fr] gap-3 max-w-3xl">
|
||
{/* Left: available catalog */}
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||
{t('bnd.available')}
|
||
</div>
|
||
<div className="max-h-[320px] overflow-y-auto divide-y divide-border">
|
||
{available.length === 0 ? (
|
||
<div className="px-3 py-2 text-xs text-muted-foreground italic">{t('bnd.allSelected')}</div>
|
||
) : (
|
||
available.map((b) => (
|
||
<button
|
||
key={b}
|
||
type="button"
|
||
onDoubleClick={() => addBand(b)}
|
||
onClick={() => addBand(b)}
|
||
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors"
|
||
>
|
||
{b}
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
|
||
<Input
|
||
value={bandDraft}
|
||
onChange={(e) => setBandDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
addBand(bandDraft);
|
||
setBandDraft('');
|
||
}
|
||
}}
|
||
placeholder={t('bnd.customPh')}
|
||
className="font-mono h-7 text-xs"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7"
|
||
onClick={() => { addBand(bandDraft); setBandDraft(''); }}
|
||
disabled={!bandDraft.trim()}
|
||
>
|
||
<Plus className="size-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Center: shuttle hint */}
|
||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||
<ArrowRight className="size-4" />
|
||
<ArrowLeft className="size-4" />
|
||
</div>
|
||
|
||
{/* Right: selected */}
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground flex items-center justify-between">
|
||
<span>{t('bnd.selected', { n: selected.length })}</span>
|
||
</div>
|
||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||
{selected.length === 0 ? (
|
||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||
{t('bnd.none')}
|
||
</div>
|
||
) : (
|
||
selected.map((b, i) => (
|
||
<div key={`${b}-${i}`} className="grid grid-cols-[auto_1fr_auto] items-center gap-1 px-2 py-1">
|
||
<div className="flex gap-0.5">
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, -1)} disabled={i === 0}>
|
||
<ArrowUp className="size-3" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, 1)} disabled={i === selected.length - 1}>
|
||
<ArrowDown className="size-3" />
|
||
</Button>
|
||
</div>
|
||
<span className="font-mono text-sm">{b}</span>
|
||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeBand(i)}>
|
||
<Trash2 className="size-3" />
|
||
</Button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SatellitesPanel() {
|
||
const sats = lists.satellites ?? [];
|
||
return (
|
||
<>
|
||
<SectionHeader title={t('sec.satellites')} hint={t('sat.hint')} />
|
||
<div className="space-y-3 max-w-xl">
|
||
<div className="space-y-1">
|
||
<Label>{t('sat.listLabel')}</Label>
|
||
{/* Raw text, one per line, parsed on change — not a row-per-entry
|
||
editor with add and delete buttons. The list is short, edited
|
||
twice a year, and usually arrives pasted from a satellite
|
||
tracker; a textarea takes that paste in one gesture. */}
|
||
<textarea
|
||
className="w-full h-56 rounded-md border border-input bg-background p-2 font-mono text-xs"
|
||
value={sats.join('\n')}
|
||
placeholder={'AO-7\nAO-91\nRS-44\nSO-50'}
|
||
onChange={(e) => {
|
||
const next = e.target.value.split('\n').map((v) => v.trim());
|
||
setLists((s) => ({ ...s, satellites: next }));
|
||
}}
|
||
onBlur={() => setLists((s) => ({
|
||
// Tidied when the field is LEFT, never while typing: dropping an
|
||
// empty line as it is typed makes the Enter key look broken.
|
||
...s,
|
||
satellites: Array.from(new Set((s.satellites ?? []).map((v) => v.trim().toUpperCase()).filter(Boolean))).sort(),
|
||
}))}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">{t('sat.listHint')}</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.modes')}
|
||
hint={t('mds.hint')}
|
||
/>
|
||
<div className="grid grid-cols-[1fr_auto_1.5fr] gap-3 max-w-4xl">
|
||
{/* Left: available catalog */}
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||
{t('bnd.available')}
|
||
</div>
|
||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||
{available.length === 0 ? (
|
||
<div className="px-3 py-2 text-xs text-muted-foreground italic">{t('mds.allSelected')}</div>
|
||
) : (
|
||
available.map((m) => (
|
||
<button
|
||
key={m.name}
|
||
type="button"
|
||
onDoubleClick={() => addModeFromCatalog(m)}
|
||
onClick={() => addModeFromCatalog(m)}
|
||
className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors flex items-center justify-between gap-2"
|
||
title={t('mds.defaultRstTitle', { sent: m.sent, rcvd: m.rcvd })}
|
||
>
|
||
<span>{m.name}</span>
|
||
<span className="text-[10px] text-muted-foreground">{m.sent}/{m.rcvd}</span>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
<div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
|
||
<Input
|
||
value={modeDraft}
|
||
onChange={(e) => setModeDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
addCustomMode(modeDraft);
|
||
setModeDraft('');
|
||
}
|
||
}}
|
||
placeholder={t('mds.customPh')}
|
||
className="font-mono uppercase h-7 text-xs"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7"
|
||
onClick={() => { addCustomMode(modeDraft); setModeDraft(''); }}
|
||
disabled={!modeDraft.trim()}
|
||
>
|
||
<Plus className="size-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Center: shuttle hint */}
|
||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||
<ArrowRight className="size-4" />
|
||
<ArrowLeft className="size-4" />
|
||
</div>
|
||
|
||
{/* Right: selected with editable RST */}
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<div className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||
<span className="w-12">{t('mds.order')}</span>
|
||
<span>{t('mds.mode')}</span>
|
||
<span>{t('mds.rstSnt')}</span>
|
||
<span>{t('mds.rstRcv')}</span>
|
||
<span className="w-6"></span>
|
||
</div>
|
||
<div className="max-h-[360px] overflow-y-auto divide-y divide-border">
|
||
{selected.length === 0 ? (
|
||
<div className="px-3 py-2 text-xs text-muted-foreground italic">
|
||
{t('mds.none')}
|
||
</div>
|
||
) : (
|
||
selected.map((m, i) => (
|
||
<div key={i} className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1 items-center">
|
||
<div className="flex gap-0.5 w-12">
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
|
||
<ArrowUp className="size-3" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === selected.length - 1}>
|
||
<ArrowDown className="size-3" />
|
||
</Button>
|
||
</div>
|
||
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
|
||
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
|
||
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
|
||
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
|
||
<Trash2 className="size-3" />
|
||
</Button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
<div className="px-3 py-1.5 border-t border-border bg-muted/40">
|
||
<Button variant="ghost" size="sm" onClick={addMode} className="h-6 text-xs">
|
||
<Plus className="size-3" /> {t('mds.addBlank')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* RST report lists — the dropdown choices in the entry form. */}
|
||
<div className="mt-6 max-w-4xl">
|
||
<div className="text-sm font-semibold mb-1">{t('mds.rstLists')}</div>
|
||
<div className="text-[11px] text-muted-foreground mb-2">
|
||
{t('mds.rstListsHint')}
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('mds.phone')}</Label>
|
||
<Textarea rows={8} className="font-mono text-xs" value={rstText.phone} onChange={(e) => setRstText((s) => ({ ...s, phone: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('mds.cw')}</Label>
|
||
<Textarea rows={8} className="font-mono text-xs" value={rstText.cw} onChange={(e) => setRstText((s) => ({ ...s, cw: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t('mds.digital')}</Label>
|
||
<Textarea rows={8} className="font-mono text-xs" value={rstText.digital} onChange={(e) => setRstText((s) => ({ ...s, digital: e.target.value }))} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function CATPanel() {
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.cat')}
|
||
hint={t('cat.hint')}
|
||
/>
|
||
<div className="space-y-4 max-w-3xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||
{t('cat.enable')}
|
||
</label>
|
||
|
||
{/* BRAND, then CONNECTION.
|
||
The backend list mixed the two: "Icom (USB)" and "Icom (network)"
|
||
were separate entries, while Kenwood and Elecraft hid the same
|
||
choice in a field further down. An operator picks a radio, then
|
||
says how it is plugged in — so that is what the panel asks, in
|
||
that order, and the two answers together choose the backend. */}
|
||
<div className="grid grid-cols-2 gap-3 items-start">
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.brand')}</Label>
|
||
<Select value={catBrand} onValueChange={(v) => applyCatBrand(v)}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{CAT_BRANDS.map((b) => <SelectItem key={b.id} value={b.id}>{b.label}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* Only where there IS a choice. A dropdown showing one entry that
|
||
cannot be changed is a control pretending to be a decision — a
|
||
Yaesu is reached over USB and that is the end of it. */}
|
||
{(CAT_BRANDS.find((b) => b.id === catBrand)?.links ?? []).length > 1 ? (
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.kwLink')}</Label>
|
||
<Select value={catLink} onValueChange={applyCatLink}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{(CAT_BRANDS.find((b) => b.id === catBrand)?.links ?? []).map((l) => (
|
||
<SelectItem key={l} value={l}>
|
||
{l === 'usb' ? t('cat.kwLinkUsb') : l === 'bridge' ? t('cat.kwLinkBridge') : t('cat.kwLinkNative')}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : <div />}
|
||
{catCfg.backend === 'omnirig' && (
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.omnirigRig')}</Label>
|
||
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="1">Rig 1</SelectItem>
|
||
<SelectItem value="2">Rig 2</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
{catCfg.backend === 'omnirig' && (
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.omnirigVfo')}</Label>
|
||
<Select value={catCfg.omnirig_vfo || 'auto'}
|
||
onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_vfo: v === 'auto' ? '' : v }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="auto">{t('cat.omnirigVfoAuto')}</SelectItem>
|
||
<SelectItem value="A">{t('cat.omnirigVfoA')}</SelectItem>
|
||
<SelectItem value="B">{t('cat.omnirigVfoB')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
|
||
</div>
|
||
)}
|
||
{catCfg.backend === 'flex' && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.flexIp')}</Label>
|
||
<Input placeholder="192.168.1.50" value={catCfg.flex_host ?? ''}
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_host: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.port')}</Label>
|
||
<PortInput value={catCfg.flex_port || 4992}
|
||
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
|
||
</div>
|
||
<div className="col-span-2">
|
||
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
||
</div>
|
||
{/* What OpsLog DOES with a Flex — panadapter spots, decode spots,
|
||
the DAX switch for voice messages — moved to Settings →
|
||
FlexRadio, with the per-band antennas and power. What stays
|
||
here is the link itself: an address and a port, which is what
|
||
this page is about for every other backend too. */}
|
||
</>
|
||
)}
|
||
{catCfg.backend === 'xiegu' && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.xieguPort')}</Label>
|
||
<div className="flex gap-2">
|
||
<Select value={catCfg.xiegu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_port: v }))}>
|
||
<SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button type="button" variant="outline" size="sm"
|
||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.baud')}</Label>
|
||
<Select value={String(catCfg.xiegu_baud || 19200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_baud: parseInt(v) || 19200 }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="text-xs text-muted-foreground">{t('cat.xieguBaudHint')}</span>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.civAddr')}</Label>
|
||
<Input value={'0x' + (catCfg.xiegu_addr || 0x70).toString(16).toUpperCase().padStart(2, '0')}
|
||
onChange={(e) => {
|
||
const n = parseInt(e.target.value.replace(/^0x/i, ''), 16);
|
||
if (!isNaN(n) && n > 0 && n <= 0xFF) setCatCfg((s) => ({ ...s, xiegu_addr: n }));
|
||
}} />
|
||
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.xieguPTTLine')}</Label>
|
||
<Select value={catCfg.xiegu_ptt_line || 'civ'}
|
||
onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_ptt_line: v === 'civ' ? '' : v }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="civ">{t('cat.xieguPTTCiv')}</SelectItem>
|
||
<SelectItem value="rts">RTS</SelectItem>
|
||
<SelectItem value="dtr">DTR</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="text-xs text-muted-foreground">{t('cat.xieguPTTHint')}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
{catCfg.backend === 'yaesu' && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.yaesuPort')}</Label>
|
||
<div className="flex gap-2">
|
||
<Select value={catCfg.yaesu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_port: v }))}>
|
||
<SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button type="button" variant="outline" size="sm"
|
||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||
</div>
|
||
<span className="text-xs text-muted-foreground">{t('cat.yaesuPortHint')}</span>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.baud')}</Label>
|
||
<Select value={String(catCfg.yaesu_baud || 38400)} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_baud: parseInt(v) || 38400 }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="text-xs text-muted-foreground">{t('cat.yaesuBaudHint')}</span>
|
||
</div>
|
||
<div className="col-span-2 space-y-1">
|
||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||
<Checkbox checked={!!catCfg.yaesu_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, yaesu_low_lines: !!c }))} />
|
||
{t('cat.lowerLines')}
|
||
</label>
|
||
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
{['icom', 'xiegu', 'kenwood', 'elecraft'].includes(catCfg.backend) && (
|
||
<div className="col-span-2 border-t border-border/60 pt-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
|
||
{t('cat.civTrace')}
|
||
</label> </div>
|
||
)}
|
||
{(catCfg.backend === 'kenwood' || catCfg.backend === 'elecraft') && (
|
||
<>
|
||
{/* USB or network, as a choice.
|
||
The two were offered side by side with nothing to say which one
|
||
the backend would use — it prefers the network address whenever
|
||
the field is not empty, which is invisible from here. */}
|
||
{(((catCfg as any).kenwood_link || 'usb') === 'usb') && (<>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.kenwoodPort')}</Label>
|
||
<div className="flex gap-2">
|
||
<Select value={catCfg.kenwood_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_port: v }))}>
|
||
<SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button type="button" variant="outline" size="sm"
|
||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||
</div> </div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.baud')}</Label>
|
||
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select> </div>
|
||
</>)}
|
||
{((catCfg as any).kenwood_link === 'bridge') && (
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.kenwoodHost')}</Label>
|
||
<Input
|
||
value={catCfg.kenwood_host || ''}
|
||
placeholder="192.168.1.50:4999"
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</p>
|
||
</div>
|
||
)}
|
||
<div className="col-span-2 space-y-1">
|
||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||
<Checkbox checked={!!catCfg.kenwood_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, kenwood_low_lines: !!c }))} />
|
||
{t('cat.lowerLines')}
|
||
</label>
|
||
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||
</div>
|
||
{catCfg.backend === 'kenwood' ? (
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.kwDataMode')}</Label>
|
||
<Select value={(catCfg as any).kenwood_data_mode || 'usb'} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_data_mode: v } as any))}>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="usb">{t('cat.kwDataUsb')}</SelectItem>
|
||
<SelectItem value="data">{t('cat.kwDataMd6')}</SelectItem>
|
||
<SelectItem value="keep">{t('cat.kwDataKeep')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">{t('cat.elecraftHint')}</span>
|
||
)}
|
||
</>
|
||
)}
|
||
{catCfg.backend === 'icom' && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.icomPort')}</Label>
|
||
<div className="flex gap-2">
|
||
<Select value={catCfg.icom_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_port: v }))}>
|
||
<SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button type="button" variant="outline" size="sm"
|
||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.baud')}</Label>
|
||
<Select value={String(catCfg.icom_baud || 115200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_baud: parseInt(v) || 115200 }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.icomModel')}</Label>
|
||
<Select
|
||
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
|
||
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
|
||
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.civAddr')}</Label>
|
||
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
||
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
|
||
</div>
|
||
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.civHint')}</p>
|
||
</>
|
||
)}
|
||
{catCfg.backend === 'icom-net' && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.icomNetHost')}</Label>
|
||
<Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''}
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.icomModel')}</Label>
|
||
<Select
|
||
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
|
||
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
|
||
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.civAddr')}</Label>
|
||
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
||
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.icomNetUser')}</Label>
|
||
<Input value={catCfg.icom_net_user ?? ''}
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_user: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.icomNetPass')}</Label>
|
||
<Input type="password" value={catCfg.icom_net_pass ?? ''}
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
|
||
</div>
|
||
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
|
||
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={!!(catCfg as any).icom_net_audio}
|
||
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, icom_net_audio: !!c } as any))} />
|
||
<span>
|
||
{t('cat.icomNetAudio')}
|
||
<span className="block text-[11px] text-muted-foreground">{t('cat.icomNetAudioHint')}</span>
|
||
</span>
|
||
</label>
|
||
</>
|
||
)}
|
||
{catCfg.backend === 'tci' && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.tciHost')}</Label>
|
||
<Input placeholder="127.0.0.1" value={catCfg.tci_host ?? ''}
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, tci_host: e.target.value }))} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.port')}</Label>
|
||
<PortInput value={catCfg.tci_port || 40001}
|
||
onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
|
||
</div>
|
||
<p className="col-span-2 text-xs text-muted-foreground">
|
||
{t('cat.tciHint')}
|
||
</p>
|
||
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={!!catCfg.tci_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, tci_spots: !!c }))} />
|
||
{t('cat.tciSpots')} <span className="text-xs text-muted-foreground">{t('cat.tciSpotsHint')}</span>
|
||
</label>
|
||
</>
|
||
)}
|
||
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom' || catCfg.backend === 'icom-net') && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.pollMs')}</Label>
|
||
<Input
|
||
type="number" min={50} max={2000} step={50}
|
||
value={catCfg.poll_ms}
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, poll_ms: parseInt(e.target.value) || 250 }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.delayMs')}</Label>
|
||
<Input
|
||
type="number" min={0} max={500} step={10}
|
||
value={catCfg.delay_ms}
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
{/* Transverter offset. OUTSIDE the per-backend blocks on purpose: a
|
||
transverter hangs off the IF of whatever radio you own, and the
|
||
offset is applied in the CAT manager, above every backend. Tucked
|
||
under the OmniRig/Icom section it would have been invisible to the
|
||
Yaesu, Kenwood, Xiegu, Flex and TCI operators who need it just as
|
||
much. */}
|
||
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox className="mt-0.5" checked={!!catCfg.offset_on}
|
||
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, offset_on: !!c }))} />
|
||
<span>{t('cat.offsetOn')} <span className="text-xs text-muted-foreground">{t('cat.offsetHint')}</span></span>
|
||
</label>
|
||
{catCfg.offset_on && (
|
||
<div className="col-span-2 flex items-center gap-2 pl-6">
|
||
<Label className="text-sm">{t('cat.offsetMhz')}</Label>
|
||
<Input
|
||
type="number" step="0.000001" className="w-40"
|
||
value={(catCfg.offset_hz ?? 0) / 1e6}
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, offset_hz: Math.round((parseFloat(e.target.value) || 0) * 1e6) }))}
|
||
/>
|
||
<span className="text-xs text-muted-foreground">{t('cat.offsetExample')}</span>
|
||
</div>
|
||
)}
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('cat.digitalDefault')}</Label>
|
||
<Select
|
||
value={catCfg.digital_default || 'FT8'}
|
||
onValueChange={(v) => setCatCfg((s) => ({ ...s, digital_default: v }))}
|
||
>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{['FT8','FT4','RTTY','PSK31','MFSK','JS8','JT65','JT9','OLIVIA','DIGITALVOICE','DATA'].map(m => (
|
||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
{/* CAT sharing. A native backend owns the rig's serial port, so without
|
||
this WSJT-X and friends are locked out of the radio entirely. */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={!!catCfg.share_enabled}
|
||
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, share_enabled: !!c }))}
|
||
/>
|
||
{t('cat.share')}
|
||
</label>
|
||
<p className="text-[11px] text-muted-foreground">{t('cat.shareHint')}</p>
|
||
{catCfg.share_enabled && (
|
||
<div className="flex flex-wrap items-end gap-4">
|
||
{/* One protocol or the other. They answer the same questions about
|
||
the same radio, and no program speaks both — so this is a
|
||
choice, not two switches. */}
|
||
<div className="space-y-1">
|
||
<Label>{t('cat.shareProto')}</Label>
|
||
<Select
|
||
value={(catCfg as any).share_proto === 'tci' ? 'tci' : 'rigctl'}
|
||
onValueChange={(v) => setCatCfg((s) => ({ ...s, share_proto: v } as any))}
|
||
>
|
||
<SelectTrigger className="h-8 w-[240px]"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="rigctl">{t('cat.shareRigctl')}</SelectItem>
|
||
<SelectItem value="tci">{t('cat.shareTci')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1 max-w-[200px]">
|
||
<Label>{t('cat.sharePort')}</Label>
|
||
{(catCfg as any).share_proto === 'tci' ? (
|
||
<PortInput
|
||
value={(catCfg as any).share_tci_port || 40001}
|
||
fallback={40001}
|
||
onChange={(n) => setCatCfg((s) => ({ ...s, share_tci_port: n } as any))}
|
||
/>
|
||
) : (
|
||
<PortInput
|
||
value={catCfg.share_port || 4532}
|
||
fallback={4532}
|
||
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{catCfg.share_enabled && (catCfg as any).share_proto === 'tci' && catCfg.backend === 'tci' && (
|
||
// Both ends TCI: ExpertSDR is almost certainly already holding
|
||
// 40001 on this machine, and our server would fail to bind. Worth
|
||
// saying here rather than leaving it in the log.
|
||
<p className="text-[11px] text-warning">{t('cat.shareTciClash')}</p>
|
||
)}
|
||
</div>
|
||
{/* PTT hotkey — a keyboard key that keys the rig while OpsLog is focused.
|
||
Uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT. */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={!!(catCfg as any).ptt_hotkey_enabled}
|
||
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_enabled: !!c } as any))}
|
||
/>
|
||
{t('cat.pttKey')}
|
||
</label>
|
||
{(catCfg as any).ptt_hotkey_enabled && (
|
||
<>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onKeyDown={(e) => {
|
||
if (!capturingPtt) return;
|
||
e.preventDefault();
|
||
if (e.code === 'Escape') { setCapturingPtt(false); return; }
|
||
setCatCfg((s) => ({ ...s, ptt_hotkey: e.code } as any));
|
||
setCapturingPtt(false);
|
||
}}
|
||
onClick={() => setCapturingPtt(true)}
|
||
className="min-w-[140px] rounded-md border border-border bg-muted/40 px-3 py-1.5 text-sm font-mono hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary"
|
||
>
|
||
{capturingPtt ? t('cat.pttKeyPress') : ((catCfg as any).ptt_hotkey || t('cat.pttKeyNone'))}
|
||
</button>
|
||
{(catCfg as any).ptt_hotkey && !capturingPtt && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setCatCfg((s) => ({ ...s, ptt_hotkey: '' } as any))}
|
||
className="text-xs text-muted-foreground hover:text-foreground"
|
||
>
|
||
{t('cat.pttKeyClear')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={!!(catCfg as any).ptt_hotkey_toggle}
|
||
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_toggle: !!c } as any))}
|
||
/>
|
||
{t('cat.pttKeyToggle')}
|
||
</label> </>
|
||
)}
|
||
</div>
|
||
{catCfg.backend === 'omnirig' && (
|
||
<>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={catModeBeforeFreq}
|
||
onCheckedChange={(c) => { const v = !!c; setCatModeBeforeFreq(v); writeUiPref('opslog.catModeBeforeFreq', v ? '1' : '0'); }}
|
||
/>
|
||
{t('cat.modeBeforeFreq')} <span className="text-xs text-muted-foreground">{t('cat.modeBeforeFreqHint')}</span>
|
||
</label>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t('cat.omnirigHint')}
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
async function testRotator(dev: RotatorDevice, sub = 1) {
|
||
setRotatorTesting(true);
|
||
setRotatorTest(null);
|
||
try {
|
||
await TestRotatorDevice(dev as any, sub);
|
||
// Say what the test ACTUALLY did, which is not the same on every backend.
|
||
//
|
||
// PstRotator and a Rotator Genius are sent a move to 0°; a SPID, an ARCO
|
||
// and a DCU-1 are asked their heading and nothing turns. They all used to
|
||
// report "Packet sent — the antenna should swing to north. If it didn't,
|
||
// check PstRotator's UDP listener" — which named a program that is not in
|
||
// the path, for a move that was never commanded. An operator on a tower
|
||
// read that as the test having failed.
|
||
const type = (dev as any).type;
|
||
const msg = type === 'rotgenius' ? t('rot.testOkRG')
|
||
: (type === 'spid' || type === 'arco' || type === 'dcu1') ? t('rot.testOkRead')
|
||
: t('cat.rotatorOk');
|
||
setRotatorTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setRotatorTesting(false);
|
||
}
|
||
}
|
||
|
||
async function testUltrabeam() {
|
||
setUbTesting(true);
|
||
setUbTest(null);
|
||
try {
|
||
await TestUltrabeam(ultrabeam as any);
|
||
setUbTest({ ok: true, msg: t('cat.ubOk') });
|
||
} catch (e: any) {
|
||
setUbTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setUbTesting(false);
|
||
}
|
||
}
|
||
|
||
function UltrabeamPanel() {
|
||
const isSteppir = ultrabeam.type === 'steppir';
|
||
// Serial is no longer a SteppIR-only route: an Ultrabeam controller has an
|
||
// RS232 port too, and a plain FTDI cable reaches it without the Ethernet
|
||
// adapter the TCP route needs.
|
||
const isSerial = ultrabeam.transport === 'serial';
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('hw.motorAntenna')}
|
||
/>
|
||
<div className="space-y-4 max-w-xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
||
{t('hw.motorEnable')}
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('hw.motorType')}</Label>
|
||
{/* Both antennas take either route now, so picking a type no longer
|
||
forces the transport. */}
|
||
<Select value={ultrabeam.type ?? 'ultrabeam'}
|
||
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v }))}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
|
||
<SelectItem value="steppir">SteppIR</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{(
|
||
<div className="space-y-1">
|
||
<Label>{t('hw.motorTransport')}</Label>
|
||
<Select value={ultrabeam.transport ?? 'tcp'}
|
||
onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
|
||
<SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isSerial ? (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('hw.motorCom')}{!isSteppir && <span className="ml-1.5 font-normal text-muted-foreground">{t('hw.motorComUb')}</span>}</Label>
|
||
{/* A list AND a text field, which is why this is a Combobox and
|
||
not the Select the other serial devices use: the port for a
|
||
USB adapter that is currently unplugged does not appear in
|
||
the list, and refusing to accept it typed means the antenna
|
||
cannot be configured until the adapter is in. The detected
|
||
ports are offered; anything else is still accepted. */}
|
||
<div className="flex items-center gap-1">
|
||
<Combobox
|
||
value={ultrabeam.com ?? ''}
|
||
options={wkPorts}
|
||
allowFreeText
|
||
commitOnType
|
||
showToggle
|
||
placeholder="COM3"
|
||
className="font-mono flex-1"
|
||
onChange={(v) => setUltrabeam((s) => ({ ...s, com: v.trim().toUpperCase() }))}
|
||
/>
|
||
<Button type="button" variant="outline" size="sm" className="shrink-0"
|
||
onClick={() => ListSerialPorts().then((ps) => setWkPorts((ps ?? []) as string[])).catch(() => {})}>↻</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('hw.motorBaud')}</Label>
|
||
{/* The Ultrabeam answers on 19200 and on nothing else, so there is
|
||
no choice to offer — an FTDI cable opens at any speed, and a
|
||
wrong one is indistinguishable from a dead controller. */}
|
||
{!isSteppir ? (
|
||
<Input className="h-9 font-mono" value="19200" readOnly disabled />
|
||
) : (
|
||
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(s2) => setUltrabeam((s) => ({ ...s, baud: parseInt(s2, 10) || 9600 }))}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{[1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>Host / IP</Label>
|
||
<Input
|
||
value={ultrabeam.host ?? ''}
|
||
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
|
||
placeholder="192.168.1.50"
|
||
className="font-mono"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>TCP port</Label>
|
||
<PortInput
|
||
value={ultrabeam.port}
|
||
onChange={(n) => setUltrabeam((s) => ({ ...s, port: n }))} fallback={23}
|
||
className="font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{isSteppir && (
|
||
<p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
|
||
)}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
||
{t('hw.motorFollow')}
|
||
</label>
|
||
{ultrabeam.follow && (
|
||
<div className="space-y-2 pl-6">
|
||
<div className="flex items-center gap-3">
|
||
<Label className="text-sm">{t('station.trackModeTip')}</Label>
|
||
<Select value={ultrabeam.track_mode || 'step'} onValueChange={(v) => setUltrabeam((s) => ({ ...s, track_mode: v }))}>
|
||
<SelectTrigger className="h-8 w-52"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="always">{t('station.trackAlways')}</SelectItem>
|
||
<SelectItem value="step">{t('station.trackStep')}</SelectItem>
|
||
<SelectItem value="band">{t('station.trackBand')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* The step is only a question in step mode — the other two modes
|
||
have nothing to threshold. */}
|
||
{(ultrabeam.track_mode || 'step') === 'step' && (
|
||
<div className="flex items-center gap-3">
|
||
<Label className="text-sm">{t('hw.motorStep')}</Label>
|
||
<Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
|
||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="25">25 kHz</SelectItem>
|
||
<SelectItem value="50">50 kHz</SelectItem>
|
||
<SelectItem value="100">100 kHz</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-muted-foreground">
|
||
{(ultrabeam.track_mode || 'step') === 'always' ? t('station.trackAlwaysTip')
|
||
: (ultrabeam.track_mode || 'step') === 'band' ? t('station.trackBandTip')
|
||
: t('station.trackStepTipMode')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{(isSteppir || ultrabeam.type === 'ultrabeam') && (
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<Label className="text-sm">{t('hw.motorBands')}</Label>
|
||
{/* Band, and under it the frequency its button tunes to — the
|
||
layout of the SteppIR controller's own Bands and Frequencies
|
||
table, which is where operators expect to find this. The box
|
||
only appears on a selected band: a tune frequency for a band the
|
||
antenna is not allowed on is a setting with no effect. Left
|
||
empty it shows the default as placeholder, so the field is
|
||
self-documenting and clearing it is how you go back. */}
|
||
<div className="flex items-start gap-1.5 flex-wrap">
|
||
{MOTOR_BANDS.map((b) => {
|
||
const on = ultrabeam.bands.includes(b);
|
||
return (
|
||
<div key={b} className="flex flex-col gap-1">
|
||
<button type="button"
|
||
onClick={() => setUltrabeam((s) => ({
|
||
...s,
|
||
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
|
||
}))}
|
||
className={`h-8 w-[4.5rem] rounded-md border px-2 text-sm font-medium transition-colors ${
|
||
on
|
||
? 'border-primary bg-primary/15 text-primary'
|
||
: 'border-input bg-background text-muted-foreground hover:bg-muted'
|
||
}`}>
|
||
{b}
|
||
</button>
|
||
{on && (
|
||
<input
|
||
type="text" inputMode="numeric"
|
||
value={ultrabeam.band_freqs?.[b] ? String(ultrabeam.band_freqs[b]) : ''}
|
||
placeholder={String(MOTOR_BAND_DEFAULT_KHZ[b] ?? '')}
|
||
title={t('hw.motorBandFreqHint')}
|
||
onChange={(e) => {
|
||
// Keep only digits, and store nothing for an empty box
|
||
// so it round-trips to "use the default" rather than
|
||
// to a zero the backend would have to interpret.
|
||
const digits = e.target.value.replace(/[^0-9]/g, '');
|
||
setUltrabeam((s) => {
|
||
const next = { ...(s.band_freqs || {}) };
|
||
if (digits === '') delete next[b]; else next[b] = parseInt(digits, 10);
|
||
return { ...s, band_freqs: next };
|
||
});
|
||
}}
|
||
className="h-7 w-[4.5rem] rounded-md border border-input bg-background px-1.5 text-center text-xs font-mono outline-none focus:border-primary"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">{t('hw.motorBandFreqHint')}</p>
|
||
</div>
|
||
)}
|
||
<div className="border-t border-border/60 pt-3 space-y-1">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={ultrabeam.tx_inhibit} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, tx_inhibit: !!c }))} />
|
||
{t('hw.motorTxInhibit')}
|
||
</label>
|
||
<p className="text-xs text-muted-foreground pl-6">{t('hw.motorTxInhibitHint')}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 pt-2">
|
||
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
|
||
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
||
</Button>
|
||
</div>
|
||
{ubTest && (
|
||
<div className={cn(
|
||
'text-xs rounded-md p-2.5 border',
|
||
ubTest.ok
|
||
? 'bg-success-muted text-success-muted-foreground border-success-border'
|
||
: 'bg-destructive/10 text-destructive border-destructive/30',
|
||
)}>
|
||
{ubTest.msg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AntGeniusPanelSettings() {
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="Antenna Genius (4O3A)"
|
||
hint={t('ag2.hint')}
|
||
/>
|
||
<div className="space-y-4 max-w-xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={antgenius.enabled} onCheckedChange={(c) => setAntgenius((s) => ({ ...s, enabled: !!c }))} />
|
||
Enable Antenna Genius control
|
||
</label>
|
||
<div className="space-y-1">
|
||
<Label>Host / IP</Label>
|
||
<Input
|
||
value={antgenius.host ?? ''}
|
||
onChange={(e) => setAntgenius((s) => ({ ...s, host: e.target.value }))}
|
||
placeholder="192.168.1.60"
|
||
className="font-mono"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('ag2.password')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={antgenius.password ?? ''}
|
||
onChange={(e) => setAntgenius((s) => ({ ...s, password: e.target.value }))}
|
||
placeholder={t('ag2.passwordPh')}
|
||
className="font-mono"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">{t('ag2.passwordHint')}</p>
|
||
</div>
|
||
|
||
{/* MY_ANTENNA from the switch.
|
||
The working conditions hold what was PLANNED for the band; the
|
||
switch knows what is connected. Off by default: a station that
|
||
names its antennas differently in the two places would otherwise
|
||
find its log quietly rewritten. */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox className="mt-0.5" checked={!!antgenius.use_for_my_antenna}
|
||
onCheckedChange={(c) => setAntgenius((s: any) => ({ ...s, use_for_my_antenna: !!c }))} />
|
||
<span>
|
||
{t('ag2.useForMyAnt')}
|
||
<span className="block text-xs text-muted-foreground">{t('ag2.useForMyAntHint')}</span>
|
||
</span>
|
||
</label>
|
||
{!!antgenius.use_for_my_antenna && (
|
||
<div className="pl-6 space-y-1">
|
||
<Label>{t('ag2.ant1Port')}</Label>
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||
{[1, 2].map((n) => (
|
||
<button key={n} type="button"
|
||
onClick={() => setAntgenius((s: any) => ({ ...s, ant1_port: n }))}
|
||
className={cn('px-3 py-1.5 font-medium',
|
||
(antgenius.ant1_port ?? 1) === n ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||
{n === 1 ? t('ag2.portA') : t('ag2.portB')}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">{t('ag2.ant1PortHint')}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function TunerGeniusPanelSettings() {
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title="Tuner Genius XL (4O3A)"
|
||
hint={t('tg2.hint')}
|
||
/>
|
||
<div className="space-y-4 max-w-xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={tunergenius.enabled} onCheckedChange={(c) => setTunergenius((s) => ({ ...s, enabled: !!c }))} />
|
||
{t('tg2.enable')}
|
||
</label>
|
||
<div className="space-y-1">
|
||
<Label>Host / IP</Label>
|
||
<Input
|
||
value={tunergenius.host ?? ''}
|
||
onChange={(e) => setTunergenius((s) => ({ ...s, host: e.target.value }))}
|
||
placeholder="192.168.1.61"
|
||
className="font-mono"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">{t('tg2.portHint')}</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('tg2.password')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={tunergenius.password ?? ''}
|
||
onChange={(e) => setTunergenius((s) => ({ ...s, password: e.target.value }))}
|
||
placeholder={t('tg2.passwordPh')}
|
||
className="font-mono"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">{t('tg2.passwordHint')}</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Bench power supply over Modbus RTU. OpsLog reads everything and writes one
|
||
// register — the output on/off. The voltage and current SET points are shown
|
||
// because they are worth seeing, and are not editable here: they belong to the
|
||
// supply's front panel, and a logbook that can set them can set them wrong.
|
||
// The COM ports come from the list SettingsModal already loads for the
|
||
// Winkeyer panel — one machine, one set of serial ports, fetched once. A hook
|
||
// of its own would be legal now (see PanelHost) and would fetch them twice.
|
||
function PSUPanelSettings() {
|
||
const ports = wkPorts;
|
||
const setPorts = setWkPorts;
|
||
return (
|
||
<>
|
||
<SectionHeader title={t('psu.title')} hint={t('psu.hint')} />
|
||
<div className="space-y-4 max-w-xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={psuCfg.enabled} onCheckedChange={(c) => setPsuCfg((s) => ({ ...s, enabled: !!c }))} />
|
||
{t('psu.enable')}
|
||
</label>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('psu.port')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={psuCfg.com_port || '_'} onValueChange={(v) => setPsuCfg((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
|
||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||
<SelectContent>
|
||
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
|
||
↻
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('psu.baud')}</Label>
|
||
<Input className="font-mono" value={String(psuCfg.baud ?? 9600)}
|
||
onChange={(e) => setPsuCfg((s) => ({ ...s, baud: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('psu.address')}</Label>
|
||
<Input className="font-mono" value={String(psuCfg.address ?? 1)}
|
||
onChange={(e) => setPsuCfg((s) => ({ ...s, address: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">{t('psu.wireHint')}</p>
|
||
<p className="text-xs text-warning">{t('psu.writeScope')}</p>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function PGXLPanelSettings() {
|
||
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
||
// presents it as brand + model.
|
||
const brandModels: Record<string, { value: string; label: string }[]> = {
|
||
spe: [
|
||
{ value: 'spe13', label: 'Expert 1.3K-FA' },
|
||
{ value: 'spe15', label: 'Expert 1.5K-FA' },
|
||
{ value: 'spe2k', label: 'Expert 2K-FA' },
|
||
],
|
||
acom: [
|
||
{ value: 'acom500', label: '500S' },
|
||
{ value: 'acom600', label: '600S' },
|
||
{ value: 'acom700', label: '700S' },
|
||
{ value: 'acom1200', label: '1200S' },
|
||
{ value: 'acom2020', label: '2020S' },
|
||
],
|
||
};
|
||
const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl' : ty.startsWith('acom') ? 'acom' : 'spe';
|
||
const patchAmp = (i: number, patch: Partial<AmpUI>) => setAmps((l) => l.map((a, j) => (j === i ? { ...a, ...patch } : a)));
|
||
// Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
|
||
// 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
|
||
const applyType = (i: number, v: string) => patchAmp(i, {
|
||
type: v,
|
||
transport: v === 'pgxl' ? 'tcp' : amps[i].transport,
|
||
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : amps[i].baud,
|
||
});
|
||
const addAmp = () => setAmps((l) => [...l, {
|
||
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
|
||
freq_out: false, freq_com_port: '', freq_baud: 9600, freq_broadcast_ms: 0,
|
||
}]);
|
||
return (
|
||
<>
|
||
<SectionHeader title="Amplifier" hint={t('amp.hint')} />
|
||
<div className="space-y-4 max-w-xl">
|
||
{amps.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">{t('amp.none')}</p>
|
||
)}
|
||
{amps.map((amp, i) => {
|
||
const brand = brandOf(amp.type);
|
||
const isPGXL = brand === 'pgxl';
|
||
const isACOM = brand === 'acom';
|
||
const isSerial = !isPGXL && amp.transport === 'serial';
|
||
return (
|
||
<div key={amp.id || `new-${i}`} className="rounded-lg border border-border p-3 space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox checked={amp.enabled} onCheckedChange={(c) => patchAmp(i, { enabled: !!c })} />
|
||
<Input className="h-8 flex-1" value={amp.name}
|
||
placeholder={t('amp.namePh')}
|
||
onChange={(e) => patchAmp(i, { name: e.target.value })} />
|
||
<Button variant="ghost" size="sm" className="h-8 text-danger" title={t('amp.remove')}
|
||
onClick={() => setAmps((l) => l.filter((_, j) => j !== i))}>
|
||
<Trash2 className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)"
|
||
was truncated with 3 equal columns. */}
|
||
<div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
|
||
<div className="space-y-1">
|
||
<Label>Brand</Label>
|
||
<Select value={brand} onValueChange={(b) => {
|
||
if (b === brand) return;
|
||
applyType(i, b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
|
||
}}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="pgxl">4O3A</SelectItem>
|
||
<SelectItem value="spe">SPE</SelectItem>
|
||
<SelectItem value="acom">ACOM</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Model</Label>
|
||
{isPGXL ? (
|
||
// The PowerGenius is the brand's single model — fixed, no choice.
|
||
<Select value="pgxl" disabled>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="pgxl">PowerGenius XL</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<Select value={amp.type} onValueChange={(v) => applyType(i, v)}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
</div>
|
||
{/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial
|
||
or an RS232-to-Ethernet bridge, so they offer both. */}
|
||
{!isPGXL && (
|
||
<div className="space-y-1">
|
||
<Label>Connection</Label>
|
||
<Select value={amp.transport} onValueChange={(v) => patchAmp(i, { transport: v })}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isSerial ? (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>COM port</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={amp.com_port || '_'} onValueChange={(v) => patchAmp(i, { com_port: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||
<ArrowDown className="size-3.5 rotate-90" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Baud</Label>
|
||
<Input type="number" min={1200} value={amp.baud}
|
||
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>Host / IP</Label>
|
||
<Input value={amp.host ?? ''} onChange={(e) => patchAmp(i, { host: e.target.value })}
|
||
placeholder="192.168.1.70" className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>TCP port</Label>
|
||
<PortInput value={amp.port}
|
||
onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* PowerGenius XL asks for a remote-access code when reached from
|
||
outside the LAN (banner "V… AUTH"), like the Tuner/Antenna Genius.
|
||
Blank on the LAN. */}
|
||
{isPGXL && (
|
||
<div className="space-y-1 max-w-xs">
|
||
<Label>{t('amp.password')}</Label>
|
||
<Input type="password" value={(amp as any).password ?? ''}
|
||
onChange={(e) => patchAmp(i, { password: e.target.value } as any)}
|
||
placeholder={t('amp.passwordPh')} className="font-mono" />
|
||
<p className="text-[11px] text-muted-foreground">{t('amp.passwordHint')}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Band-follow, for any amp that takes its band from a transceiver
|
||
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
||
driven over its network protocol. A SECOND serial port,
|
||
separate from the metering one above. */}
|
||
{!isPGXL && (
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={!!amp.freq_out}
|
||
onCheckedChange={(c) => patchAmp(i, { freq_out: !!c })}
|
||
/>
|
||
{t('amp.freqOut')}
|
||
</label>
|
||
{amp.freq_out && (
|
||
<>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('amp.freqPort')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={amp.freq_com_port || '_'} onValueChange={(v) => patchAmp(i, { freq_com_port: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||
<ArrowDown className="size-3.5 rotate-90" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Baud</Label>
|
||
<Input type="number" min={1200} value={amp.freq_baud ?? 9600}
|
||
onChange={(e) => patchAmp(i, { freq_baud: parseInt(e.target.value) || 9600 })} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('amp.freqBroadcast')}</Label>
|
||
<Select value={String(amp.freq_broadcast_ms ?? 0)} onValueChange={(v) => patchAmp(i, { freq_broadcast_ms: parseInt(v) })}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="0">{t('amp.freqPollOnly')}</SelectItem>
|
||
<SelectItem value="500">{t('amp.freqEvery', { ms: 500 })}</SelectItem>
|
||
<SelectItem value="1000">{t('amp.freqEvery', { ms: 1000 })}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground">{t('amp.freqHint')}</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
|
||
{!isPGXL && !isACOM && (
|
||
<p className="text-[10px] text-muted-foreground">
|
||
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
|
||
</p>
|
||
)}
|
||
{isACOM && (
|
||
<p className="text-[10px] text-muted-foreground">
|
||
ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
<Button variant="outline" size="sm" onClick={addAmp}>
|
||
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
|
||
</Button>
|
||
|
||
{/* WHICH amplifiers are coupled, not whether coupling is on.
|
||
A station can run a combiner pair AND a third amplifier that has
|
||
nothing to do with it — two SPE on the combiner, a PowerGenius on
|
||
another antenna — so a single switch would send that third one into
|
||
OPERATE alongside them. Shown from two amplifiers up. */}
|
||
{amps.length > 1 && (
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<div className="text-sm">{t('amp.linked')}</div>
|
||
<p className="text-xs text-muted-foreground">{t('amp.linkedHint')}</p>
|
||
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||
{amps.filter((x) => (x.id ?? '') !== '').map((x) => {
|
||
const on = linkedAmps.includes(x.id!);
|
||
return (
|
||
<label key={x.id} className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||
<Checkbox checked={on} onCheckedChange={(c) => {
|
||
const next = c ? [...linkedAmps, x.id!] : linkedAmps.filter((v) => v !== x.id);
|
||
setLinkedAmps(next);
|
||
SetLinkedAmps(next).catch(() => {});
|
||
}} />
|
||
{x.name?.trim() || x.type}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
{/* An amplifier saved without an id cannot be a member — say so
|
||
rather than silently leaving it out of the list. */}
|
||
{amps.some((x) => (x.id ?? '') === '') && (
|
||
<p className="text-xs text-muted-foreground">{t('amp.linkedSaveFirst')}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function RotatorPanel() {
|
||
// Rotors are a list, like amplifiers: add with +, remove with the trash icon.
|
||
// A Rotator Genius with "two rotors" contributes 2 addressable rotors.
|
||
const patch = (i: number, p: Partial<RotatorDevice>) => setRotors((l) => l.map((d, j) => (j === i ? { ...d, ...p } : d)) as any);
|
||
const addRotor = () => setRotors((l) => [...l, {
|
||
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
|
||
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
|
||
transport: 'tcp', com_port: '', baud: 9600,
|
||
} as any]);
|
||
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
|
||
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
|
||
// The motorized-antenna choice only matters once more than one rotor exists.
|
||
const multi = rotors.length > 1 || rotors.some((d) => (d as any).type === 'rotgenius' && (d as any).dual);
|
||
return (
|
||
<>
|
||
<SectionHeader title="Rotator" hint={anyPst ? t('rot.hint') : undefined} />
|
||
<div className="space-y-4 max-w-xl">
|
||
{rotors.length === 0 && <p className="text-sm text-muted-foreground">{t('rot.none')}</p>}
|
||
{rotors.map((d, i) => {
|
||
const dev = d as any;
|
||
const isRG = dev.type === 'rotgenius';
|
||
const isARCO = dev.type === 'arco';
|
||
const isDCU1 = dev.type === 'dcu1';
|
||
// A SPID has a COM port and nothing else — no network transport to
|
||
// offer, which is the whole point of driving it without PstRotator.
|
||
const isSPID = dev.type === 'spid';
|
||
const isSerialCap = isARCO || isDCU1 || isSPID; // COM-port or serial-over-IP controllers
|
||
const transport = dev.transport ?? 'tcp';
|
||
return (
|
||
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<Compass className="size-4 text-primary shrink-0" />
|
||
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
|
||
onChange={(e) => patch(i, { name: e.target.value })} />
|
||
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
|
||
onClick={() => removeRotor(i)} title={t('rot.remove')}>
|
||
<Trash2 className="size-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('rot.type')}</Label>
|
||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||
<Select value={dev.type ?? 'pst'}
|
||
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : (v === 'arco' || v === 'dcu1') ? 4001 : 12000, ...(v === 'dcu1' || v === 'spid' ? { transport: 'serial' } : {}), ...(v === 'spid' ? { baud: 600, spid_model: 'rot2prog' } : {}) })}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
||
<SelectItem value="dcu1">Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)</SelectItem>
|
||
<SelectItem value="spid">SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{isRG && !dev.dual && (
|
||
<div className="space-y-1">
|
||
<Label>{t('rot.rotatorNum')}</Label>
|
||
<Select value={String(dev.rotator_num ?? 1)}
|
||
onValueChange={(v) => patch(i, { rotator_num: parseInt(v, 10) || 1 })}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="1">1</SelectItem>
|
||
<SelectItem value="2">2</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
{/* SPID: pick the dialect. They differ in reply length AND baud
|
||
rate, so this cannot be detected — a wrong choice is a
|
||
controller that never answers. */}
|
||
{isSPID && (
|
||
<div className="space-y-1">
|
||
<Label>{t('rot.spidModel')}</Label>
|
||
<Select value={dev.spid_model || 'rot2prog'}
|
||
onValueChange={(v) => patch(i, { spid_model: v as any, baud: v === 'rot1prog' ? 1200 : 600 })}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="rot2prog">Rot2Prog (RAS, BIG-RAS/HR, MD-01, MD-02)</SelectItem>
|
||
<SelectItem value="rot1prog">Rot1Prog (azimuth only)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
{/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */}
|
||
{isSerialCap && !isSPID && (
|
||
<div className="space-y-1">
|
||
<Label>Connection</Label>
|
||
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Rotator Genius drives both its ports — one entry = two rotors. */}
|
||
{isRG && (
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={!!dev.dual} onCheckedChange={(c) => patch(i, { dual: !!c })} />
|
||
{t('rot.rgDual')}
|
||
</label>
|
||
)}
|
||
{isSerialCap && transport === 'serial' ? (
|
||
<div className="space-y-1 max-w-xs">
|
||
<Label>COM port</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={dev.com_port || '_'} onValueChange={(v) => patch(i, { com_port: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||
<ArrowDown className="size-3.5 rotate-90" />
|
||
</Button>
|
||
{/* A SPID runs at 600 or 1200 baud — not a typo, a pulse
|
||
controller has nothing to say quickly. Offering only the
|
||
usual rates would have left it permanently mute. */}
|
||
<Select value={String(dev.baud || (isSPID ? 600 : 9600))} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{(isSPID ? [600, 1200, 2400, 4800, 9600] : [4800, 9600, 19200, 38400, 57600]).map((b) => (
|
||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>Host / IP</Label>
|
||
<Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })}
|
||
placeholder={isRG || isSerialCap ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{isRG || isSerialCap ? 'TCP port' : 'UDP port'}</Label>
|
||
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
||
fallback={isRG ? 9006 : isSerialCap ? 4001 : 12000} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!isRG && !isSerialCap && (
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
||
This rotator supports elevation (VHF / satellite)
|
||
</label>
|
||
)}
|
||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||
{isDCU1 && <p className="text-xs text-muted-foreground">{t('rot.dcu1Hint')}</p>}
|
||
{isSPID && <p className="text-xs text-muted-foreground">{t('rot.spidHint')}</p>}
|
||
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
||
{multi && (
|
||
<div className="space-y-1 max-w-xs">
|
||
<Label>{isRG && dev.dual ? t('rot.antenna1') : t('rot.antenna')}</Label>
|
||
<Select value={dev.motorized ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized: v === 'motor' })}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
|
||
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
{/* Second logical rotor of a dual Rotator Genius: its own name + antenna. */}
|
||
{isRG && dev.dual && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('rot.name2')}</Label>
|
||
<Input value={dev.name2 ?? ''} placeholder={`${dev.name || 'Rotor'} 2`}
|
||
onChange={(e) => patch(i, { name2: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('rot.antenna2')}</Label>
|
||
<Select value={dev.motorized2 ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized2: v === 'motor' })}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
|
||
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => testRotator(d, 1)} disabled={rotatorTesting}>
|
||
{rotatorTesting ? t('hw.sending') : (isRG && dev.dual ? t('rot.testRotor1') : t('hw.testRotator'))}
|
||
</Button>
|
||
{isRG && dev.dual && (
|
||
<Button variant="outline" size="sm" onClick={() => testRotator(d, 2)} disabled={rotatorTesting}>
|
||
{t('rot.testRotor2')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
<Button variant="outline" size="sm" onClick={addRotor}>
|
||
<Plus className="size-3.5 mr-1" /> {t('rot.add')}
|
||
</Button>
|
||
{rotors.length > 0 && (
|
||
<div className="flex items-center gap-2 pt-2">
|
||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))}>
|
||
Stop
|
||
</Button>
|
||
{anyPst && (
|
||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))}>
|
||
Park
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* How much of the rotor widget to draw. Compact keeps the dial and the
|
||
SP/LP azimuths and drops the controls — for an operator who turns
|
||
the antenna from the bearing pill or from PstRotator and wants the
|
||
dial to take a column, not a panel. */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<div className="text-sm font-semibold">{t('rot.widget')}</div>
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<Checkbox checked={rotorCompact}
|
||
onCheckedChange={(c) => { const v = !!c; setRotorCompact(v); writeUiPref('opslog.rotorCompact', v ? '1' : '0'); }} />
|
||
{t('rot.compact')}
|
||
</label>
|
||
<p className="text-xs text-muted-foreground">{t('rot.compactHint')}</p>
|
||
</div>
|
||
|
||
{/* Quick-turn buttons for the rotor widget. Their azimuths start out
|
||
computed from the station square, so they are right for THIS QTH
|
||
rather than copied from someone else's. Hidden in compact mode —
|
||
editing buttons that are not drawn is a trap. */}
|
||
<div className={cn('border-t border-border/60 pt-3 space-y-2', rotorCompact && 'opacity-40')}>
|
||
<div className="text-sm font-semibold">{t('rot.presets')}</div>
|
||
<p className="text-xs text-muted-foreground">{t('rot.presetsHint')}</p>
|
||
<div className="space-y-1.5">
|
||
{rotorPresets.map((p, i) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<Input className="w-24" maxLength={6} value={p.label}
|
||
placeholder={t('rot.presetName')}
|
||
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} />
|
||
<Input className="w-24" type="number" min={0} max={359} value={p.azimuth}
|
||
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, azimuth: Math.max(0, Math.min(359, parseInt(e.target.value, 10) || 0)) } : x)))} />
|
||
<span className="text-xs text-muted-foreground">°</span>
|
||
<Button variant="ghost" size="sm" title={t('rot.remove')}
|
||
onClick={() => setRotorPresets((list) => list.filter((_, j) => j !== i))}>
|
||
<Trash2 className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{rotorPresets.length < 8 && (
|
||
<Button variant="outline" size="sm" onClick={() => setRotorPresets((l) => [...l, { label: '', azimuth: 0 }])}>
|
||
<Plus className="size-3.5 mr-1" /> {t('rot.presetAdd')}
|
||
</Button>
|
||
)}
|
||
<Button variant="outline" size="sm"
|
||
onClick={() => { ResetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch((e) => setErr(String(e?.message ?? e))); }}>
|
||
{t('rot.presetsReset')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{rotatorTest && (
|
||
<div className={cn(
|
||
'text-xs rounded-md p-2.5 border',
|
||
rotatorTest.ok
|
||
? 'bg-success-muted text-success-muted-foreground border-success-border'
|
||
: 'bg-destructive/10 text-destructive border-destructive/30',
|
||
)}>
|
||
{rotatorTest.msg}
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-muted-foreground">
|
||
From the main entry strip, click the bearing pill to rotate the active rotor to the short-path
|
||
azimuth. Shift+click for long-path, Ctrl+click to stop. With several rotors, pick the active one
|
||
on the compass widget.
|
||
</p>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function WinkeyerPanel() {
|
||
const setMacro = (i: number, patch: Partial<WKMac>) => setWk((s) => {
|
||
const macros = [...s.macros];
|
||
while (macros.length <= i) macros.push({ label: '', text: '' });
|
||
macros[i] = { ...macros[i], ...patch };
|
||
return { ...s, macros };
|
||
});
|
||
const num = (v: string, d: number) => { const n = parseInt(v, 10); return isNaN(n) ? d : n; };
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.winkeyer')}
|
||
/>
|
||
<div className="space-y-4 max-w-2xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
|
||
{t('wk.enable')}
|
||
</label>
|
||
|
||
<div className="grid grid-cols-4 gap-3 items-end">
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.engine')}</Label>
|
||
{/* Picking the serial engine defaults PTT-keying ON: without RTS held,
|
||
the rig drops to RX between words (semi-break-in). */}
|
||
<Select value={wk.engine} onValueChange={(v) => setWkField(v === 'serial' ? { engine: v, use_ptt: true } : { engine: v })}>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="winkeyer">{t('wk.engWinkeyer')}</SelectItem>
|
||
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
||
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
||
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
||
<SelectItem value="kenwood">{t('wk.engKenwood')}</SelectItem>
|
||
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
||
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
||
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
||
{t('wk.escClears')}
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
||
<Checkbox checked={wk.esm} onCheckedChange={(c) => setWkField({ esm: !!c })} />
|
||
{t('wk.esm')}
|
||
</label>
|
||
</div>
|
||
|
||
{wk.engine === 'icom' ? (
|
||
<>
|
||
{(!catCfg.enabled || catCfg.backend !== 'icom') && (
|
||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||
<span aria-hidden>⚠</span>
|
||
<span>{t('wk.catWarnIcom', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||
</p>
|
||
)}
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.speed')}</Label>
|
||
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : wk.engine === 'yaesu' ? (
|
||
<>
|
||
{(!catCfg.enabled || catCfg.backend !== 'yaesu') && (
|
||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||
<span aria-hidden>⚠</span>
|
||
<span>{t('wk.catWarnYaesu', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||
</p>
|
||
)}
|
||
<p className="text-xs text-muted-foreground -mt-1">{t('wk.yaesuHint')}</p>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.speed')}</Label>
|
||
<Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : wk.engine === 'kenwood' ? (
|
||
<>
|
||
{(!catCfg.enabled || catCfg.backend !== 'kenwood') && (
|
||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||
<span aria-hidden>⚠</span>
|
||
<span>{t('wk.catWarnKenwood', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||
</p>
|
||
)}
|
||
<p className="text-xs text-muted-foreground -mt-1">{t('wk.kenwoodHint')}</p>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.speed')}</Label>
|
||
<Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : wk.engine === 'flex' ? (
|
||
<>
|
||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||
<span aria-hidden>⚠</span>
|
||
<span>{t('wk.catWarnFlex', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||
</p>
|
||
)}
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.speed')}</Label>
|
||
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : wk.engine === 'serial' ? (
|
||
<>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('wk.keyingPort')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('wk.comPort')} /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('wk.noPorts')}</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="ghost" size="sm" className="h-8 px-2" title={t('wk.reloadPorts')}
|
||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||
<ArrowDown className="size-3.5 rotate-90" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.cwKeyLine')}</Label>
|
||
<Select value={wk.cw_key_line === 'rts' ? 'rts' : 'dtr'} onValueChange={(v) => setWkField({ cw_key_line: v })}>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="dtr">{t('wk.lineDtr')}</SelectItem>
|
||
<SelectItem value="rts">{t('wk.lineRts')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.speed')}</Label>
|
||
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.farnsworth')}</Label>
|
||
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.leadIn')}</Label>
|
||
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.tail')}</Label>
|
||
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer pb-1.5 self-end">
|
||
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> {t('wk.keyPtt')}
|
||
</label>
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.cw_invert} onCheckedChange={(c) => setWkField({ cw_invert: !!c })} />
|
||
{t('wk.invert')}
|
||
</label>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('wk.serialPort')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('wk.comPort')} /></SelectTrigger>
|
||
<SelectContent>
|
||
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('wk.noPorts')}</SelectItem>}
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="ghost" size="sm" className="h-8 px-2" title={t('wk.reloadPorts')}
|
||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||
<ArrowDown className="size-3.5 rotate-90" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.baud')}</Label>
|
||
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.speed')}</Label>
|
||
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.weight')}</Label>
|
||
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.leadIn')}</Label>
|
||
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.tail')}</Label>
|
||
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.ratio')}</Label>
|
||
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.farnsworth')}</Label>
|
||
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('wk.sidetone')}</Label>
|
||
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
|
||
</div>
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>{t('wk.paddleMode')}</Label>
|
||
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="iambic_b">Iambic B</SelectItem>
|
||
<SelectItem value="iambic_a">Iambic A</SelectItem>
|
||
<SelectItem value="ultimatic">Ultimatic</SelectItem>
|
||
<SelectItem value="bug">Bug</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> {t('wk.swapPaddles')}
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> {t('wk.autospace')}
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> {t('wk.keyPttShort')}
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
|
||
</label>
|
||
</div>
|
||
|
||
{/* Protocol trace — for reporting a keyer that misbehaves. Session
|
||
only, deliberately not persisted: it is a diagnostic, and a log
|
||
full of hex bytes helps nobody who forgot it was on. */}
|
||
<div className="border-t border-border/60 pt-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={wkTrace} onCheckedChange={(c) => { setWkTrace(!!c); SetWinkeyerTrace(!!c); }} />
|
||
{t('wk.trace')}
|
||
</label>
|
||
<p className="text-[10px] text-muted-foreground mt-1">{t('wk.traceHint')}</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Macro editor */}
|
||
<div className="border-t border-border/60 pt-3">
|
||
<Label className="text-sm font-medium">{t('wk.macroTitle')}</Label>
|
||
<p className="text-[11px] text-muted-foreground mb-2">
|
||
{t('wk.macroVars')} <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> {t('wk.macroCut')} <span className="font-mono">*</span>={t('wk.macroMyCall')}, <span className="font-mono">!</span>={t('wk.macroHisCall')}. <span className="font-mono"><LOGQSO></span> = {t('wk.macroLog')} (<span className="font-mono">73 TU <LOGQSO></span>).
|
||
</p>
|
||
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
||
{Array.from({ length: 12 }).map((_, i) => {
|
||
const m = wk.macros[i] ?? { label: '', text: '' };
|
||
return (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
|
||
<Input className="h-8 w-28 shrink-0 text-xs" placeholder={t('wk.label')} value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
|
||
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function statusForServer(id: number): ClusterServerStatus | undefined {
|
||
return clusterStatuses.find((s) => (s.server_id as number) === id);
|
||
}
|
||
|
||
async function clusterToggleEnabled(srv: ClusterServer, on: boolean) {
|
||
try {
|
||
await SaveClusterServer({ ...srv, enabled: on } as any);
|
||
await reloadClusterServers();
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function clusterDeleteServer(srv: ClusterServer) {
|
||
if (!confirm(`Delete cluster "${srv.name}"? Active session will be closed.`)) return;
|
||
try { await DeleteClusterServer(srv.id as number); await reloadClusterServers(); }
|
||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function clusterMove(srv: ClusterServer, dir: -1 | 1) {
|
||
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||
const idx = sorted.findIndex((s) => s.id === srv.id);
|
||
const j = idx + dir;
|
||
if (idx < 0 || j < 0 || j >= sorted.length) return;
|
||
const a = sorted[idx], b = sorted[j];
|
||
try {
|
||
await SaveClusterServer({ ...a, sort_order: b.sort_order } as any);
|
||
await SaveClusterServer({ ...b, sort_order: a.sort_order } as any);
|
||
await reloadClusterServers();
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
function clusterAddNew() {
|
||
const next: ClusterServer = {
|
||
id: 0, name: '', host: '', port: 7300,
|
||
login_override: '', password: '', init_commands: '',
|
||
enabled: true, sort_order: clusterServers.length,
|
||
};
|
||
setEditingServer(next);
|
||
}
|
||
|
||
function ClusterPanel() {
|
||
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||
// Written on every keystroke. This panel has no Save button, and a pair of
|
||
// text boxes whose contents only take effect on some other button is how
|
||
// work gets lost.
|
||
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
|
||
const next = clusterMacros.map((m, j) => (j === i ? { ...m, ...patch } : m));
|
||
setClusterMacros(next);
|
||
saveClusterMacros(next);
|
||
};
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.cluster')}
|
||
hint={t('clu.hint')}
|
||
/>
|
||
<div className="space-y-4">
|
||
<div className="rounded-md border border-border overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||
<tr>
|
||
<th className="px-3 py-2 w-10"></th>
|
||
<th className="text-left px-3 py-2">{t('clu.name')}</th>
|
||
<th className="text-left px-3 py-2">{t('clu.hostPort')}</th>
|
||
<th className="text-left px-3 py-2 w-28">{t('clu.status')}</th>
|
||
<th className="px-3 py-2 w-40">{t('clu.actions')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sorted.map((s, i) => {
|
||
const st = statusForServer(s.id as number);
|
||
const state = (st?.state ?? 'disconnected') as string;
|
||
const isMaster = i === sorted.findIndex((x) => x.enabled);
|
||
return (
|
||
<tr key={s.id as number} className="border-t border-border align-middle">
|
||
<td className="px-2 py-2 text-center">
|
||
<Checkbox
|
||
checked={s.enabled}
|
||
onCheckedChange={(c) => clusterToggleEnabled(s, !!c)}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-2 font-medium">
|
||
{s.name}
|
||
{isMaster && s.enabled && (
|
||
<span className="ml-2 inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider bg-warning-muted text-warning-muted-foreground border border-warning-border">MASTER</span>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 font-mono text-xs">{s.host}:{s.port}</td>
|
||
<td className="px-3 py-2">
|
||
<span className={cn(
|
||
'inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider border',
|
||
state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
|
||
state === 'connecting' || state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
|
||
state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
|
||
'bg-muted text-muted-foreground border-border',
|
||
)}>
|
||
{state.toUpperCase()}
|
||
{st?.retries ? ` #${st.retries}` : ''}
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-2">
|
||
<div className="flex items-center gap-0.5 justify-end">
|
||
{state === 'connected' || state === 'connecting' || state === 'reconnecting' ? (
|
||
<Button variant="ghost" size="icon" className="size-6 text-success hover:text-success"
|
||
onClick={async () => { await DisconnectClusterServer(s.id as number).catch(() => {}); await reloadClusterServers(); }}
|
||
title={t('clu.disconnect')}><Power className="size-3.5" /></Button>
|
||
) : (
|
||
<Button variant="ghost" size="icon" className="size-6"
|
||
onClick={async () => { await ConnectClusterServer(s.id as number).catch(() => {}); await reloadClusterServers(); }}
|
||
title={t('clu.connect')}><Power className="size-3.5" /></Button>
|
||
)}
|
||
<Button variant="ghost" size="icon" className="size-6" disabled={i === 0} onClick={() => clusterMove(s, -1)} title={t('clu.moveUp')}><ArrowUp className="size-3" /></Button>
|
||
<Button variant="ghost" size="icon" className="size-6" disabled={i === sorted.length - 1} onClick={() => clusterMove(s, 1)} title={t('clu.moveDown')}><ArrowDown className="size-3" /></Button>
|
||
<Button variant="ghost" size="icon" className="size-6" onClick={() => setEditingServer(s)} title={t('clu.edit')}><Cog className="size-3.5" /></Button>
|
||
<Button variant="ghost" size="icon" className="size-6 text-destructive hover:text-destructive" onClick={() => clusterDeleteServer(s)} title={t('clu.delete')}><Trash2 className="size-3.5" /></Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{sorted.length === 0 && (
|
||
<tr><td colSpan={5} className="px-3 py-4 text-center text-muted-foreground text-xs">{t('clu.none')}</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 pt-2 border-t border-border">
|
||
<Button variant="outline" size="sm" onClick={clusterAddNew}>
|
||
<Plus className="size-3.5 mr-1" /> {t('clu.add')}
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={async () => { await ConnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
|
||
{t('clu.connectAll')}
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
|
||
{t('clu.disconnectAll')}
|
||
</Button>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer ml-auto">
|
||
<Checkbox checked={clusterAutoConnect} onCheckedChange={(c) => setClusterAutoConnectState(!!c)} />
|
||
{t('clu.autoConnect')}
|
||
</label>
|
||
</div>
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<div>
|
||
<span className="text-sm font-medium">{t('clu.macros')}</span>
|
||
</div>
|
||
{/* Two columns of six: twelve rows stacked would push everything else
|
||
in this panel off the screen. */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
|
||
{clusterMacros.map((m, i) => (
|
||
<div key={i} className="flex items-center gap-1.5">
|
||
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
|
||
<Input
|
||
className="h-8 w-28 shrink-0 text-xs"
|
||
placeholder={t('clu.macroLabel')}
|
||
value={m.label}
|
||
maxLength={24}
|
||
onChange={(e) => 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. */}
|
||
<Input
|
||
className="h-8 flex-1 min-w-0 font-mono text-xs"
|
||
placeholder={t('clu.macroCmd')}
|
||
value={m.cmd}
|
||
title={m.cmd}
|
||
maxLength={500}
|
||
onChange={(e) => setMacro(i, { cmd: e.target.value })}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t('clu.freeNodes')} <span className="font-mono">dxc.k0xm.net:7300</span>,{' '}
|
||
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
|
||
<span className="font-mono">w8avi.net:7300</span>.
|
||
</p>
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
|
||
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
|
||
<span>{t('clu.workedSameSlot')} <span className="text-xs text-muted-foreground">{t('clu.workedSameSlotHint')}</span></span>
|
||
</label>
|
||
{/* "No colour on worked" and "colour what is unworked here" used to live
|
||
here. They moved to the Cluster tab's filter panel, next to Hide
|
||
worked: they are things an operator changes while working a run, not
|
||
things set up once. A preferences dialog you reopen every ten minutes
|
||
is a filter in the wrong place. */}
|
||
|
||
{/* Grid chasing. Here rather than in the filter panel because it is set
|
||
up once: it decides whether locators learnt from decodes are KEPT
|
||
across restarts, not what the list shows right now. */}
|
||
{/* Spot lifetime. Applies to the cluster list AND every band map: the
|
||
spots are removed from the shared list rather than hidden, so the
|
||
setting cannot mean one thing in one view and another elsewhere. */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<span className="text-sm">{t('clu.spotTtl')}</span>
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||
{[0, 5, 10, 15, 30, 60].map((v) => (
|
||
<button key={v} type="button"
|
||
onClick={() => { setSpotTTL(v); setSpotTTLText(String(v)); SetSpotTTLMinutes(v).catch(() => {}); }}
|
||
className={cn('px-2.5 py-1.5 font-medium', spotTTL === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||
{v === 0 ? t('clu.spotTtlNever') : `${v}′`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{/* Raw text in local state: binding the input straight to the
|
||
clamped number makes an empty field impossible to type into. */}
|
||
<Input className="h-8 w-20" value={spotTTLText}
|
||
onChange={(e) => {
|
||
const raw = e.target.value.replace(/[^0-9]/g, '');
|
||
setSpotTTLText(raw);
|
||
const n = Math.min(720, parseInt(raw, 10) || 0);
|
||
setSpotTTL(n);
|
||
SetSpotTTLMinutes(n).catch(() => {});
|
||
}} />
|
||
<span className="text-xs text-muted-foreground">{t('clu.spotTtlHint')}</span>
|
||
</div>
|
||
{/* The list size, beside the lifetime because between them they decide
|
||
the same thing: whichever runs out first removes the spot. */}
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<span className="text-sm">{t('clu.spotMax')}</span>
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||
{[500, 1000, 2500, 5000].map((v) => (
|
||
<button key={v} type="button"
|
||
onClick={() => { setSpotMaxText(String(v)); SetSpotMax(v).catch(() => {}); }}
|
||
className={cn('px-2.5 py-1.5 font-medium', Number(spotMaxText) === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||
{v}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<Input className="h-8 w-24" value={spotMaxText}
|
||
onChange={(e) => {
|
||
const raw = e.target.value.replace(/[^0-9]/g, '');
|
||
setSpotMaxText(raw);
|
||
const n = parseInt(raw, 10);
|
||
if (Number.isFinite(n) && n > 0) SetSpotMax(Math.min(10000, n)).catch(() => {});
|
||
}} />
|
||
<span className="text-xs text-muted-foreground">{t('clu.spotMaxHint')}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={chaseGrids} className="mt-0.5"
|
||
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} />
|
||
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
|
||
</label>
|
||
{chaseGrids && (
|
||
<p className="pl-6 text-xs text-muted-foreground">
|
||
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
|
||
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
|
||
</p>
|
||
)}
|
||
|
||
{/* What counts as "I already have this square". There is no single
|
||
right answer — a VUCC chaser counts a square per band, someone
|
||
filling a wall map counts it once — so it is a choice, and the
|
||
same six GridTracker offers, because a square wanted in one and
|
||
not the other is a bug report every time. */}
|
||
<div className="pl-6 space-y-1.5 pt-1">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
|
||
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
|
||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{(gridScope.scopes ?? []).map((s: any) => (
|
||
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.hunt')}</span>
|
||
<Select value={gridScope.hunt} onValueChange={(v) => saveGridScope({ ...gridScope, hunt: v })}>
|
||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
|
||
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
||
squares and chasing entities are different wants; they only share
|
||
the PSK Reporter feed, which either one brings up. */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={chaseNew} className="mt-0.5"
|
||
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
|
||
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
||
switching it on adds two of them — the operator should see that
|
||
happen where it happens rather than find nodes they did not add. */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={bandOpen.enabled} className="mt-0.5"
|
||
onCheckedChange={(c) => saveBandOpen({ ...bandOpen, enabled: !!c })} />
|
||
<span>{t('bo.enable')} <span className="text-xs text-muted-foreground">{t('bo.enableHint')}</span></span>
|
||
</label>
|
||
{bandOpen.enabled && (
|
||
<div className="pl-6 space-y-2">
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{(bandOpen.available ?? []).map((b: string) => {
|
||
const on = (bandOpen.bands ?? []).includes(b);
|
||
return (
|
||
<button key={b} type="button"
|
||
onClick={() => saveBandOpen({
|
||
...bandOpen,
|
||
bands: on ? bandOpen.bands.filter((x: string) => x !== b) : [...(bandOpen.bands ?? []), b],
|
||
})}
|
||
className={cn('px-2 py-0.5 rounded-md border text-[11px] font-bold tracking-wider font-mono',
|
||
on ? 'bg-primary text-primary-foreground border-primary' : 'text-muted-foreground border-border hover:bg-muted')}>
|
||
{b}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{/* How near a RECEIVER has to be. This is the whole premise of
|
||
the watch — a report is evidence about YOUR path only if it
|
||
was collected near you — and the right distance differs by
|
||
band, so it is the operator's call. Committed on blur/Enter,
|
||
not per keystroke: each save restarts the feed. */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
|
||
<Input
|
||
type="number" min={25} max={1000} step={25}
|
||
className="w-24 h-7 text-xs"
|
||
defaultValue={bandOpen.near_km ?? 300}
|
||
key={`nk-${bandOpen.near_km ?? 300}`}
|
||
onBlur={(e) => {
|
||
const v = parseInt(e.target.value, 10);
|
||
if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v });
|
||
}}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||
/>
|
||
<span className="text-xs text-muted-foreground">km</span>
|
||
</div>
|
||
|
||
{/* A live count, because a feed that is connected but silent looks
|
||
exactly like one that is broken until a number moves. */}
|
||
<p className="text-xs text-muted-foreground">
|
||
{pskrStatus?.running
|
||
? t('bo.feedUp', { n: pskrStatus.received ?? 0 })
|
||
: t('bo.feedDown')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Self-spot. The interval only shows once it's on — an interval for
|
||
something switched off is just a question the operator can't act on. */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={selfSpot.enabled}
|
||
onCheckedChange={(c) => setSelfSpot((s) => ({ ...s, enabled: !!c }))} />
|
||
{t('clu.selfSpot')}
|
||
</label>
|
||
{selfSpot.enabled && (
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<span className="text-muted-foreground">{t('clu.selfSpotEvery')}</span>
|
||
<Input
|
||
type="number"
|
||
min={SELF_SPOT_MIN_MIN}
|
||
max={240}
|
||
className="w-20 h-8 font-mono text-xs"
|
||
value={selfSpotText}
|
||
onChange={(e) => setSelfSpotText(e.target.value)}
|
||
onBlur={() => {
|
||
const n = Math.floor(Number(selfSpotText));
|
||
const v = Number.isFinite(n) && n > SELF_SPOT_MIN_MIN ? Math.min(n, 240) : SELF_SPOT_MIN_MIN;
|
||
setSelfSpotText(String(v));
|
||
setSelfSpot((s) => ({ ...s, minutes: v }));
|
||
}}
|
||
/>
|
||
<span className="text-muted-foreground">{t('clu.selfSpotMinutes')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{editingServer && (
|
||
<ClusterServerEditor
|
||
value={editingServer}
|
||
onCancel={() => setEditingServer(null)}
|
||
onSave={async (srv) => {
|
||
try {
|
||
await SaveClusterServer(srv as any);
|
||
await reloadClusterServers();
|
||
setEditingServer(null);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ConfirmationsPanel() {
|
||
// ADIF status codes. FULL set for paper QSL/eQSL/Clublog/HRDLog,
|
||
// SIMPLE Y/N set for LoTW (the only values the LoTW protocol returns).
|
||
const FULL_OPTIONS = [
|
||
{ value: '_', label: t('conf.blank') },
|
||
{ value: 'Y', label: t('conf.y') },
|
||
{ value: 'N', label: t('conf.n') },
|
||
{ value: 'R', label: t('conf.r') },
|
||
{ value: 'Q', label: t('conf.q') },
|
||
{ value: 'I', label: t('conf.i') },
|
||
];
|
||
// LoTW / Clublog / HRDLog also use ADIF-style status codes — keep
|
||
// R (requested) available so users can mark "queued for upload"
|
||
// and filter on it later.
|
||
|
||
// Renderer inlined as a constant — declaring this as a function
|
||
// INSIDE ConfirmationsPanel would re-instantiate the component on
|
||
// every render, which unmounts and re-mounts the Radix Select
|
||
// (closing it the moment you click the trigger).
|
||
const renderSelect = (
|
||
key: keyof QSLDefaults,
|
||
options: { value: string; label: string }[],
|
||
) => (
|
||
<Select
|
||
value={qslDefaults[key] || '_'}
|
||
onValueChange={(v) => setQslDefaults((d) => ({ ...d, [key]: v === '_' ? '' : v }))}
|
||
>
|
||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{options.map((o) => (
|
||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.confirmations')}
|
||
hint={t('conf.hint')}
|
||
/>
|
||
<ProfileScopeNote profile={activeProfileObj} />
|
||
<div className="space-y-3 max-w-2xl">
|
||
{/* Paper QSL */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">{t('conf.paperQsl')}</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||
{renderSelect('qsl_sent', FULL_OPTIONS)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
|
||
{renderSelect('qsl_rcvd', FULL_OPTIONS)}
|
||
</div>
|
||
</div>
|
||
{/* eQSL */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">eQSL</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||
{renderSelect('eqsl_sent', FULL_OPTIONS)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
|
||
{renderSelect('eqsl_rcvd', FULL_OPTIONS)}
|
||
</div>
|
||
</div>
|
||
{/* LoTW */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">LoTW</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||
{renderSelect('lotw_sent', FULL_OPTIONS)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
|
||
{renderSelect('lotw_rcvd', FULL_OPTIONS)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
{/* Clublog */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">Clublog</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||
{renderSelect('clublog_status', FULL_OPTIONS)}
|
||
</div>
|
||
<div />
|
||
</div>
|
||
{/* HRDLog */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">HRDLog</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||
{renderSelect('hrdlog_status', FULL_OPTIONS)}
|
||
</div>
|
||
<div />
|
||
</div>
|
||
{/* QRZ.com */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">QRZ.com</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
|
||
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
||
</div>
|
||
</div>
|
||
{/* HAMLOG.online */}
|
||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||
{renderSelect('hamlog_status', FULL_OPTIONS)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
|
||
{renderSelect('hamlog_confirmed', FULL_OPTIONS)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function UDPIntegrationsPanelWrapper() {
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.udp')}
|
||
hint={t('udp.hint')}
|
||
/>
|
||
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
function OperatingPanelWrapper() {
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.operating')}
|
||
hint={t('operating.hint')}
|
||
/>
|
||
<OperatingPanel
|
||
bands={lists.bands ?? []}
|
||
onError={(m) => setErr(m)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function BackupPanel() {
|
||
const fmtLast = (iso: string) => {
|
||
if (!iso) return t('bk.never');
|
||
const d = new Date(iso);
|
||
if (isNaN(d.getTime())) return iso;
|
||
const p = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())} UTC`;
|
||
};
|
||
const effectiveFolder = (backupCfg.folder || backupCfg.default_folder || '').replace(/\\/g, '/');
|
||
async function backupNow() {
|
||
setBackupRunning(true); setBackupResult(null);
|
||
try {
|
||
// Save current draft first so the backup runs with the values
|
||
// the user just typed (folder, rotation, zip) — otherwise the
|
||
// backend would use stale persisted config.
|
||
await SaveBackupSettings(backupCfg as any);
|
||
const path = await RunBackupNow();
|
||
setBackupResult({ ok: true, msg: t('bk.writtenTo') + ' ' + path });
|
||
const refreshed = await GetBackupSettings();
|
||
setBackupCfg(refreshed as any);
|
||
} catch (e: any) {
|
||
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally { setBackupRunning(false); }
|
||
}
|
||
return (
|
||
<>
|
||
{/* No hint: everyone knows what a backup is. */}
|
||
<SectionHeader title={t('sec.backup')} />
|
||
<div className="space-y-5 max-w-2xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={!!backupCfg.enabled}
|
||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
|
||
/>
|
||
<span>{t('bk.auto')}</span>
|
||
</label>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">{t('bk.folder')}</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
className="font-mono text-xs flex-1"
|
||
placeholder={backupCfg.default_folder || t('bk.folderPh')}
|
||
value={backupCfg.folder ?? ''}
|
||
onChange={(e) => setBackupCfg((b) => ({ ...b, folder: e.target.value }))}
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async () => {
|
||
try {
|
||
const p = await PickBackupFolder();
|
||
if (p) setBackupCfg((b) => ({ ...b, folder: p }));
|
||
} catch (e: any) {
|
||
setBackupResult({ ok: false, msg: String(e?.message ?? e) });
|
||
}
|
||
}}
|
||
>
|
||
{t('bk.browse')}
|
||
</Button>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground">
|
||
{backupCfg.folder
|
||
? <>{t('bk.effective')} <span className="font-mono">{effectiveFolder}</span></>
|
||
: <>{t('bk.defaultUse')} <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-muted-foreground">{t('bk.rotation')}</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={365}
|
||
className="w-24 font-mono text-xs"
|
||
value={backupCfg.rotation || 5}
|
||
onChange={(e) => {
|
||
const n = Number(e.target.value);
|
||
if (Number.isFinite(n) && n > 0) setBackupCfg((b) => ({ ...b, rotation: Math.floor(n) }));
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Stacked, not one flex row: these labels are full sentences and the
|
||
"keep every backup" hint is two lines — side by side they wrapped
|
||
into unreadable slivers. It also puts keep-all under the option it
|
||
depends on rather than beside it. */}
|
||
<div className="space-y-2.5">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={!!backupCfg.zip}
|
||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
|
||
/>
|
||
<span>{t('bk.zip')}</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={!!(backupCfg as any).every_exit}
|
||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, every_exit: !!c } as any))}
|
||
/>
|
||
<span>{t('bk.everyExit')}</span>
|
||
</label>
|
||
{!!(backupCfg as any).every_exit && (
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer pl-6">
|
||
<Checkbox
|
||
className="mt-0.5"
|
||
checked={!!(backupCfg as any).keep_all}
|
||
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, keep_all: !!c } as any))}
|
||
/>
|
||
<span>
|
||
{t('bk.keepAll')}
|
||
<span className="block text-xs text-muted-foreground mt-0.5 leading-relaxed">{t('bk.keepAllHint')}</span>
|
||
</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 flex items-center gap-3">
|
||
<Button size="sm" onClick={backupNow} disabled={backupRunning}>
|
||
{backupRunning ? t('bk.backingUp') : t('bk.backupNow')}
|
||
</Button>
|
||
<span className="text-xs text-muted-foreground">
|
||
{t('bk.lastRun')} <strong className="text-foreground">{fmtLast(backupCfg.last_backup_at)}</strong>
|
||
</span>
|
||
</div>
|
||
|
||
{backupResult && (
|
||
<div className={cn(
|
||
'text-xs px-3 py-2 rounded-md border',
|
||
backupResult.ok
|
||
? 'bg-success-muted border-success-border text-success-muted-foreground'
|
||
: 'bg-danger-muted border-danger-border text-danger-muted-foreground',
|
||
)}>
|
||
{backupResult.msg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ExternalServicesPanel() {
|
||
const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
|
||
{ k: 'qrz', label: 'QRZ.COM', ready: true },
|
||
{ k: 'clublog', label: 'CLUBLOG', ready: true },
|
||
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
|
||
{ k: 'eqsl', label: 'EQSL', ready: true },
|
||
{ k: 'lotw', label: 'LOTW', ready: true },
|
||
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
||
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
|
||
{ k: 'pota', label: 'POTA', ready: true },
|
||
];
|
||
|
||
async function savePotaToken() {
|
||
setPotaBusy(true);
|
||
setPotaResult(null);
|
||
try {
|
||
await SavePOTAToken(potaToken);
|
||
setPotaResult({ ok: true, msg: 'Token saved. Run the sync from the QSL Manager → POTA hunter log.' });
|
||
} catch (e: any) {
|
||
setPotaResult({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setPotaBusy(false);
|
||
}
|
||
}
|
||
const qrz = extSvc.qrz;
|
||
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
|
||
const clublog = extSvc.clublog;
|
||
const setClublog = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, clublog: { ...s.clublog, ...patch } }));
|
||
|
||
async function testQrz() {
|
||
setQrzTesting(true);
|
||
setQrzTest(null);
|
||
try {
|
||
// Persist first so the backend test reads the key just typed.
|
||
await SaveExternalServices(extSvc as any);
|
||
const msg = await TestQRZUpload();
|
||
setQrzTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setQrzTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setQrzTesting(false);
|
||
}
|
||
}
|
||
|
||
async function testClublog() {
|
||
setClublogTesting(true);
|
||
setClublogTest(null);
|
||
try {
|
||
await SaveExternalServices(extSvc as any);
|
||
const msg = await TestClublogUpload();
|
||
setClublogTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setClublogTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setClublogTesting(false);
|
||
}
|
||
}
|
||
|
||
const hrdlog = extSvc.hrdlog;
|
||
const setHrdlog = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, hrdlog: { ...s.hrdlog, ...patch } }));
|
||
|
||
async function testHrdlog() {
|
||
setHrdlogTesting(true);
|
||
setHrdlogTest(null);
|
||
try {
|
||
await SaveExternalServices(extSvc as any);
|
||
const msg = await TestHRDLogUpload();
|
||
setHrdlogTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setHrdlogTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setHrdlogTesting(false);
|
||
}
|
||
}
|
||
|
||
const hamlog = extSvc.hamlog ?? emptyExtCfg();
|
||
const setHamlog = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
|
||
|
||
// Checking the KEY, not the connection.
|
||
//
|
||
// HAMLOG answers KEYSTATUS with the callsign the key belongs to, which no
|
||
// other service here does — so the answer is shown as the callsign rather
|
||
// than a bare "OK". A key pasted from a club account or a second station is
|
||
// then visible immediately, which is exactly the mistake a green tick would
|
||
// have hidden.
|
||
async function testHamlog() {
|
||
setHamlogTesting(true);
|
||
setHamlogTest(null);
|
||
try {
|
||
const call = await CheckHamlogKey(hamlog.api_key ?? '');
|
||
setHamlogTest({ ok: true, msg: call ? t('es.hamlogKeyOkCall', { call }) : t('es.hamlogKeyOk') });
|
||
} catch (e: any) {
|
||
setHamlogTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setHamlogTesting(false);
|
||
}
|
||
}
|
||
|
||
const cloudlog = extSvc.cloudlog;
|
||
const setCloudlog = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, cloudlog: { ...s.cloudlog, ...patch } }));
|
||
|
||
async function testCloudlog() {
|
||
setCloudlogTesting(true);
|
||
setCloudlogTest(null);
|
||
try {
|
||
// Save first: the backend test reads the stored config, so it must see
|
||
// what was just typed (same as the other services' Test buttons).
|
||
await SaveExternalServices(extSvc as any);
|
||
const msg = await TestCloudlogUpload();
|
||
setCloudlogTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setCloudlogTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setCloudlogTesting(false);
|
||
}
|
||
}
|
||
|
||
const eqsl = extSvc.eqsl;
|
||
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
|
||
|
||
async function testEqsl() {
|
||
setEqslTesting(true);
|
||
setEqslTest(null);
|
||
try {
|
||
await SaveExternalServices(extSvc as any);
|
||
const msg = await TestEQSLUpload();
|
||
setEqslTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setEqslTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setEqslTesting(false);
|
||
}
|
||
}
|
||
|
||
const lotw = extSvc.lotw;
|
||
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
||
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
||
|
||
async function refreshLocations() {
|
||
try {
|
||
const locs: any = await ListTQSLStationLocations();
|
||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||
} catch (e: any) {
|
||
setLotwTest({ ok: false, msg: String(e?.message ?? e) });
|
||
}
|
||
}
|
||
|
||
async function testLotw() {
|
||
setLotwTesting(true);
|
||
setLotwTest(null);
|
||
try {
|
||
await SaveExternalServices(extSvc as any);
|
||
const msg = await TestLoTWUpload();
|
||
setLotwTest({ ok: true, msg });
|
||
} catch (e: any) {
|
||
setLotwTest({ ok: false, msg: String(e?.message ?? e) });
|
||
} finally {
|
||
setLotwTesting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<SectionHeader
|
||
title={t('sec.external')}
|
||
hint={t('extsvc.hint')}
|
||
/>
|
||
|
||
<ProfileScopeNote profile={activeProfileObj} />
|
||
|
||
{/* Applies to BOTH services, so it sits above the tab strip rather than
|
||
inside one tab where it would look like a QRZ-only setting (and be
|
||
invisible to anyone who only opens the Club Log tab). */}
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer mb-4 max-w-2xl">
|
||
<Checkbox
|
||
checked={!!extSvc.delete_remote}
|
||
onCheckedChange={(c) => setExtSvc((v) => ({ ...v, delete_remote: !!c }))}
|
||
className="mt-0.5"
|
||
/>
|
||
<span>
|
||
{t('es.deleteRemote')}
|
||
<span className="block text-xs text-muted-foreground mt-0.5">{t('es.deleteRemoteHint')}</span>
|
||
</span>
|
||
</label>
|
||
|
||
{/* Tab strip */}
|
||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||
{TABS.map((tab) => (
|
||
<button
|
||
key={tab.k}
|
||
type="button"
|
||
onClick={() => setExtSvcTab(tab.k)}
|
||
className={cn(
|
||
'px-3 py-1.5 text-xs font-semibold rounded-t-md border-x border-t -mb-px transition-colors',
|
||
extSvcTab === tab.k
|
||
? 'bg-card border-border text-foreground'
|
||
: 'bg-muted/40 border-transparent text-muted-foreground hover:text-foreground',
|
||
)}
|
||
>
|
||
{tab.label}
|
||
{!tab.ready && <span className="ml-1 text-[9px] opacity-60">{t('es.soon')}</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{extSvcTab === 'qrz' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.apiKey')}</Label>
|
||
<Input
|
||
value={qrz.api_key}
|
||
onChange={(e) => setQrz({ api_key: e.target.value })}
|
||
placeholder={t('es.qrzApiPh')}
|
||
className="font-mono text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.forceCall')}</Label>
|
||
<Input
|
||
value={qrz.force_station_callsign}
|
||
onChange={(e) => setQrz({ force_station_callsign: e.target.value.toUpperCase() })}
|
||
placeholder={t('es.forceCallPh')}
|
||
className="font-mono text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={qrz.auto_upload}
|
||
onCheckedChange={(c) => setQrz({ auto_upload: !!c })}
|
||
/>
|
||
{t('es.autoUpload')}
|
||
</label>
|
||
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||
<Select
|
||
value={qrz.upload_mode || 'immediate'}
|
||
onValueChange={(v) => setQrz({ upload_mode: v })}
|
||
>
|
||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||
<SelectItem value="on_close">{t('es.onClose')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testQrz} disabled={qrzTesting || !qrz.api_key}>
|
||
<UploadCloud className="size-3.5" /> {qrzTesting ? t('es.testing') : t('es.testConn')}
|
||
</Button>
|
||
{qrzTest && (
|
||
<span className={cn('text-xs', qrzTest.ok ? 'text-success' : 'text-danger')}>
|
||
{qrzTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'clublog' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.accountEmail')}</Label>
|
||
<Input
|
||
type="email"
|
||
value={clublog.email}
|
||
onChange={(e) => setClublog({ email: e.target.value })}
|
||
placeholder={t('es.clubEmailPh')}
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.password')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={clublog.password}
|
||
onChange={(e) => setClublog({ password: e.target.value })}
|
||
placeholder={t('es.clubPwPh')}
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.logbookCall')}</Label>
|
||
<Input
|
||
value={clublog.callsign}
|
||
onChange={(e) => setClublog({ callsign: e.target.value.toUpperCase() })}
|
||
placeholder={t('es.callDefaultPh')}
|
||
className="font-mono text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={clublog.auto_upload}
|
||
onCheckedChange={(c) => setClublog({ auto_upload: !!c })}
|
||
/>
|
||
{t('es.autoUpload')}
|
||
</label>
|
||
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||
<Select
|
||
value={clublog.upload_mode || 'immediate'}
|
||
onValueChange={(v) => setClublog({ upload_mode: v })}
|
||
>
|
||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||
<SelectItem value="on_close">{t('es.onClose')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testClublog} disabled={clublogTesting}>
|
||
<UploadCloud className="size-3.5" /> {clublogTesting ? t('es.testing') : t('es.testConn')}
|
||
</Button>
|
||
{clublogTest && (
|
||
<span className={cn('text-xs', clublogTest.ok ? 'text-success' : 'text-danger')}>
|
||
{clublogTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'hrdlog' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.stationCall')}</Label>
|
||
<Input
|
||
value={hrdlog.callsign}
|
||
onChange={(e) => setHrdlog({ callsign: e.target.value.toUpperCase() })}
|
||
placeholder={t('es.callDefaultPh')}
|
||
className="font-mono text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.uploadCode')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={hrdlog.code}
|
||
onChange={(e) => setHrdlog({ code: e.target.value })}
|
||
placeholder={t('es.hrdCodePh')}
|
||
className="text-xs"
|
||
/>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground -mt-1">
|
||
{t('es.hrdHint')}
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={hrdlog.auto_upload}
|
||
onCheckedChange={(c) => setHrdlog({ auto_upload: !!c })}
|
||
/>
|
||
{t('es.autoUpload')}
|
||
</label>
|
||
|
||
{/* ON AIR is a live status, not an upload: it needs the rig
|
||
readable, not a QSO. Its own switch beside the upload one
|
||
rather than folded into it. */}
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox className="mt-0.5"
|
||
checked={!!(hrdlog as any).on_air}
|
||
onCheckedChange={(c) => setHrdlog({ on_air: !!c } as any)}
|
||
/>
|
||
<span>
|
||
{t('es.hrdOnAir')}
|
||
<span className="block text-xs text-muted-foreground mt-0.5">{t('es.hrdOnAirHint')}</span>
|
||
</span>
|
||
</label>
|
||
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||
<Select
|
||
value={hrdlog.upload_mode || 'immediate'}
|
||
onValueChange={(v) => setHrdlog({ upload_mode: v })}
|
||
>
|
||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||
<SelectItem value="on_close">{t('es.onClose')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testHrdlog} disabled={hrdlogTesting}>
|
||
<UploadCloud className="size-3.5" /> {hrdlogTesting ? t('es.testing') : t('es.testConn')}
|
||
</Button>
|
||
{hrdlogTest && (
|
||
<span className={cn('text-xs', hrdlogTest.ok ? 'text-success' : 'text-danger')}>
|
||
{hrdlogTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'hamlog' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.apiKey')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={hamlog.api_key}
|
||
onChange={(e) => setHamlog({ api_key: e.target.value })}
|
||
className="text-xs"
|
||
/>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground -mt-1">
|
||
{t('es.hamlogHint')}{' '}
|
||
<button type="button" className="underline hover:text-foreground"
|
||
onClick={() => BrowserOpenURL('https://hamlog.online/account/agent.php')}>
|
||
hamlog.online/account/agent.php
|
||
</button>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={hamlog.auto_upload}
|
||
onCheckedChange={(c) => setHamlog({ auto_upload: !!c })}
|
||
/>
|
||
{t('es.autoUpload')}
|
||
</label>
|
||
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||
<Select
|
||
value={hamlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
|
||
onValueChange={(v) => setHamlog({ upload_mode: v })}
|
||
>
|
||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testHamlog} disabled={hamlogTesting}>
|
||
<UploadCloud className="size-3.5" /> {hamlogTesting ? t('es.testing') : t('es.hamlogCheckKey')}
|
||
</Button>
|
||
{hamlogTest && (
|
||
<span className={cn('text-xs', hamlogTest.ok ? 'text-success' : 'text-danger')}>
|
||
{hamlogTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'cloudlog' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.cloudlogUrl')}</Label>
|
||
<Input
|
||
value={cloudlog.url}
|
||
onChange={(e) => setCloudlog({ url: e.target.value })}
|
||
placeholder={t('es.cloudlogUrlPh')}
|
||
className="font-mono text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.apiKey')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={cloudlog.api_key}
|
||
onChange={(e) => setCloudlog({ api_key: e.target.value })}
|
||
placeholder={t('es.cloudlogKeyPh')}
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.cloudlogStationId')}</Label>
|
||
<Input
|
||
value={cloudlog.station_id}
|
||
onChange={(e) => setCloudlog({ station_id: e.target.value })}
|
||
placeholder="1"
|
||
className="font-mono text-xs w-24"
|
||
/>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground -mt-1">
|
||
{t('es.cloudlogHint')}
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={cloudlog.auto_upload}
|
||
onCheckedChange={(c) => setCloudlog({ auto_upload: !!c })}
|
||
/>
|
||
{t('es.autoUpload')}
|
||
</label>
|
||
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||
<Select
|
||
value={cloudlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
|
||
onValueChange={(v) => setCloudlog({ upload_mode: v })}
|
||
>
|
||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* No "on close" option: unlike the other services, Cloudlog has no
|
||
per-QSO status column in the logbook, so there is nothing to
|
||
select a backlog from at shutdown. */}
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testCloudlog} disabled={cloudlogTesting}>
|
||
<UploadCloud className="size-3.5" /> {cloudlogTesting ? t('es.testing') : t('es.testConn')}
|
||
</Button>
|
||
{cloudlogTest && (
|
||
<span className={cn('text-xs', cloudlogTest.ok ? 'text-success' : 'text-danger')}>
|
||
{cloudlogTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'eqsl' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.usernameCall')}</Label>
|
||
<Input
|
||
value={eqsl.username}
|
||
onChange={(e) => setEqsl({ username: e.target.value.toUpperCase() })}
|
||
placeholder={t('es.eqslUserPh')}
|
||
className="font-mono text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.password')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={eqsl.password}
|
||
onChange={(e) => setEqsl({ password: e.target.value })}
|
||
placeholder={t('es.eqslPwPh')}
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.qthNick')}</Label>
|
||
<Input
|
||
value={eqsl.qth_nickname}
|
||
onChange={(e) => setEqsl({ qth_nickname: e.target.value })}
|
||
placeholder={t('es.qthNickPh')}
|
||
className="text-xs"
|
||
/>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground -mt-1">
|
||
{t('es.eqslHint')}
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={eqsl.auto_upload}
|
||
onCheckedChange={(c) => setEqsl({ auto_upload: !!c })}
|
||
/>
|
||
{t('es.autoUpload')}
|
||
</label>
|
||
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||
<Select
|
||
value={eqsl.upload_mode || 'immediate'}
|
||
onValueChange={(v) => setEqsl({ upload_mode: v })}
|
||
>
|
||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||
<SelectItem value="on_close">{t('es.onClose')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testEqsl} disabled={eqslTesting}>
|
||
<UploadCloud className="size-3.5" /> {eqslTesting ? t('es.testing') : t('es.testConn')}
|
||
</Button>
|
||
{eqslTest && (
|
||
<span className={cn('text-xs', eqslTest.ok ? 'text-success' : 'text-danger')}>
|
||
{eqslTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'lotw' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('es.lotwUser')}</Label>
|
||
<Input
|
||
value={lotw.username}
|
||
onChange={(e) => setLotw({ username: e.target.value })}
|
||
placeholder={t('es.lotwUserPh')}
|
||
className="font-mono text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.lotwPw')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={lotw.password}
|
||
onChange={(e) => setLotw({ password: e.target.value })}
|
||
placeholder={t('es.lotwPwPh')}
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.tqslPath')}</Label>
|
||
<Input
|
||
value={lotw.tqsl_path}
|
||
onChange={(e) => setLotw({ tqsl_path: e.target.value })}
|
||
placeholder="C:\Program Files (x86)\TrustedQSL\tqsl.exe"
|
||
className="font-mono text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.stationLoc')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={lotw.station_location || '_'} onValueChange={(v) => setLotw({ station_location: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('es.pickLoc')} /></SelectTrigger>
|
||
<SelectContent>
|
||
{stationLocations.length === 0 && <SelectItem value="_" disabled>{t('es.noLoc')}</SelectItem>}
|
||
{stationLocations.map((n) => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={refreshLocations} title={t('es.reloadLoc')}>
|
||
<ArrowDown className="size-3.5 rotate-90" />
|
||
</Button>
|
||
</div>
|
||
<Label className="text-sm">{t('es.forceCall')}</Label>
|
||
<div>
|
||
<Input
|
||
value={lotw.force_station_callsign}
|
||
onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
|
||
placeholder={t('es.lotwForcePh')}
|
||
className="font-mono uppercase w-64"
|
||
/>
|
||
<div className="text-[10px] text-muted-foreground mt-1">
|
||
{t('es.lotwForceHint')}
|
||
</div>
|
||
</div>
|
||
<Label className="text-sm">{t('es.keyPw')}</Label>
|
||
<Input
|
||
type="password"
|
||
value={lotw.key_password}
|
||
onChange={(e) => setLotw({ key_password: e.target.value })}
|
||
placeholder={t('es.keyPwPh')}
|
||
className="text-xs"
|
||
/>
|
||
<Label className="text-sm">{t('es.considerUnsent')}</Label>
|
||
<div>
|
||
<div className="flex items-center gap-4">
|
||
{(['N', 'R'] as const).map((f) => {
|
||
const flags = lotw.upload_flags ?? [];
|
||
const checked = flags.includes(f);
|
||
return (
|
||
<label key={f} className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={checked}
|
||
onCheckedChange={(c) => {
|
||
const next = c
|
||
? Array.from(new Set([...flags, f]))
|
||
: flags.filter((x) => x !== f);
|
||
setLotw({ upload_flags: next });
|
||
}}
|
||
/>
|
||
{f === 'N' ? t('es.flagN') : t('es.flagR')}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground mt-1">
|
||
{t('es.lotwUnsentHint')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={lotw.auto_upload}
|
||
onCheckedChange={(c) => setLotw({ auto_upload: !!c, upload_mode: 'on_close' })}
|
||
/>
|
||
{t('es.lotwAutoClose')}
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={lotw.write_log}
|
||
onCheckedChange={(c) => setLotw({ write_log: !!c })}
|
||
/>
|
||
{t('es.lotwWriteLog')}
|
||
</label>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={testLotw} disabled={lotwTesting}>
|
||
<UploadCloud className="size-3.5" /> {lotwTesting ? t('es.testing') : t('es.testConn')}
|
||
</Button>
|
||
{lotwTest && (
|
||
<span className={cn('text-xs', lotwTest.ok ? 'text-success' : 'text-danger')}>
|
||
{lotwTest.msg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* LoTW user list — powers the colour-coded "LoTW" badge next to the
|
||
entered callsign (green < 1 week · amber 1–4 weeks · red > 30 days). */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<Label className="text-sm font-medium">{t('lotw.usersTitle')}</Label>
|
||
<p className="text-[11px] text-muted-foreground">{t('lotw.usersHint')}</p>
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={downloadLotwUsers} disabled={lotwUsersBusy}>
|
||
<ArrowDown className="size-3.5" /> {lotwUsersBusy ? t('lotw.usersDownloading') : t('lotw.usersDownload')}
|
||
</Button>
|
||
<span className="text-xs text-muted-foreground">
|
||
{lotwUsers.count > 0
|
||
? t('lotw.usersLoaded', { n: lotwUsers.count.toLocaleString(), date: lotwUsers.updated ? new Date(lotwUsers.updated).toLocaleDateString() : '?' })
|
||
: t('lotw.usersNone')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : extSvcTab === 'pota' ? (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||
{t('es.potaHint')}
|
||
</p>
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-start">
|
||
<Label className="text-sm pt-2">{t('es.sessionToken')}</Label>
|
||
<Textarea
|
||
value={potaToken}
|
||
onChange={(e) => setPotaToken(e.target.value)}
|
||
placeholder="eyJ… (Authorization header from pota.app)"
|
||
className="font-mono text-[11px] h-20"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Button onClick={savePotaToken} disabled={potaBusy}>
|
||
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> {t('es.saving')}</> : t('es.saveToken')}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{t('es.potaThen')}
|
||
</span>
|
||
</div>
|
||
{potaResult && (
|
||
<div className={cn('text-xs rounded-md px-3 py-2 border',
|
||
potaResult.ok ? 'border-success-border bg-success-muted text-success-muted-foreground' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
|
||
{potaResult.msg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
||
<Construction className="size-10 opacity-30" />
|
||
<div className="text-sm font-semibold text-foreground/70">
|
||
{t('es.comingSoon', { name: TABS.find((tab) => tab.k === extSvcTab)?.label ?? '' })}
|
||
</div>
|
||
<div className="text-xs">{t('es.notWired')}</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DatabasePanel() {
|
||
// Compacting: which database is running, and what the last one saved.
|
||
// SQLite frees pages inside the file and never shrinks it, so this is the
|
||
// only thing that gives the disk space back after a large delete.
|
||
const [compacting, setCompacting] = useState('');
|
||
const [compactMsg, setCompactMsg] = useState<Record<string, string>>({});
|
||
function compact(target: 'settings' | 'logbook') {
|
||
setCompacting(target);
|
||
setCompactMsg((m) => ({ ...m, [target]: '' }));
|
||
CompactDatabase(target)
|
||
.then((r: any) => {
|
||
const msg = r?.backend === 'mysql'
|
||
? t('db.compactMysqlDone')
|
||
: t('db.compactDone', { before: fmtBytes(r?.before ?? 0), after: fmtBytes(r?.after ?? 0) });
|
||
setCompactMsg((m) => ({ ...m, [target]: msg }));
|
||
})
|
||
.catch((e: any) => setErr(String(e?.message ?? e)))
|
||
.finally(() => setCompacting(''));
|
||
}
|
||
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
|
||
async function refreshBackend() { try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} }
|
||
// The chosen file is persisted (config.json) the moment it's picked; dbMsg
|
||
// holds the pending path so the banner can offer a one-click restart to load
|
||
// it (the DB pointer is only read at startup).
|
||
async function openExisting() {
|
||
try {
|
||
const p = await PickOpenDatabase();
|
||
if (!p) return;
|
||
await OpenDatabase(p);
|
||
await refreshDb();
|
||
setDbMsg(p);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function saveCopy() {
|
||
try {
|
||
const p = await PickSaveDatabase();
|
||
if (!p) return;
|
||
await MoveDatabase(p);
|
||
await refreshDb();
|
||
setDbMsg(p);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function createNew() {
|
||
try {
|
||
const p = await PickSaveDatabase();
|
||
if (!p) return;
|
||
await CreateDatabase(p);
|
||
await refreshDb();
|
||
setDbMsg(p);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
// Rename the CURRENT database (keeps all config), unlike New database which
|
||
// starts empty. The old file is removed on the next launch.
|
||
async function renameDb() {
|
||
try {
|
||
const p = await PickSaveDatabase();
|
||
if (!p) return;
|
||
await RenameDatabase(p);
|
||
await refreshDb();
|
||
setDbMsg(p);
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function resetDefault() {
|
||
try {
|
||
await ResetDatabaseToDefault();
|
||
await refreshDb();
|
||
setDbMsg(dbSettings.default_path || '');
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
function revealFolder() { RevealDataFolder().catch((e: any) => setErr(String(e?.message ?? e))); }
|
||
// Rename/relocate THIS profile's logbook, carrying the QSOs across.
|
||
async function renameLogbook() {
|
||
try {
|
||
const p = await PickSaveDatabase();
|
||
if (!p) return;
|
||
await RenameLogbook(p);
|
||
setMysqlField({ enabled: false, sqlite_path: p });
|
||
setRestartMsg(t('db.logbookRenamed'));
|
||
await refreshBackend();
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
// Switching the logbook backend applies immediately (no restart): the local
|
||
// SQLite file always stays the config store; only the QSO logbook moves.
|
||
function useLocalLogbook() {
|
||
SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: '' } as any)
|
||
.then(async () => { setMysqlField({ enabled: false, sqlite_path: '' }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
|
||
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||
}
|
||
// Point THIS profile's logbook at a SQLite file (settings stay in the
|
||
// settings db). A NEW name is created + migrated empty; an existing file is
|
||
// opened with its QSOs.
|
||
async function newLogbook() {
|
||
try {
|
||
const p = await PickSaveDatabase();
|
||
if (!p) return;
|
||
await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
|
||
setMysqlField({ enabled: false, sqlite_path: p });
|
||
setRestartMsg(t('db.switchedSqliteFile'));
|
||
await refreshBackend();
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
async function openLogbook() {
|
||
try {
|
||
const p = await PickOpenDatabase();
|
||
if (!p) return;
|
||
await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
|
||
setMysqlField({ enabled: false, sqlite_path: p });
|
||
setRestartMsg(t('db.switchedSqliteFile'));
|
||
await refreshBackend();
|
||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||
}
|
||
function connectMysql() {
|
||
SaveMySQLSettings(mysqlCfg as any)
|
||
.then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
|
||
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||
}
|
||
return (
|
||
<>
|
||
<SectionHeader title={t('sec.database')} />
|
||
|
||
{/* Settings / application database (settings + profiles) — always shown,
|
||
distinct from the QSO logbook so the two are never confused. */}
|
||
<div className="space-y-2 max-w-2xl mb-5 border border-border/60 rounded-md p-3">
|
||
<Label>{t('db.appDb')}</Label>
|
||
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||
{dbSettings.path || '—'}
|
||
{dbSettings.is_custom
|
||
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
|
||
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||
<Button variant="outline" size="sm" onClick={openExisting}><Database className="size-3.5" /> {t('db.openExisting')}</Button>
|
||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
||
{/* Pushed right: these two act on the file that is already there,
|
||
while the four on the left change WHICH file is in use. */}
|
||
<Button variant="outline" size="sm" className="ml-auto" onClick={() => compact('settings')} disabled={compacting !== ''}>
|
||
{compacting === 'settings' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||
</div>
|
||
{compactMsg.settings && <p className="text-[11px] text-success">{compactMsg.settings}</p>}
|
||
{/* The DB pointer is only read at startup, so offer the restart inline. */}
|
||
{dbMsg && (
|
||
<div className="text-[11px] text-success space-y-1 pt-1">
|
||
<div>{t('db.savedRestart')} <span className="font-mono break-all">{dbMsg}</span></div>
|
||
<Button size="sm" className="h-7" onClick={() => RestartApp().catch((e: any) => setErr(String(e?.message ?? e)))}>
|
||
<Power className="size-3.5" /> {t('db.restartNow')}
|
||
</Button>
|
||
<span className="ml-2 text-muted-foreground">{t('db.restartHint')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Logbook (QSOs) — this profile: default SQLite file, a dedicated file, or MySQL. */}
|
||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
||
<Label className="text-sm">{t('db.logbookLabel')}</Label>
|
||
<Select
|
||
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
||
onValueChange={(v) => {
|
||
setRestartMsg('');
|
||
if (v === 'mysql') { setMysqlField({ enabled: true }); return; }
|
||
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
|
||
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* Compact active-backend confirmation / MySQL-fallback warning. */}
|
||
{backendStatus && (
|
||
backendStatus.fallback ? (
|
||
<div className="max-w-2xl mb-4 text-xs bg-warning-muted border border-warning-border text-warning-muted-foreground rounded-md px-3 py-2">
|
||
{t('db.fallback')}
|
||
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
|
||
</div>
|
||
) : (
|
||
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
|
||
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{/* SQLite logbook file: default logbook.db, or a dedicated file for this profile. */}
|
||
{!mysqlCfg.enabled && (
|
||
<div className="space-y-3 max-w-2xl">
|
||
<div className="space-y-1">
|
||
<Label>{t('db.logbookFile')}</Label>
|
||
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||
{mysqlCfg.sqlite_path || dbSettings.logbook_default_path || '—'}
|
||
{mysqlCfg.sqlite_path
|
||
? <span className="ml-2 text-[10px] text-success">{t('db.dedicatedFile')}</span>
|
||
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">{t('db.logbookFileHint')}</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" size="sm" onClick={newLogbook}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||
<Button variant="outline" size="sm" onClick={openLogbook}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||
<Button variant="outline" size="sm" onClick={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button>
|
||
<Button variant="outline" size="sm" className="ml-auto" onClick={() => compact('logbook')} disabled={compacting !== ''}>
|
||
{compacting === 'logbook' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
|
||
</Button>
|
||
{mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
|
||
</div>
|
||
{compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{/* MySQL: shared logbook connection (multi-operator) */}
|
||
{mysqlCfg.enabled && (
|
||
<div className="space-y-3 max-w-2xl">
|
||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||
<Label className="text-sm">{t('db.host')}</Label>
|
||
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
||
<Label className="text-sm">{t('db.port')}</Label>
|
||
<PortInput className="h-8 w-28 font-mono" value={mysqlCfg.port} fallback={3306} onChange={(n) => setMysqlField({ port: n })} />
|
||
<Label className="text-sm">{t('db.database')}</Label>
|
||
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
|
||
<Label className="text-sm">{t('db.user')}</Label>
|
||
<Input className="h-8" value={mysqlCfg.user} onChange={(e) => setMysqlField({ user: e.target.value })} />
|
||
<Label className="text-sm">{t('es.password')}</Label>
|
||
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
|
||
</div>
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<Button variant="outline" size="sm" className="h-8"
|
||
onClick={() => { setMysqlMsg(t('db.testing')); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg(t('db.connectedReady'))).catch((e: any) => setMysqlMsg(t('db.failed') + String(e?.message ?? e))); }}>
|
||
{t('db.testCreate')}
|
||
</Button>
|
||
<Button size="sm" className="h-8" onClick={connectMysql}>{t('db.connectUse')}</Button>
|
||
{/* Compacting a shared logbook is OPTIMIZE TABLE, and it rebuilds the
|
||
table with everyone else waiting on it — hence the warning, and
|
||
hence its place here rather than beside the connection buttons of
|
||
a file only this operator uses. */}
|
||
<Button variant="outline" size="sm" className="h-8 ml-auto" title={t('db.compactMysqlWarn')}
|
||
onClick={() => compact('logbook')} disabled={compacting !== ''}>
|
||
{compacting === 'logbook' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
|
||
</div>
|
||
{compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{restartMsg && <div className="max-w-2xl mt-3 text-[11px] text-success">{restartMsg}</div>}
|
||
|
||
{/* Backup settings, merged into this Database section. */}
|
||
<div className="border-t border-border/60 mt-6 pt-5">
|
||
{BackupPanel()}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AudioPanel() {
|
||
// Names the CAT backend the operator has configured, so the PTT dropdown can
|
||
// say "CAT — Icom CI-V (USB)" rather than the flatly wrong "CAT (OmniRig)".
|
||
const catBackendLabel = ({
|
||
omnirig: t('cat.optOmnirig'),
|
||
flex: t('cat.optFlex'),
|
||
icom: t('cat.optIcom'),
|
||
'icom-net': t('cat.optIcomNet'),
|
||
tci: t('cat.optTci'),
|
||
yaesu: t('cat.optYaesu'),
|
||
xiegu: t('cat.optXiegu'),
|
||
} as Record<string, string>)[catCfg.backend] ?? '';
|
||
|
||
const deviceSelect = (
|
||
field: keyof AudioSettings,
|
||
devices: AudioDev[],
|
||
placeholder: string,
|
||
) => (
|
||
<Select
|
||
value={(audioCfg[field] as string) || '_'}
|
||
onValueChange={(v) => setAudioField({ [field]: v === '_' ? '' : v } as any)}
|
||
>
|
||
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_">{t('aud.noneDefault')}</SelectItem>
|
||
{devices.map((d) => (
|
||
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' ' + t('aud.defaultTag') : ''}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
);
|
||
return (
|
||
<>
|
||
<div className="flex items-start justify-between">
|
||
<SectionHeader
|
||
title={t('hw.audioVoice')}/>
|
||
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
|
||
{t('aud.refreshDevices')}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-3 max-w-2xl">
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('aud.fromRadio')}</Label>
|
||
{deviceSelect('from_radio', audioInputs, t('aud.phFromRadio'))}
|
||
<Label className="text-sm">{t('aud.toRadio')}</Label>
|
||
{deviceSelect('to_radio', audioOutputs, t('aud.phToRadio'))}
|
||
<Label className="text-sm">{t('aud.recMic')}</Label>
|
||
{deviceSelect('recording_device', audioInputs, t('aud.phRecMic'))}
|
||
<Label className="text-sm">{t('aud.listening')}</Label>
|
||
{deviceSelect('listening_device', audioOutputs, t('aud.phListening'))}
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
|
||
<strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
|
||
</p>
|
||
|
||
{/* The radio is one of the devices above when it can carry its own
|
||
audio — see ListAudioInputDevices. What used to be here was a test
|
||
bench: open the stream, record ten seconds, key a tone. It settled
|
||
how TCI works and has no business in front of an operator now that
|
||
choosing the device is the whole of the setup. */}
|
||
<div className="flex items-center gap-3">
|
||
<Button
|
||
variant={monitorOn ? 'default' : 'outline'}
|
||
size="sm"
|
||
className="h-8"
|
||
onClick={toggleMonitor}
|
||
disabled={!monitorOn && !audioCfg.from_radio}
|
||
title={t('aud.monitorTitle')}
|
||
>
|
||
{monitorOn ? t('aud.stopListening') : t('aud.listenRadio')}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{monitorOn ? t('aud.monitorOn') : t('aud.monitorHint')}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Button
|
||
variant={txOn ? 'destructive' : 'outline'}
|
||
size="sm"
|
||
className="h-8"
|
||
onClick={toggleTX}
|
||
disabled={!txOn && !audioCfg.to_radio}
|
||
title={t('aud.txTitle')}
|
||
>
|
||
{txOn ? t('aud.stopTalk') : t('aud.talkRadio')}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{txOn ? t('aud.txOn') : t('aud.txHint')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
||
<h4 className="text-sm font-semibold text-foreground">{t('aud.recorder')}</h4>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
|
||
{t('aud.recordEvery')}
|
||
</label>
|
||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||
<Label className="text-sm">{t('aud.recFolder')}</Label>
|
||
<div className="flex gap-2">
|
||
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
|
||
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
|
||
<Button variant="outline" size="sm" className="h-8 shrink-0"
|
||
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
|
||
{t('aud.browse')}
|
||
</Button>
|
||
</div>
|
||
<Label className="text-sm">{t('aud.preroll')}</Label>
|
||
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
|
||
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
|
||
className="h-8 w-24 font-mono" />
|
||
<Label className="text-sm">{t('aud.format')}</Label>
|
||
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
|
||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="wav">{t('aud.wav')}</SelectItem>
|
||
<SelectItem value="mp3">{t('aud.mp3')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Label className="text-sm">{t('aud.fromLevel')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
|
||
onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ from_gain: v }); AudioApplyLevels(v, audioCfg.mic_gain); }} className="w-48 accent-primary" />
|
||
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
|
||
</div>
|
||
<Label className="text-sm">{t('aud.qsoPlayLevel')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input type="range" min={10} max={300} step={5} value={audioCfg.qso_play_gain}
|
||
onChange={(e) => setAudioField({ qso_play_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||
<span className="font-mono text-xs w-12 text-right">{audioCfg.qso_play_gain}%</span>
|
||
</div>
|
||
<Label className="text-sm">{t('aud.micLevel')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
|
||
onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ mic_gain: v }); AudioApplyLevels(audioCfg.from_gain, v); }} className="w-48 accent-primary" />
|
||
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">{t('aud.levelHint')}</p>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
|
||
{t('aud.autoSend')}
|
||
</label>
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
|
||
<h4 className="text-sm font-semibold text-foreground">{t('aud.dvkTitle')}</h4>
|
||
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
|
||
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
|
||
<Label className="text-sm">{t('aud.pttMethod')}</Label>
|
||
<div className="flex gap-2 items-center">
|
||
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
|
||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">{t('aud.pttNone')}</SelectItem>
|
||
{/* This has ALWAYS driven whichever CAT backend is active —
|
||
it calls the generic manager, and every backend (OmniRig,
|
||
FlexRadio, Icom CI-V over USB and over the network, TCI)
|
||
implements SetPTT. The label said "CAT (OmniRig)", so
|
||
operators on a CI-V rig concluded there was no CAT PTT for
|
||
them and reached for RTS/DTR or VOX instead. Name the
|
||
backend actually configured. */}
|
||
<SelectItem value="cat">{t('aud.pttCat')}{catBackendLabel ? ` — ${catBackendLabel}` : ''}</SelectItem>
|
||
<SelectItem value="rts">{t('aud.pttRts')}</SelectItem>
|
||
<SelectItem value="dtr">{t('aud.pttDtr')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
{audioCfg.ptt_method !== 'none' && (
|
||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr(t('aud.errPttTest') + String(e?.message ?? e))); }}>
|
||
{t('aud.testPtt')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||
<>
|
||
<Label className="text-sm">{t('aud.pttPort')}</Label>
|
||
<div className="flex gap-2 items-center">
|
||
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-8 w-44"><SelectValue placeholder={t('aud.pickPort')} /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="_">{t('aud.selectPort')}</SelectItem>
|
||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
|
||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||
{t('aud.refresh')}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Playback level. Nothing else could raise it: the message went to the
|
||
rig exactly as recorded, so a quietly recorded mic drove the radio
|
||
quietly and the only remedies were the rig's own USB input menu or
|
||
the Windows mixer. */}
|
||
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2">
|
||
<Label className="text-sm">{t('aud.txLevel')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<input type="range" min={10} max={400} step={5} value={audioCfg.tx_gain}
|
||
onChange={(e) => setAudioField({ tx_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||
<span className="font-mono text-xs w-12 text-right">{audioCfg.tx_gain}%</span>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">{t('aud.txLevelHint')}</p>
|
||
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
|
||
<div className="space-y-1.5">
|
||
{dvkMsgs.map((m) => {
|
||
const recHere = dvkStat.recording && dvkStat.rec_slot === m.slot;
|
||
const recBusy = dvkStat.recording && !recHere;
|
||
return (
|
||
<div key={m.slot} className="flex items-center gap-2">
|
||
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
|
||
<Input
|
||
className="h-8 flex-1"
|
||
placeholder={t('aud.msgPlaceholder', { n: m.slot })}
|
||
value={m.label}
|
||
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
|
||
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
|
||
/>
|
||
<span className="w-16 text-[11px] text-muted-foreground text-right">
|
||
{m.has_audio ? `✓ ${m.duration_sec.toFixed(1)}s` : '—'}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant={recHere ? 'destructive' : 'outline'} size="sm" className="h-8 w-28 shrink-0 select-none touch-none"
|
||
disabled={recBusy}
|
||
onPointerDown={(e) => {
|
||
e.preventDefault();
|
||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||
setDvkErr('');
|
||
DVKStartRecord(m.slot).catch((err) => setDvkErr(t('aud.errRecord') + String(err?.message ?? err)));
|
||
}}
|
||
onPointerUp={() => {
|
||
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr(t('aud.errSave') + String(err?.message ?? err)));
|
||
}}
|
||
>
|
||
{recHere ? t('aud.recordingNow') : t('aud.holdRec')}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline" size="sm" className="h-8 w-20 shrink-0"
|
||
disabled={!m.has_audio || dvkStat.recording}
|
||
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr(t('aud.errPlay') + String(err?.message ?? err))))}
|
||
>
|
||
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
|
||
</Button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function GeneralPanel() {
|
||
const { lang, setLang, t } = useI18n();
|
||
return (
|
||
<>
|
||
<SectionHeader title={t('sec.general')} hint={t('gen.hint')} />
|
||
<div className="space-y-3 max-w-3xl">
|
||
<div className="flex items-center gap-3">
|
||
<Label className="text-sm w-40">{t('settings.language')}</Label>
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||
{([['en', FlagGB, 'English'], ['fr', FlagFR, 'Français']] as [Lang, typeof FlagGB, string][]).map(([code, Flag, label]) => (
|
||
<button key={code} type="button" onClick={() => setLang(code)}
|
||
className={cn('flex items-center gap-2 px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0',
|
||
lang === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||
<Flag className="w-5 rounded-[2px] border border-border/30" />
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Display only. The log is stored in ISO/ADIF whatever is chosen here
|
||
— that is what ADIF specifies and what sorts correctly, and a
|
||
stored format that followed a preference would make the log
|
||
unreadable the day the preference changed. */}
|
||
<div className="flex items-center gap-3">
|
||
<Label className="text-sm w-40">{t('gen.dateFormat')}</Label>
|
||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||
{([['iso', t('gen.dateStandard'), '2026-07-30 14:25'],
|
||
['fr', t('gen.dateFR'), '30-07-2026 14:25'],
|
||
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
|
||
<button key={code} type="button" title={sample}
|
||
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
|
||
className={cn('flex flex-col items-start px-3 py-1 text-sm font-medium border-l border-border first:border-l-0',
|
||
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||
{label}
|
||
<span className="text-[10px] font-mono opacity-70">{sample}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<ThemeSelector />
|
||
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
|
||
{t('gen.autofocusWB')}
|
||
</label>
|
||
{/* Backend setting, not a UI pref: the fold happens in the SQL. Saved
|
||
instantly like the rest of this panel. */}
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={workedVariants}
|
||
onCheckedChange={(c) => { const v = !!c; setWorkedVariants(v); SetWorkedCallVariants(v).catch((e: any) => setErr(String(e?.message ?? e))); }} />
|
||
{t('gen.workedVariants')} <span className="text-xs text-muted-foreground">{t('gen.workedVariantsHint')}</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={showBeamMap} onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} />
|
||
{t('gen.showBeam')}
|
||
</label>
|
||
|
||
{/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and
|
||
shows a two-column callsign helper (partial matches + one-edit calls). */}
|
||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
||
{t('scp.enable')}
|
||
</label>
|
||
<p className="text-[11px] text-muted-foreground">{t('scp.settingsHint')}</p>
|
||
{scp.enabled && (
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
||
<ArrowDown className="size-3.5" /> {scpBusy ? t('scp.downloading') : t('scp.download')}
|
||
</Button>
|
||
<span className="text-xs text-muted-foreground">
|
||
{scp.count > 0
|
||
? t('scp.loaded', { n: scp.count.toLocaleString(), date: scp.updated ? new Date(scp.updated).toLocaleDateString() : '?' })
|
||
: t('scp.notLoaded')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
||
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={showQsoRate} onCheckedChange={(c) => { const v = !!c; setShowQsoRate(v); writeUiPref('opslog.showQsoRate', v ? '1' : '0'); }} />
|
||
{t('gen.showQsoRate')} <span className="text-xs text-muted-foreground">{t('gen.showQsoRateHint')}</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
||
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
||
</label>
|
||
<TelemetryToggle />
|
||
|
||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} yaesuAvailable={yaesuAvailable} />
|
||
|
||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||
<h4 className="text-sm font-semibold text-foreground">{t('gen.pwEnc')}</h4>
|
||
{secret.has_passphrase ? (
|
||
<>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t('gen.pwEncrypted')}{' '}
|
||
{secret.unlocked
|
||
? <span className="text-success font-medium">{t('gen.pwUnlocked')}</span>
|
||
: <span className="text-warning font-medium">{t('gen.pwLocked')}</span>}.
|
||
</p>
|
||
{secret.unlocked && (
|
||
<>
|
||
<div className="flex items-center gap-2 max-w-md">
|
||
<Input type="password" placeholder={t('gen.pwNewPh')} value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
|
||
<Input type="password" placeholder={t('gen.pwConfirm')} value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
|
||
<Button size="sm" disabled={!ppNew || ppBusy} onClick={applyPassphrase}>{t('gen.pwChange')}</Button>
|
||
</div>
|
||
<Button variant="outline" size="sm" disabled={ppBusy} onClick={removePassphrase}>{t('gen.pwRemove')}</Button>
|
||
</>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t('gen.pwHint')}
|
||
</p>
|
||
<div className="flex items-center gap-2 max-w-md">
|
||
<Input type="password" placeholder={t('gen.pwPassphrase')} value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
|
||
<Input type="password" placeholder={t('gen.pwConfirm')} value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
|
||
<Button size="sm" disabled={!ppNew || ppNew !== ppConfirm || ppBusy} onClick={applyPassphrase}>{t('gen.pwEncrypt')}</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
{ppErr && <div className="text-xs text-destructive">{ppErr}</div>}
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||
<h4 className="text-sm font-semibold text-foreground">{t('gen.clubTitle')}</h4>
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={clubInfo.enabled}
|
||
onCheckedChange={async (c) => {
|
||
const v = !!c; setClubInfo((s) => ({ ...s, enabled: v })); setClubErr('');
|
||
try {
|
||
await SetClublogCtyEnabled(v);
|
||
let info = (await GetClublogCtyInfo()) as ClubInfo;
|
||
// First enable with no cached data → download it now.
|
||
if (v && !info.loaded) {
|
||
setClubBusy(true);
|
||
try { info = (await DownloadClublogCty()) as ClubInfo; }
|
||
finally { setClubBusy(false); }
|
||
}
|
||
setClubInfo(info);
|
||
} catch (e: any) { setClubErr(String(e?.message ?? e)); }
|
||
}}
|
||
className="mt-0.5"
|
||
/>
|
||
<span>
|
||
{t('gen.clubUse')}
|
||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||
{t('gen.clubDesc')}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<div className="flex items-center gap-3 pl-6">
|
||
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
||
onClick={() => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); }}>
|
||
{clubBusy ? t('gen.downloading') : (clubInfo.loaded ? t('gen.clubUpdate') : t('gen.clubDownload'))}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{clubInfo.loaded
|
||
? t('gen.clubCount', { n: clubInfo.count.toLocaleString() }) + (clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : '')
|
||
: t('gen.notDownloaded')}
|
||
</span>
|
||
</div>
|
||
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
|
||
</div>
|
||
|
||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||
<h4 className="text-sm font-semibold text-foreground">{t('gen.mwTitle')}</h4>
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox
|
||
checked={mwInfo.enabled}
|
||
onCheckedChange={async (c) => {
|
||
const v = !!c; setMwInfo((s) => ({ ...s, enabled: v })); setMwErr('');
|
||
try {
|
||
await SetClublogMostWantedEnabled(v);
|
||
let info = (await GetClublogMostWantedInfo()) as MWInfo;
|
||
// First enable with no cached list → fetch it now (for the active call).
|
||
if (v && !info.loaded) {
|
||
setMwBusy(true);
|
||
try { info = (await DownloadClublogMostWanted()) as MWInfo; }
|
||
finally { setMwBusy(false); }
|
||
}
|
||
setMwInfo(info);
|
||
} catch (e: any) { setMwErr(String(e?.message ?? e)); }
|
||
}}
|
||
className="mt-0.5"
|
||
/>
|
||
{/* No explanatory line: every operator knows what Most Wanted is. */}
|
||
<span>{t('gen.mwShow')}</span>
|
||
</label>
|
||
<div className="flex items-center gap-3 pl-6">
|
||
<Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
|
||
onClick={() => { setMwBusy(true); setMwErr(''); DownloadClublogMostWanted().then((i) => setMwInfo(i as MWInfo)).catch((e: any) => setMwErr(String(e?.message ?? e))).finally(() => setMwBusy(false)); }}>
|
||
{mwBusy ? t('gen.downloading') : (mwInfo.loaded ? t('gen.mwUpdate') : t('gen.mwDownload'))}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{mwInfo.loaded
|
||
? t('gen.mwCount', { n: mwInfo.count.toLocaleString() }) + (mwInfo.callsign ? ' · ' + mwInfo.callsign : '') + (mwInfo.date ? ' · ' + mwInfo.date : '')
|
||
: t('gen.notDownloaded')}
|
||
</span>
|
||
</div>
|
||
{mwErr && <p className="text-[11px] text-destructive pl-6">{mwErr}</p>}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function EmailPanel() {
|
||
return (
|
||
<>
|
||
<SectionHeader title={t('email.title')}/>
|
||
<div className="space-y-3 max-w-2xl">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={emailCfg.enabled} onCheckedChange={(c) => setEmailField({ enabled: !!c })} />
|
||
Enable e-mail sending
|
||
</label>
|
||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||
<Label className="text-sm">SMTP server</Label>
|
||
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
|
||
<Label className="text-sm">Port / encryption</Label>
|
||
<div className="flex gap-2 items-center">
|
||
<PortInput className="h-8 w-24 font-mono" value={emailCfg.smtp_port} fallback={587} onChange={(n) => setEmailField({ smtp_port: n })} />
|
||
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
|
||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="starttls">STARTTLS (587)</SelectItem>
|
||
<SelectItem value="ssl">SSL/TLS (465)</SelectItem>
|
||
<SelectItem value="none">{t('em.none')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div />
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={emailCfg.auth} onCheckedChange={(c) => setEmailField({ auth: !!c })} />
|
||
{t('em.smtpAuth')}
|
||
</label>
|
||
<Label className="text-sm">{t('em.username')}</Label>
|
||
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
||
<Label className="text-sm">{t('es.password')}</Label>
|
||
{/* No reveal button: the settings window is often open while the
|
||
screen is shared or shown to someone in the shack. */}
|
||
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||
<Label className="text-sm">{t('em.fromAddr')}</Label>
|
||
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||
<Label className="text-sm">{t('em.replyTo')}</Label>
|
||
<div>
|
||
<Input className="h-8" placeholder={t('em.replyToPh')} value={emailCfg.reply_to} onChange={(e) => setEmailField({ reply_to: e.target.value })} />
|
||
<div className="text-[10px] text-muted-foreground mt-1">{t('em.replyToHint')}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" className="h-8"
|
||
onClick={() => { setEmailMsg(t('em.sendingTest')); SaveEmailSettings(emailCfg as any).then(() => TestEmail('')).then(() => setEmailMsg(t('em.testSent'))).catch((e: any) => setEmailMsg(t('em.testFailed') + String(e?.message ?? e))); }}>
|
||
{t('em.sendTest')}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
|
||
</div>
|
||
|
||
<div className="pt-2 mt-2 border-t border-border space-y-2">
|
||
<Label className="text-sm font-semibold">{t('em.recEmail')}</Label>
|
||
<div className="text-[11px] text-muted-foreground">
|
||
{t('em.recVarsHint')} {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
|
||
</div>
|
||
<Input className="h-8" placeholder={t('em.subject')} value={emailCfg.subject}
|
||
onChange={(e) => setEmailField({ subject: e.target.value })} />
|
||
<Textarea rows={3} className="text-sm" placeholder={t('em.body')} value={emailCfg.body}
|
||
onChange={(e) => setEmailField({ body: e.target.value })} />
|
||
</div>
|
||
|
||
<div className="pt-2 mt-2 border-t border-border space-y-2">
|
||
<Label className="text-sm font-semibold">{t('em.qslCardEmail')}</Label>
|
||
<div className="text-[11px] text-muted-foreground">
|
||
{t('em.qslVarsHint')} {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
|
||
</div>
|
||
<Input className="h-8" placeholder={t('em.subject')} value={eqslCfg.subject}
|
||
onChange={(e) => setEqslField({ subject: e.target.value })} />
|
||
<Textarea rows={3} className="text-sm" placeholder={t('em.body')} value={eqslCfg.body}
|
||
onChange={(e) => setEqslField({ body: e.target.value })} />
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
|
||
{t('em.autoSend')}
|
||
</label>
|
||
<div className="text-[11px] text-muted-foreground">
|
||
{t('em.autoSendHint')}
|
||
</div>
|
||
<div className="pt-2 space-y-1">
|
||
<Label className="text-sm">{t('em.qslDefaultMsg')}</Label>
|
||
<Input className="h-8" placeholder={t('em.qslDefaultMsgPh')} value={eqslCfg.default_message ?? ''}
|
||
onChange={(e) => setEqslField({ default_message: e.target.value })} />
|
||
<div className="text-[11px] text-muted-foreground">{t('em.qslDefaultMsgHint')}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function USCountiesPanel() {
|
||
const loaded = ulsStatus.count > 0;
|
||
return (
|
||
<div className="max-w-2xl space-y-5">
|
||
<div>
|
||
<h3 className="text-sm font-semibold mb-1">{t('uscty.title')}</h3>
|
||
<p className="text-xs text-muted-foreground leading-relaxed">{t('uscty.intro')}</p>
|
||
</div>
|
||
|
||
{/* Required-download notice */}
|
||
<div className="rounded-md border border-warning/40 bg-warning/10 p-3 text-xs text-foreground/90 leading-relaxed">
|
||
{t('uscty.needDownload')}
|
||
</div>
|
||
|
||
{/* Status + download */}
|
||
<div className="rounded-md border border-border p-3 space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-xs">
|
||
<div className="font-medium">{t('uscty.dbStatus')}</div>
|
||
<div className="text-muted-foreground">
|
||
{loaded
|
||
? t('uscty.loaded', { n: ulsStatus.count.toLocaleString(), date: ulsStatus.updated_at ? new Date(ulsStatus.updated_at).toLocaleDateString() : '—' })
|
||
: t('uscty.notLoaded')}
|
||
</div>
|
||
</div>
|
||
<Button size="sm" onClick={downloadUls} disabled={ulsBusy}>
|
||
{ulsBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : <ArrowDown className="size-3.5 mr-1.5" />}
|
||
{loaded ? t('uscty.update') : t('uscty.download')}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* A database downloaded by an older OpsLog still holds Connecticut's
|
||
planning regions instead of its counties. The download date cannot
|
||
show that, so say it outright. */}
|
||
{loaded && ulsStatus.needs_refresh && !ulsBusy && (
|
||
<div className="text-[11px] rounded border border-warning-border bg-warning-muted text-warning-muted-foreground px-2 py-1.5 leading-relaxed">
|
||
{t('uscty.stale')}
|
||
</div>
|
||
)}
|
||
|
||
{ulsProgress && (
|
||
<div className="space-y-1">
|
||
<div className="text-[11px] text-muted-foreground flex justify-between">
|
||
<span>{ulsProgress.stage}</span><span>{ulsProgress.pct}%</span>
|
||
</div>
|
||
<div className="h-1.5 w-full rounded bg-muted overflow-hidden">
|
||
<div className="h-full bg-primary transition-all" style={{ width: `${ulsProgress.pct}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{ulsMsg && (
|
||
<div className={cn('text-xs', ulsMsg.ok ? 'text-success' : 'text-destructive')}>{ulsMsg.text}</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Backfill existing QSOs */}
|
||
<div className="rounded-md border border-border p-3 space-y-2">
|
||
<div className="text-xs font-medium">{t('uscty.backfillTitle')}</div>
|
||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('uscty.backfillIntro')}</p>
|
||
<div className="flex items-center gap-3">
|
||
<Button size="sm" variant="secondary" onClick={runBackfill} disabled={backfillBusy || !loaded}>
|
||
{backfillBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||
{t('uscty.backfillRun')}
|
||
</Button>
|
||
{backfillMsg && <span className="text-xs text-muted-foreground">{backfillMsg}</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// One line per database: what it holds, when it was refreshed, and the button
|
||
// that refreshes it. Every one of these already had an updater somewhere —
|
||
// buried in the Awards manager, in a corner of the Lookup page, behind the
|
||
// US Counties section — and an operator wanting them all current had to know
|
||
// where each one lived. The point of this page is that they are in one place,
|
||
// named, with their own line.
|
||
function DatabasesPanel() {
|
||
const rows: Array<{
|
||
key: string; name: string; detail: string; busy: boolean; run?: () => void; note?: string;
|
||
}> = [
|
||
{
|
||
key: 'cty', name: t('db.cty'),
|
||
detail: ctyInfo?.entities ? t('db.ctyDetail', { n: ctyInfo.entities, d: fmtDay(ctyInfo.file_mod_time) }) : t('db.never'),
|
||
busy: ctyBusy, run: refreshCty,
|
||
},
|
||
{
|
||
key: 'clublog', name: t('db.clublog'),
|
||
detail: clubInfo?.count ? t('db.clublogDetail', { n: clubInfo.count, d: clubInfo.date || '—' }) : t('db.never'),
|
||
busy: clubBusy,
|
||
run: () => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); },
|
||
},
|
||
{
|
||
key: 'lotw', name: t('db.lotwUsers'),
|
||
detail: lotwUsers?.count ? t('db.lotwDetail', { n: lotwUsers.count, d: fmtDay(lotwUsers.updated) }) : t('db.never'),
|
||
busy: lotwUsersBusy, run: downloadLotwUsers,
|
||
},
|
||
{
|
||
key: 'scp', name: t('db.scp'),
|
||
detail: scp?.count ? t('db.scpDetail', { n: scp.count, d: fmtDay(scp.updated) }) : t('db.never'),
|
||
busy: scpBusy, run: downloadScp,
|
||
},
|
||
{
|
||
key: 'uls', name: t('db.uls'),
|
||
detail: ulsStatus?.count ? t('db.ulsDetail', { n: ulsStatus.count, d: fmtDay(ulsStatus.updated_at) }) : t('db.never'),
|
||
busy: ulsBusy, run: downloadUls,
|
||
},
|
||
{
|
||
key: 'rda', name: t('db.rda'),
|
||
detail: t('db.rdaDetail', { n: rdaCount.toLocaleString() }),
|
||
busy: false, note: t('db.rdaNote'),
|
||
},
|
||
];
|
||
return (
|
||
<div className="space-y-4 max-w-3xl">
|
||
<SectionHeader title={t('sec.databases')} hint={t('db.hint')} />
|
||
<div className="rounded-md border border-border divide-y divide-border">
|
||
{rows.map((r) => (
|
||
<div key={r.key} className="flex items-center gap-3 p-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-medium">{r.name}</div>
|
||
<div className="text-[11px] text-muted-foreground">{r.detail}</div>
|
||
</div>
|
||
{r.run ? (
|
||
<Button size="sm" variant="secondary" onClick={r.run} disabled={r.busy}>
|
||
{r.busy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||
{t('db.update')}
|
||
</Button>
|
||
) : (
|
||
<span className="text-[11px] text-muted-foreground italic shrink-0">{r.note}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* The award reference lists. Their own block because they are one kind
|
||
of thing with one updater each, and because the list depends on which
|
||
awards are defined — a shared WAPC brought in by an operator appears
|
||
here the day it can be updated. */}
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-medium">{t('db.refLists')}</div>
|
||
<p className="text-[11px] text-muted-foreground">{t('db.refListsHint')}</p>
|
||
<div className="rounded-md border border-border divide-y divide-border">
|
||
{refMeta.filter((m) => m.can_update).map((m) => (
|
||
<div key={m.code} className="flex items-center gap-3 p-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-medium font-mono">{m.code}</div>
|
||
<div className="text-[11px] text-muted-foreground">
|
||
{m.count ? t('db.refDetail', { n: m.count, d: fmtDay(m.updated_at) }) : t('db.never')}
|
||
</div>
|
||
</div>
|
||
<Button size="sm" variant="secondary" disabled={refBusy === m.code}
|
||
onClick={() => updateRefList(m.code)}>
|
||
{refBusy === m.code ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||
{t('db.update')}
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{refMeta.filter((m) => m.can_update).length === 0 && (
|
||
<div className="p-3 text-[11px] text-muted-foreground">{t('db.noRefLists')}</div>
|
||
)}
|
||
</div>
|
||
{refMsg && <p className="text-[11px] text-muted-foreground">{refMsg}</p>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Russian districts: the offline RDA database and the backfill it feeds.
|
||
//
|
||
// Its own section rather than a corner of the Awards manager: it is a
|
||
// database and a bulk operation on the log, which is what every other item in
|
||
// this part of the sidebar is.
|
||
function RDAPanel() {
|
||
return (
|
||
<div className="space-y-4 max-w-2xl">
|
||
<SectionHeader title={t('sec.rda')} hint={t('rda.hint')} />
|
||
<div className="rounded-md border border-border p-3 space-y-1">
|
||
<div className="text-xs font-medium">{t('rda.dbTitle')}</div>
|
||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||
{t('rda.dbCount', { n: rdaCount.toLocaleString() })}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-md border border-border p-3 space-y-2">
|
||
<div className="text-xs font-medium">{t('rda.backfillTitle')}</div>
|
||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('rda.backfillIntro')}</p>
|
||
{/* The choice is the whole point of the panel, so it is a checkbox and
|
||
not a hidden default: a dated record is a fact, a current district
|
||
on a ten-year-old QSO is an assumption. */}
|
||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||
<Checkbox checked={rdaUseCurrent} className="mt-0.5" onCheckedChange={(c) => setRdaUseCurrent(!!c)} />
|
||
<span>{t('rda.useCurrent')} <span className="text-muted-foreground">{t('rda.useCurrentHint')}</span></span>
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
<Button size="sm" variant="secondary" onClick={runRDABackfill} disabled={rdaBusy}>
|
||
{rdaBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||
{rdaBusy ? t('rda.backfillRunning') : t('rda.backfillRun')}
|
||
</Button>
|
||
{rdaMsg && <span className="text-xs text-muted-foreground">{rdaMsg}</span>}
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">{t('rda.neverOverwrites')}</p>
|
||
</div>
|
||
|
||
{/* Comparing the two sources. Read-only: it answers a question about the
|
||
log without touching it. */}
|
||
<div className="rounded-md border border-border p-3 space-y-2">
|
||
<div className="text-xs font-medium">{t('rda.cmpTitle')}</div>
|
||
<div className="flex items-center gap-3">
|
||
<Button size="sm" variant="secondary" onClick={runRDACompare} disabled={rdaCmpBusy}>
|
||
{rdaCmpBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||
{rdaCmpBusy ? t('rda.cmpRunning') : t('rda.cmpRun')}
|
||
</Button>
|
||
{!!rdaCmpMsg && <span className="text-xs text-muted-foreground">{rdaCmpMsg}</span>}
|
||
{rdaCmp && (
|
||
<span className="text-xs text-muted-foreground">
|
||
{t('rda.cmpDone', {
|
||
s: rdaCmp.scanned ?? 0, a: rdaCmp.agree ?? 0, d: rdaCmp.disagree ?? 0,
|
||
l: rdaCmp.only_log ?? 0, b: rdaCmp.only_db ?? 0,
|
||
})}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{/* The disagreements themselves. A count alone would say "there is a
|
||
problem" and leave the operator with no way to look at it. */}
|
||
{rdaCmp?.conflicts?.length > 0 && (
|
||
<>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
{t('rda.cmpListHint', { n: rdaCmp.conflicts.length, d: rdaCmp.disagree ?? 0 })}
|
||
</p>
|
||
{/* Tall enough to work through. It was capped at 224 px — about six
|
||
rows of a list that can hold two hundred — inside a panel that
|
||
does not scroll to reveal what the box could not show. */}
|
||
{/* NO overscroll-contain here. It stops the wheel from chaining to
|
||
the settings pane behind, so with a short list — nothing to
|
||
scroll inside the box — the pointer over the table froze the
|
||
whole panel. The box scrolls when it has to; the page scrolls
|
||
the rest of the time. */}
|
||
<div className="max-h-[28rem] overflow-y-auto rounded border border-border">
|
||
<table className="w-full text-[11px]">
|
||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
||
<tr>
|
||
<th className="py-1 px-2 font-medium">{t('rda.cmpCall')}</th>
|
||
<th className="py-1 pr-2 font-medium">{t('rda.cmpDate')}</th>
|
||
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromLog')}</th>
|
||
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromDb')}</th>
|
||
<th className="py-1 pr-2 font-medium">{t('rda.cmpKind')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rdaCmp.conflicts.map((c: any) => (
|
||
<tr key={c.qso_id} className="border-b border-border/30">
|
||
{/* The callsign opens the contact. A list of things to fix
|
||
that cannot be acted on is a list of things to write
|
||
down and look up again later. */}
|
||
<td className="py-1 px-2 font-mono font-semibold">
|
||
{onEditQSO ? (
|
||
<button type="button"
|
||
onClick={() => { onEditQSO(c.qso_id); onClose(); }}
|
||
title={t('rda.cmpOpen')}
|
||
className="underline decoration-dotted underline-offset-2 hover:text-primary">
|
||
{c.callsign}
|
||
</button>
|
||
) : c.callsign}
|
||
</td>
|
||
<td className="py-1 pr-2 font-mono">{c.date}</td>
|
||
<td className="py-1 pr-2 font-mono">{c.from_log}{c.confirmed ? ' ✓' : ''}</td>
|
||
<td className="py-1 pr-2 font-mono">{c.from_db}</td>
|
||
<td className="py-1 pr-2 text-muted-foreground">
|
||
{c.dated ? t('rda.cmpDated') : t('rda.cmpCurrent')}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Map sections to their content + icon (for placeholder).
|
||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||
general: GeneralPanel,
|
||
appearance: () => <AppearancePanel />,
|
||
email: EmailPanel,
|
||
station: StationPanel,
|
||
profiles: ProfilesPanel,
|
||
operating: OperatingPanelWrapper,
|
||
confirmations: ConfirmationsPanel,
|
||
'external-services': ExternalServicesPanel,
|
||
lookup: LookupPanel,
|
||
'lists-bands': BandsPanel,
|
||
'lists-modes': ModesPanel,
|
||
'lists-satellites': SatellitesPanel,
|
||
cluster: ClusterPanel,
|
||
udp: UDPIntegrationsPanelWrapper,
|
||
// Module-scope components, wrapped so their props can be passed. The nested
|
||
// panels below go through PanelHost instead — which is what now lets either
|
||
// kind hold hooks.
|
||
adifmon: () => <ADIFMonitorPanel />,
|
||
foldersync: () => <FolderSyncPanel />,
|
||
webpublish: () => <WebPublishPanel />,
|
||
relayauto: () => <RelayAutoPanel />,
|
||
backup: BackupPanel,
|
||
database: DatabasePanel,
|
||
uscounties: USCountiesPanel,
|
||
databases: DatabasesPanel,
|
||
autostart: () => <AutostartPanelComponent />,
|
||
// RDAPanel is CALLED, not written as <RDAPanel />.
|
||
//
|
||
// It is nested inside this component, so as an element it would be a new
|
||
// component TYPE on every render — React cannot know it is the same panel,
|
||
// so it unmounts the old tree and mounts a fresh one. A fresh scroll
|
||
// container starts at the top, which is what threw the district comparison
|
||
// back to the first row every three seconds. Calling it produces the same
|
||
// elements in place, and the scroll position is simply never disturbed.
|
||
// (Safe because RDAPanel holds no hooks of its own — see PanelHost.)
|
||
awards: () => (<div className="space-y-6"><AwardsSelectionPanel profile={activeProfile ?? undefined} />{RDAPanel()}</div>),
|
||
cat: CATPanel,
|
||
rotator: RotatorPanel,
|
||
winkeyer: WinkeyerPanel,
|
||
antenna: UltrabeamPanel,
|
||
antgenius: AntGeniusPanelSettings,
|
||
tunergenius: TunerGeniusPanelSettings,
|
||
psu: PSUPanelSettings,
|
||
pgxl: PGXLPanelSettings,
|
||
flex: () => (
|
||
<div className="space-y-6">
|
||
<FlexBandPanel bands={lists.bands ?? []} />
|
||
{/* Rendered here rather than inside FlexBandPanel so these keep writing
|
||
to the modal's own CAT settings, saved by its Save button. A panel
|
||
that saved them itself would be overwritten by that same Save a
|
||
moment later, using the values it had read on opening. */}
|
||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||
<h4 className="text-sm font-semibold text-foreground">{t('cat.flexOptions')}</h4>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={!!catCfg.flex_decode_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_decode_spots: !!c }))} />
|
||
{t('cat.flexDecodeSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSpotsHint')}</span>
|
||
</label>
|
||
{catCfg.flex_decode_spots && (
|
||
<div className="flex items-center gap-2 pl-6">
|
||
<Label className="text-sm">{t('cat.flexDecodeSecs')}</Label>
|
||
<Input type="number" className="w-24" min={10} max={3600}
|
||
value={catCfg.flex_decode_secs || 120}
|
||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} />
|
||
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
||
</div>
|
||
)}
|
||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||
<Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
|
||
<span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Auto-zoom on a spot click. Its own block: it changes the RADIO's
|
||
display, where everything above changes what OpsLog draws on it. */}
|
||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||
<h4 className="text-sm font-semibold text-foreground">{t('flexzoom.title')}</h4>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={flexZoom.enabled} onCheckedChange={(c) => saveFlexZoom({ ...flexZoom, enabled: !!c })} />
|
||
{t('flexzoom.enable')}
|
||
</label>
|
||
{flexZoom.enabled && (
|
||
<div className="pl-6 space-y-2">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
{([['cw_khz', 'flexzoom.cw'], ['ssb_khz', 'flexzoom.ssb'], ['digi_khz', 'flexzoom.digi']] as const).map(([k, lbl]) => (
|
||
<label key={k} className="flex items-center gap-1.5 text-xs">
|
||
<span className="text-muted-foreground">{t(lbl)}</span>
|
||
<Input type="number" min={1} max={14000} className="h-8 w-20"
|
||
value={String((flexZoom as any)[k] ?? '')}
|
||
onChange={(e) => saveFlexZoom({ ...flexZoom, [k]: parseInt(e.target.value, 10) || 0 } as any)} />
|
||
<span className="text-muted-foreground">kHz</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||
<Checkbox className="mt-0.5" checked={flexZoom.centre} onCheckedChange={(c) => saveFlexZoom({ ...flexZoom, centre: !!c })} />
|
||
<span>{t('flexzoom.centre')} <span className="text-muted-foreground">{t('flexzoom.centreHint')}</span></span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Spot colours, one row per status.
|
||
|
||
Saved on the spot rather than on the modal's Save: this is a table of
|
||
colours, and having to close a dialog to see what a colour looks like
|
||
on the waterfall turns picking one into a guessing game. */}
|
||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h4 className="text-sm font-semibold text-foreground">{t('spotcol.title')}</h4>
|
||
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground underline"
|
||
onClick={() => ResetSpotColors().then((c: any) => setSpotColors(c)).catch(() => {})}>
|
||
{t('spotcol.reset')}
|
||
</button>
|
||
</div>
|
||
{/* Whether spots are drawn at all comes first: colouring them is the
|
||
next question, and the two were on different pages. */}
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||
{t('cat.flexSpots')}
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={spotColors.enabled}
|
||
onCheckedChange={(c) => saveSpotColors({ ...spotColors, enabled: !!c })} />
|
||
{t('spotcol.enable')}
|
||
</label>
|
||
{spotColors.enabled && (
|
||
<div className="rounded-md border border-border divide-y divide-border max-w-2xl">
|
||
{SPOT_COLOR_ROWS.map((k) => {
|
||
const c = spotColors.colors[k] ?? { text: '', bg: '' };
|
||
return (
|
||
<div key={k} className="flex items-center gap-3 px-3 py-1.5">
|
||
{/* Sent to the radio at all. First in the row because it
|
||
decides whether the colours next to it mean anything. */}
|
||
<Checkbox checked={!c.hide} title={t('spotcol.send')}
|
||
onCheckedChange={(v) => setSpotColor(k, { hide: !v } as any)} />
|
||
<span className={cn('text-xs flex-1 min-w-0', c.hide && 'opacity-40 line-through')}>{t('spotcol.s_' + k)}</span>
|
||
{/* The preview is the point of the row: two colour boxes say
|
||
nothing about what the pair looks like together. */}
|
||
<span className="text-xs font-mono px-2 py-0.5 rounded"
|
||
style={{ color: argbToCss(c.text) || undefined, background: argbToCss(c.bg) || undefined }}>
|
||
DL1ABC
|
||
</span>
|
||
<ArgbInput label={t('spotcol.text')} value={c.text} onChange={(v) => setSpotColor(k, { text: v })} />
|
||
<ArgbInput label={t('spotcol.bg')} value={c.bg ?? ''} onChange={(v) => setSpotColor(k, { bg: v })} />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
),
|
||
audio: AudioPanel,
|
||
};
|
||
|
||
return (
|
||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('settings.title')}</DialogTitle>
|
||
<DialogDescription className="sr-only">Configure OpsLog modules — station, lookup, hardware…</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{loading ? (
|
||
<div className="p-6 text-muted-foreground">Loading…</div>
|
||
) : (
|
||
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
|
||
{/* Left sidebar tree */}
|
||
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
|
||
<Tree selected={selected} onSelect={setSelected} flexAvailable={flexAvailable} />
|
||
</aside>
|
||
|
||
{/* Right content pane */}
|
||
<div className="overflow-y-auto p-6">
|
||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
||
{breadcrumb}
|
||
</div>
|
||
<PanelHost key={selected} render={PANELS[selected]} />
|
||
|
||
{err && (
|
||
<div className="mt-6 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2 max-w-2xl">
|
||
{err}
|
||
</div>
|
||
)}
|
||
{msg && (
|
||
<div className="mt-6 text-xs rounded-md px-3 py-2 max-w-2xl text-[color:var(--color-ok)] bg-[color:var(--color-ok)]/10 border border-[color:var(--color-ok)]/30">
|
||
{msg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={onClose} disabled={saving}>{t('btn.cancel')}</Button>
|
||
<Button variant="outline" onClick={() => save(false)} disabled={saving || loading}>{saving ? t('btn.savingLong') : t('btn.save')}</Button>
|
||
<Button onClick={() => save(true)} disabled={saving || loading}>{saving ? t('btn.savingLong') : t('btn.saveClose')}</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export const SettingsModal = memo(SettingsModalImpl);
|
||
|
||
// PortInput — a TCP port field you can actually clear.
|
||
//
|
||
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
|
||
// the contents and parseInt gives NaN, so the default is written straight back:
|
||
// the digits reappear under the cursor and the only way to enter a different
|
||
// port is to overwrite character by character. Reported on the cluster editor;
|
||
// the same line existed in eight other places.
|
||
//
|
||
// The raw TEXT is the state here, and the parent is only told about a value that
|
||
// is actually a port. An empty box stays empty while you type; on blur it falls
|
||
// back to the last good value, so nothing is ever saved as 0 or NaN.
|
||
function PortInput({ value, onChange, fallback, className, min = 1, max = 65535 }: {
|
||
value: number; onChange: (n: number) => void; fallback: number; className?: string; min?: number; max?: number;
|
||
}) {
|
||
const [text, setText] = useState(value ? String(value) : '');
|
||
const typed = useRef(false);
|
||
// Only adopt the incoming value while the operator has NOT started typing —
|
||
// settings arrive from the backend after mount, and re-syncing mid-edit is
|
||
// precisely the behaviour being fixed.
|
||
useEffect(() => {
|
||
if (!typed.current) setText(value ? String(value) : '');
|
||
}, [value]);
|
||
return (
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
className={className}
|
||
value={text}
|
||
onChange={(e) => {
|
||
typed.current = true;
|
||
const digits = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
|
||
setText(digits);
|
||
const n = parseInt(digits, 10);
|
||
if (n >= min && n <= max) onChange(n);
|
||
}}
|
||
onBlur={() => {
|
||
typed.current = false;
|
||
const n = parseInt(text, 10);
|
||
if (!(n >= min && n <= max)) {
|
||
setText(String(fallback));
|
||
onChange(fallback);
|
||
}
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ClusterServerEditor edits one row of cluster_servers. Init commands are
|
||
// free-form (one per line); the backend strips blanks and "//" comments.
|
||
interface ClusterEditorProps {
|
||
value: Omit<clusterModels.ServerConfig, 'convertValues'>;
|
||
onCancel: () => void;
|
||
onSave: (s: Omit<clusterModels.ServerConfig, 'convertValues'>) => void | Promise<void>;
|
||
}
|
||
|
||
function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
|
||
const [s, setS] = useState(value);
|
||
const update = (patch: Partial<typeof s>) => setS((cur) => ({ ...cur, ...patch }));
|
||
return (
|
||
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||
<DialogContent className="max-w-[640px] px-6">
|
||
<DialogHeader className="px-2">
|
||
<DialogTitle>{s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'}</DialogTitle>
|
||
<DialogDescription className="text-xs">
|
||
Telnet endpoint + optional login override and init commands. Init commands are sent one per line, 0.5s apart, right after login.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="grid grid-cols-2 gap-3 py-2 px-2">
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>Display name</Label>
|
||
<Input autoFocus value={s.name} onChange={(e) => update({ name: e.target.value })} placeholder="VE7CC, F4BPO home…" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Host</Label>
|
||
<Input className="font-mono" value={s.host} onChange={(e) => update({ host: e.target.value })} placeholder="dxc.k0xm.net" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Port</Label>
|
||
<PortInput className="font-mono" value={s.port} fallback={7300} onChange={(n) => update({ port: n })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Login callsign (optional)</Label>
|
||
<Input className="font-mono uppercase" value={s.login_override} onChange={(e) => update({ login_override: e.target.value })} placeholder="Active profile if empty" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>Password (optional)</Label>
|
||
<Input type="password" value={s.password} onChange={(e) => update({ password: e.target.value })} autoComplete="off" />
|
||
</div>
|
||
<div className="space-y-1 col-span-2">
|
||
<Label>Init commands (one per line, // for comments)</Label>
|
||
<Textarea
|
||
className="font-mono text-xs min-h-[120px]"
|
||
value={s.init_commands}
|
||
onChange={(e) => update({ init_commands: e.target.value })}
|
||
placeholder={`// turn on DXCC info\nset/needsdxcc\nsh/dx 30`}
|
||
/>
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-2">
|
||
<Checkbox checked={s.enabled} onCheckedChange={(c) => update({ enabled: !!c })} />
|
||
Enabled (will be connected at startup if Auto-connect is on)
|
||
</label>
|
||
</div>
|
||
<DialogFooter className="px-2">
|
||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||
<Button
|
||
onClick={() => onSave({ ...s, name: s.name.trim(), host: s.host.trim(), login_override: s.login_override.trim().toUpperCase() })}
|
||
disabled={!s.name.trim() || !s.host.trim()}
|
||
>
|
||
{s.id ? 'Save changes' : 'Create cluster'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|