Files
OpsLog/frontend/src/components/SettingsModal.tsx
T
rouggy 638ffcb326 feat: per-profile separate SQLite logbook file (config never swapped)
Design flaw: the SQLite backend conflated the app/config database (opslog.db —
settings + profiles) with the QSO logbook. connectLogbook returned a.db for any
non-MySQL profile, so the ONLY way to get a separate SQLite logbook was the
whole-app "change database location" pointer — which swapped config + profiles
too, wiping the operator's setup (reported: creating Jerem.db lost all configs).

profile.ProfileDB gains a Path field: a non-empty path routes that profile's
logbook to its own SQLite file (db.Open creates + migrates it on first use),
while opslog.db keeps settings/profiles. connectLogbook opens the file; empty
path = shared db (unchanged default, backward compatible). MySQLSettings carries
sqlite_path; Get/Save wire it through.

UI: the Database panel gains a third backend option "SQLite — separate file"
with a file picker, and now clearly labels the shared application database
(settings + profiles) vs this profile's logbook, so the two are never confused.
2026-07-24 17:12:31 +02:00

5165 lines
248 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, 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,
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus,
DownloadULSCounties, ULSStatus, BackfillUSCounties,
ComputeStationInfo,
GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
type RotatorSettings = Omit<mainModels.RotatorSettings, '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: 'JS8', sent: '-10', rcvd: '-10' },
{ name: 'MSK144', sent: '+00', rcvd: '+00' },
{ name: 'JT65', sent: '-15', rcvd: '-15' },
{ name: 'JT9', sent: '-15', rcvd: '-15' },
{ name: 'Q65', sent: '-15', rcvd: '-15' },
{ name: 'FST4', sent: '-15', rcvd: '-15' },
{ name: 'FST4W', sent: '-15', rcvd: '-15' },
{ name: 'WSPR', sent: '-20', rcvd: '-20' },
{ name: 'RTTY', sent: '599', rcvd: '599' },
{ name: 'PSK31', sent: '599', rcvd: '599' },
{ name: 'PSK63', sent: '599', rcvd: '599' },
{ name: 'PSK125', sent: '599', rcvd: '599' },
{ name: 'OLIVIA', sent: '599', rcvd: '599' },
{ name: 'CONTESTI', sent: '599', rcvd: '599' },
{ name: 'MFSK', sent: '599', rcvd: '599' },
{ name: 'THROB', sent: '599', rcvd: '599' },
{ name: 'HELL', sent: '599', rcvd: '599' },
{ name: 'PACKET', sent: '599', rcvd: '599' },
{ name: 'PACTOR', sent: '599', rcvd: '599' },
{ name: 'VARA', sent: '599', rcvd: '599' },
{ name: 'VARA HF', sent: '599', rcvd: '599' },
{ name: 'ARDOP', sent: '599', rcvd: '599' },
{ name: 'ATV', sent: '59', rcvd: '59' },
{ name: 'SSTV', sent: '59', rcvd: '59' },
{ name: 'C4FM', sent: '59', rcvd: '59' },
{ name: 'DSTAR', sent: '59', rcvd: '59' },
{ name: 'DMR', sent: '59', rcvd: '59' },
{ name: 'FUSION', sent: '59', rcvd: '59' },
];
const emptyProfile = (): Profile => ({
id: 0,
name: '',
callsign: '', operator: '', op_name: '', owner_callsign: '',
my_grid: '', my_country: '',
my_state: '', my_cnty: '',
my_street: '', my_city: '', my_postal_code: '',
my_sota_ref: '', my_pota_ref: '',
my_rig: '', my_antenna: '',
tx_pwr: undefined,
is_active: false,
sort_order: 0,
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
// Server-managed timestamps — send null (NOT "") so Go's time.Time unmarshal
// leaves the zero value instead of failing to parse an empty RFC3339 string.
created_at: null as any,
updated_at: null as any,
});
interface Props {
initialSection?: string;
onClose: () => void;
onSaved: () => void;
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
}
// Pretty little card showing what OpsLog will stamp on each QSO based on
// the callsign + grid in the Station Information form. Debounces the
// backend resolver so we don't fire on every keystroke; refreshes when
// inputs change. Empty card when no callsign yet.
/* ====== Tree definition ======
Section IDs are stable strings — adding new ones means adding a panel below.
`disabled: true` greys them out and shows the "coming soon" placeholder. */
type SectionId =
| 'general'
| 'email'
| 'station'
| 'profiles'
| 'operating'
| 'confirmations'
| 'external-services'
| 'udp'
| 'adifmon'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
| 'cluster'
| 'backup'
| 'database'
| 'autostart'
| 'uscounties'
| 'awards'
| 'cat'
| 'rotator'
| 'winkeyer'
| 'antenna'
| 'antgenius'
| 'pgxl'
| 'flex'
| 'relayauto'
| 'audio';
type TreeNode =
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
// buildTree returns the settings sidebar. The FlexRadio item only appears when
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] {
const hardware: TreeNode[] = [
{ kind: 'item', label: t('sec.cat'), id: 'cat' },
{ kind: 'item', label: t('sec.rotator'), id: 'rotator' },
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
{ kind: 'item', label: t('sec.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.external'), id: 'external-services' },
],
},
{
kind: 'group', label: t('nav.software'), icon: Cog, defaultOpen: true, children: [
{ kind: 'item', label: t('sec.general'), id: 'general' },
{ kind: 'item', label: t('sec.email'), id: 'email' },
{ kind: 'item', label: t('sec.lookup'), id: 'lookup' },
{ kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [
{ kind: 'item', label: t('sec.bands'), id: 'lists-bands' },
{ kind: 'item', label: t('sec.modes'), id: 'lists-modes' },
]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
{ kind: 'item', label: t('sec.database'), id: 'database' },
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
],
},
{
kind: 'group', label: t('nav.hardware'), icon: Server, defaultOpen: true, children: hardware,
},
];
}
// Map section id → i18n key (breadcrumb / placeholders).
const SECTION_KEY: Partial<Record<SectionId, string>> = {
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
adifmon: 'sec.adifmon',
uscounties: 'sec.uscounties',
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
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',
cluster: 'DX Cluster',
backup: 'Database backup',
database: 'Database',
autostart: 'Autostart',
udp: 'UDP integrations',
adifmon: 'ADIF monitor',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
winkeyer: 'CW Keyer',
antenna: 'Ultrabeam / Steppir',
antgenius: 'Antenna 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.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' },
'dim-slate': { bg: '#343b47', card: '#3d4552', accent: '#fb923c' },
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
};
function ThemeSwatch({ theme }: { theme: Exclude<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>
{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>
);
}
// 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 };
// 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'];
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 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's left and right
// panes show, independently: the great-circle map, the locator street map, the
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
const { t } = useI18n();
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
const options = [
...MAIN_PANE_VALUES,
...(flexAvailable ? ['flex'] : []),
...(icomAvailable ? ['icom'] : []),
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
.sort((a, b) => a.label.localeCompare(b.label));
useEffect(() => {
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
}, []);
const pick = (side: 'left' | 'right', v: string) => {
if (side === 'left') setLeft(v); else setRight(v);
// Persist (per-profile) AND tell the parent the new value directly, so the
// Main view updates from the chosen value — never a stale DB re-read.
SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {});
onChanged?.(side, v);
};
return (
<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">
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
<Select value={left} onValueChange={(v) => pick('left', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
<Select value={right} onValueChange={(v) => pick('right', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
</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>
);
}
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>
);
}
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
// when the band changes (frequency change / spot click). Antennas come live from
// the connected FlexRadio; bands come from Lists → Bands.
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
const [rxList, setRxList] = useState<string[]>([]);
const [txList, setTxList] = useState<string[]>([]);
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({});
const [msg, setMsg] = useState('');
useEffect(() => {
GetFlexState().then((s: any) => {
setRxList((s?.ant_list ?? []) as string[]);
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
}).catch(() => {});
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {});
}, []);
const set = (band: string, side: 'rx' | 'tx', v: string) => {
const key = band.toUpperCase();
setMap((m) => {
const cur = m[key] ?? { rx: '', tx: '' };
const next = { ...m, [key]: { ...cur, [side]: v } };
SaveFlexBandAntennas(next as any)
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
.catch((e: any) => setMsg(String(e?.message ?? e)));
return next;
});
};
return (
<div className="space-y-3">
<div>
<h3 className="text-base font-semibold text-foreground">FlexRadio per-band antennas</h3>
<p className="text-xs text-muted-foreground">
Choose the RX and TX antenna for each band. They're applied automatically when the band
changes (frequency change or clicking a spot).
</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-xl">
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
</div>
)}
{bands.length === 0 ? (
<p className="text-xs text-muted-foreground">No bands configured — add them in Lists → Bands.</p>
) : (
<div className="rounded-md border border-border overflow-hidden max-w-xl">
<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">Band</th>
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th>
</tr>
</thead>
<tbody>
{bands.map((b) => {
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
return (
<tr key={b} className="border-t border-border/50">
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
<td className="px-3 py-1.5">
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value="">— none —</option>
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
<td className="px-3 py-1.5">
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value="">— none —</option>
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
</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.
const ICOM_MODELS: { name: string; addr: number }[] = [
{ name: 'IC-705', addr: 0xA4 },
{ name: 'IC-7300', addr: 0x94 },
{ name: 'IC-7610', addr: 0x98 },
{ name: 'IC-7700', addr: 0x88 },
{ name: 'IC-7800', addr: 0x80 },
{ name: 'IC-9100', addr: 0x7C },
{ name: 'IC-9700', addr: 0xA2 },
];
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
const { t } = useI18n();
const [selected, setSelected] = useState<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,
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: [] });
// 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, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
});
const [rotator, setRotator] = useState<RotatorSettings>({
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
} as any);
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; tx_inhibit: boolean; 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, tx_inhibit: false, 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 }>({ enabled: false, host: '', password: '' });
// 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[]>([]);
// WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string };
type WKSettings = {
enabled: boolean; engine: string; esc_clears_call: boolean;
port: string; baud: number; wpm: number; weight: number;
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
use_ptt: boolean; serial_echo: boolean; cw_key_line: string; cw_invert: boolean; macros: WKMac[];
};
const [wk, setWk] = useState<WKSettings>({
enabled: false, engine: 'winkeyer', esc_clears_call: true,
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
});
const [wkPorts, setWkPorts] = useState<string[]>([]);
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;
};
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,
});
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 (F1F6).
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');
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 [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 [showSmtpPass, setShowSmtpPass] = useState(false);
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 };
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false });
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;
};
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: '',
});
// External services (logbook upload). One block per service; only QRZ is
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
type ExtServiceCfg = {
api_key: string; email: string; username: string; password: string; callsign: string;
code: string; qth_nickname: string;
force_station_callsign: string;
tqsl_path: string; station_location: string; key_password: string;
upload_flags: string[]; write_log: boolean;
auto_upload: boolean; upload_mode: string;
};
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
upload_flags: ['N', 'R'], write_log: false,
auto_upload: false, upload_mode: 'immediate',
});
const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
});
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [qrzTesting, setQrzTesting] = useState(false);
const [clublogTest, setClublogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [clublogTesting, setClublogTesting] = useState(false);
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [lotwTesting, setLotwTesting] = useState(false);
// 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); }
};
// US Counties (offline FCC ULS) — download progress arrives via events.
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ 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) }); }
};
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 [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 because
// PANELS[selected]() is called as a function, so panels can't hold hooks.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
const [potaToken, setPotaToken] = useState('');
const [potaBusy, setPotaBusy] = useState(false);
const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string; unmatched?: any[] } | null>(null);
useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []);
const [backupCfg, setBackupCfg] = useState<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 }>({ 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);
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
const [editingServer, setEditingServer] = useState<ClusterServer | null>(null);
async function reloadClusterServers() {
try {
const [list, ac, st] = await Promise.all([
ListClusterServers(),
GetClusterAutoConnect(),
GetClusterStatus(),
]);
setClusterServers((list ?? []) as ClusterServer[]);
setClusterAutoConnectState(ac);
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Live cluster status updates while Preferences is open — the user can
// click Connect/Disconnect inside the modal and see the pills change
// without saving + reopening.
useEffect(() => {
const unsub = EventsOn('cluster:state', async (st: any) => {
setClusterStatuses((st ?? []) as ClusterServerStatus[]);
try {
const list = await ListClusterServers();
setClusterServers((list ?? []) as ClusterServer[]);
} catch {}
});
return () => { unsub?.(); };
}, []);
const [profiles, setProfiles] = useState<Profile[]>([]);
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
// the panel as a plain function, not as a JSX element, so any useState
// inside the panel function would violate the Rules of Hooks.
const [profileSelectedId, setProfileSelectedId] = useState<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(),
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
]);
setLookup(l);
setActiveProfile(ap as Profile);
setLists(ls);
setRstText({
phone: ((ls as any).rst_phone ?? []).join(' '),
cw: ((ls as any).rst_cw ?? []).join(' '),
digital: ((ls as any).rst_digital ?? []).join(' '),
});
await reloadProfiles();
await reloadClusterServers();
setCatCfg(c);
setRotator(r);
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { 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 { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
reloadAudioDevices();
reloadDvk();
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setLoading(false);
}
})();
}, []);
// Every setting is per-profile, so when the active profile changes WHILE this
// dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…)
// — otherwise they keep showing the previous profile's values until reopen.
useEffect(() => {
const off = EventsOn('profile:changed', () => {
(async () => {
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotator(await GetRotatorSettings() as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { 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 { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
try {
const ls: any = await GetListsSettings();
setLists(ls);
setRstText({
phone: (ls.rst_phone ?? []).join(' '),
cw: (ls.rst_cw ?? []).join(' '),
digital: (ls.rst_digital ?? []).join(' '),
});
} catch {}
})();
});
return () => { off(); };
}, []);
// Auto-fill the active profile's MY_* DXCC metadata from the station
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
// are derived values, so they always recompute when the callsign or grid
// changes — the user can still edit a field, it just re-populates when the
// source changes. Debounced so we don't hammer cty.dat while typing.
useEffect(() => {
const call = (activeProfile?.callsign ?? '').trim();
if (!call) return;
const grid = (activeProfile?.my_grid ?? '').trim();
const t = window.setTimeout(async () => {
try {
const i: any = await ComputeStationInfo(call, grid);
setActiveProfile((p) => {
if (!p) return p;
const patch: any = {};
if (i.country) patch.my_country = i.country;
if (i.dxcc) patch.my_dxcc = i.dxcc;
if (i.cqz) patch.my_cqz = i.cqz;
if (i.ituz) patch.my_ituz = i.ituz;
if (i.lat) patch.my_lat = i.lat;
if (i.lon) patch.my_lon = i.lon;
// Only re-render when a value actually changed (prevents loops).
const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]);
return changed ? { ...p, ...patch } : p;
});
} catch { /* offline / unknown prefix — leave fields as-is */ }
}, 250);
return () => window.clearTimeout(t);
}, [activeProfile?.callsign, activeProfile?.my_grid]);
// ── Band selection helpers (dual-list shuttle) ──────────────────────────
function addBand(tag: string) {
const b = tag.trim().toLowerCase();
if (!b) return;
setLists((l) => {
if ((l.bands ?? []).includes(b)) return l;
return { ...l, bands: [...(l.bands ?? []), b] };
});
}
function removeBand(i: number) {
setLists((l) => {
const next = [...(l.bands ?? [])];
next.splice(i, 1);
return { ...l, bands: next };
});
}
function moveBand(i: number, dir: -1 | 1) {
setLists((l) => {
const next = [...(l.bands ?? [])];
const j = i + dir;
if (j < 0 || j >= next.length) return l;
[next[i], next[j]] = [next[j], next[i]];
return { ...l, bands: next };
});
}
// ── Mode helpers ────────────────────────────────────────────────────────
function addMode() {
setLists((l) => ({
...l,
modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
}));
}
function addModeFromCatalog(m: { name: string; sent: string; rcvd: string }) {
setLists((l) => {
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === m.name)) return l;
return {
...l,
modes: [...(l.modes ?? []), { name: m.name, default_rst_sent: m.sent, default_rst_rcvd: m.rcvd }],
};
});
}
function addCustomMode(name: string) {
const n = name.trim().toUpperCase();
if (!n) return;
setLists((l) => {
if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === n)) return l;
return {
...l,
modes: [...(l.modes ?? []), { name: n, default_rst_sent: '59', default_rst_rcvd: '59' }],
};
});
}
function removeMode(i: number) {
setLists((l) => {
const next = [...(l.modes ?? [])];
next.splice(i, 1);
return { ...l, modes: next };
});
}
function moveMode(i: number, dir: -1 | 1) {
setLists((l) => {
const next = [...(l.modes ?? [])];
const j = i + dir;
if (j < 0 || j >= next.length) return l;
[next[i], next[j]] = [next[j], next[i]];
return { ...l, modes: next };
});
}
function updateMode(i: number, patch: Partial<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),
} as any);
if (activeProfile) {
await SaveProfile({
...activeProfile,
callsign: (activeProfile.callsign ?? '').trim().toUpperCase(),
operator: (activeProfile.operator ?? '').trim().toUpperCase(),
my_grid: (activeProfile.my_grid ?? '').trim().toUpperCase(),
my_sota_ref: (activeProfile.my_sota_ref ?? '').trim().toUpperCase(),
my_pota_ref: (activeProfile.my_pota_ref ?? '').trim().toUpperCase(),
} as any);
}
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await 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);
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>
</>
);
}
// 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() {
// 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>
</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>
<Input
type="number" min={1} max={3650}
value={lookup.cache_ttl_days}
onChange={(e) => setLookup((s) => ({ ...s, cache_ttl_days: parseInt(e.target.value) || 30 }))}
/>
</div>
<Button variant="outline" onClick={clearCache} disabled={clearing}>
{clearing ? t('lk.clearing') : t('lk.clearCache')}
</Button>
</div>
</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 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>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('cat.backend')}</Label>
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
</SelectContent>
</Select>
</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 === '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>
<Input type="number" value={catCfg.flex_port || 4992}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
</div>
<div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
</div>
<label className="col-span-2 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')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span>
</label>
<label className="col-span-2 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="col-span-2 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>
)}
</>
)}
{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>
<Input type="number" value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 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>
</>
)}
<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>
{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>
</>
)}
{catCfg.backend === 'flex' && (
<p className="text-xs text-muted-foreground">
{t('cat.flexHint')}
</p>
)}
</div>
</>
);
}
async function testRotator() {
setRotatorTesting(true);
setRotatorTest(null);
try {
await TestRotator(rotator as any);
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
} 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';
const isSerial = isSteppir && 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>
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
so the serial fields never apply to it. */}
<Select value={ultrabeam.type ?? 'ultrabeam'}
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
<SelectItem value="steppir">SteppIR</SelectItem>
</SelectContent>
</Select>
</div>
{isSteppir && (
<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')}</Label>
<Input
value={ultrabeam.com ?? ''}
onChange={(e) => setUltrabeam((s) => ({ ...s, com: e.target.value }))}
placeholder="COM3"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>{t('hw.motorBaud')}</Label>
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, baud: parseInt(v, 10) || 9600 }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{[1200, 4800, 9600, 19200].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>
<Input
type="number" min={1} max={65535}
value={ultrabeam.port}
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 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 }))} />
Follow rig frequency (auto-tune the antenna)
</label>
{ultrabeam.follow && (
<div className="flex items-center gap-3 pl-6">
<Label className="text-sm">Re-tune step</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>
<span className="text-xs text-muted-foreground">re-tune only when the frequency moves this far</span>
</div>
)}
</div>
{isSteppir && (
<div className="border-t border-border/60 pt-3 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Label className="text-sm">{t('hw.steppirRange')}</Label>
<input type="number" min={1} max={60} value={ultrabeam.freq_min_mhz}
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_min_mhz: parseInt(e.target.value, 10) || 0 }))}
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" />
<span className="text-xs text-muted-foreground"></span>
<input type="number" min={1} max={60} value={ultrabeam.freq_max_mhz}
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_max_mhz: parseInt(e.target.value, 10) || 0 }))}
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" />
<span className="text-xs text-muted-foreground">MHz</span>
</div>
<p className="text-xs text-muted-foreground">{t('hw.steppirRangeHint')}</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>
</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,
}]);
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>
<Input type="number" min={1} max={65535} value={amp.port}
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
</div>
</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>
</div>
</>
);
}
function RotatorPanel() {
const isRG = (rotator as any).type === 'rotgenius';
const isARCO = (rotator as any).type === 'arco';
return (
<>
<SectionHeader
title="Rotator"
hint={isRG || isARCO ? undefined : t('rot.hint')}
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
{t('rot.enable')}
</label>
<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={(rotator as any).type ?? 'pst'}
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
</SelectContent>
</Select>
</div>
{isRG && (
<div className="space-y-1">
<Label>{t('rot.rotatorNum')}</Label>
<Select value={String((rotator as any).rotator_num ?? 1)}
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
{isARCO && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={(rotator as any).transport ?? 'tcp'}
onValueChange={(v) => setRotator((s) => ({ ...s, 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>
{isARCO && (rotator as any).transport === 'serial' ? (
<div className="space-y-1 max-w-xs">
<Label>COM port</Label>
<div className="flex items-center gap-2">
<Select value={(rotator as any).com_port || '_'}
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
<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="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input
type="number" min={1} max={65535}
value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
className="font-mono"
/>
</div>
</div>
)}
{!isRG && !isARCO && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, 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>}
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
</Button>
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Stop
</Button>
{!isRG && (
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Park
</Button>
)}
</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 to the short-path azimuth.
Shift+click for long-path, Ctrl+click to stop.
</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="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>
</div>
{wk.engine === 'icom' ? (
<>
<p className="text-xs text-muted-foreground -mt-2">
{t('wk.icomNote')}
</p>
{(!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 === 'flex' ? (
<>
<p className="text-xs text-muted-foreground -mt-2">
{t('wk.flexNote')}
</p>
{(!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>
</>
)}
{/* 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">&lt;MY_CALL&gt; &lt;CALL&gt; &lt;STX&gt; &lt;STRX&gt; &lt;MY_NAME&gt; &lt;HIS_NAME&gt; &lt;MY_QTH&gt; &lt;GRID&gt; &lt;CONT_TX&gt; &lt;n&gt;</span> {t('wk.macroCut')} <span className="font-mono">*</span>={t('wk.macroMyCall')}, <span className="font-mono">!</span>={t('wk.macroHisCall')}. <span className="font-mono">&lt;LOGQSO&gt;</span> = {t('wk.macroLog')} (<span className="font-mono">73 TU &lt;LOGQSO&gt;</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));
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>
<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>
</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">
<div className="text-[11px] text-muted-foreground">
{t('conf.note')}
</div>
{/* 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>
</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 (
<>
<SectionHeader
title={t('sec.backup')}
hint={mysqlCfg.enabled ? t('bk.hintMysql') : t('bk.hint')}
/>
<div className="space-y-4 max-w-xl">
<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="flex items-end gap-3">
<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>
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
<Checkbox
checked={!!backupCfg.zip}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
/>
<span>{t('bk.zip')}</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: '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 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} />
{/* 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>
<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 === '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 14 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() {
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)); }
}
// 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)));
}
// Route THIS profile's logbook to its own SQLite file (config stays in
// opslog.db). db.Open creates+migrates the file if it's new. Pick a name/place.
async function pickSeparateSqlite() {
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)); }
}
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')} />
{/* Logbook backend: local SQLite file (solo) or shared MySQL (multi-op).
Switching is instant — no restart. Only changing the local SQLite
FILE needs a restart (it also stores this operator's settings). */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
<Label className="text-sm">{t('db.backend')}</Label>
<Select
value={mysqlCfg.enabled ? 'mysql' : (mysqlCfg.sqlite_path ? 'sqlite_file' : 'sqlite')}
onValueChange={(v) => {
setRestartMsg('');
if (v === 'mysql') { setMysqlField({ enabled: true }); return; }
if (v === 'sqlite') { useLocalLogbook(); return; } // shared app db
// sqlite_file: keep any existing path; if none, prompt to pick one.
setMysqlField({ enabled: false });
if (!mysqlCfg.sqlite_path) pickSeparateSqlite();
}}
>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
<SelectItem value="sqlite_file">{t('db.optSqliteFile')}</SelectItem>
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">{t('db.profileHint')}</p>
{/* 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>
{backendStatus.active === 'mysql' && <span> · {t('db.configLocal')}</span>}
</div>
)
)}
{/* SQLite: local logbook file management */}
{/* Separate per-profile SQLite logbook FILE (config stays in opslog.db) */}
{!mysqlCfg.enabled && mysqlCfg.sqlite_path && (
<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}</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={pickSeparateSqlite}><FolderOpen className="size-3.5" /> {t('db.chooseFile')}</Button>
</div>
{restartMsg && <div className="text-xs text-success-muted-foreground">{restartMsg}</div>}
</div>
)}
{/* Shared application/config database (opslog.db) = settings + profiles +
this profile's QSOs when it uses the default logbook. */}
{!mysqlCfg.enabled && !mysqlCfg.sqlite_path && (
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
<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>
<div className="text-[10px] text-muted-foreground">{t('db.defaultLabel')} <span className="font-mono">{dbSettings.default_path}</span></div>
<p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
</div>
{dbMsg && (
<div className="text-xs bg-success-muted border border-success-border text-success-muted-foreground rounded-md px-3 py-3 space-y-2">
<div className="flex items-start gap-2">
<Check className="size-4 mt-0.5 shrink-0" />
<div>
<div className="font-medium">{t('db.savedRestart')}</div>
<div className="font-mono text-[10px] mt-1 break-all opacity-90">{dbMsg}</div>
</div>
</div>
<div className="flex items-center gap-2 pl-6">
<Button size="sm" onClick={() => { RestartApp().catch((e: any) => setErr(String(e?.message ?? e))); }}>
<Power className="size-3.5" /> {t('db.restartNow')}
</Button>
<span className="text-[10px] opacity-80">{t('db.restartHint')}</span>
</div>
</div>
)}
</div>
)}
{/* MySQL: shared logbook connection (multi-operator) */}
{mysqlCfg.enabled && (
<div className="space-y-3 max-w-2xl">
<div className="text-[11px] text-muted-foreground leading-relaxed">{t('db.mysqlHint')}</div>
<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>
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} />
<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>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div>
</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() {
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="_"> none / system default </SelectItem>
{devices.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</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}>
Refresh devices
</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">From Radio (RX in)</Label>
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
<Label className="text-sm">To Radio (TX out)</Label>
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
<Label className="text-sm">Recording mic</Label>
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
<Label className="text-sm">Listening (preview)</Label>
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
</div>
<p className="text-[11px] text-muted-foreground">
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
</p>
<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="Hear the rig's RX audio (From Radio) through your Listening device"
>
{monitorOn ? '■ Stop listening' : '▶ Listen to radio'}
</Button>
<span className="text-[11px] text-muted-foreground">
{monitorOn
? 'RX monitor running — From Radio → Listening device.'
: 'Live-monitor the rig here (USB codec now; network audio later).'}
</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="Key PTT and pipe your live mic into the rig (To Radio device)"
>
{txOn ? '■ Stop talking (TX)' : '🎙 Talk to radio (TX)'}
</Button>
<span className="text-[11px] text-muted-foreground">
{txOn
? 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.'
: 'Live mic → rig with PTT (USB now; network TX later).'}
</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">QSO recorder</h4>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
Record every QSO to an audio file (From Radio + your mic)
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Recordings folder</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(() => {})}>
Browse
</Button>
</div>
<Label className="text-sm">Pre-roll (seconds)</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">File 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">WAV (lossless, larger)</SelectItem>
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
</SelectContent>
</Select>
<Label className="text-sm">From Radio level</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
</div>
<Label className="text-sm">Mic level</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} 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">If your voice is louder than the station, lower Mic level.</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
Auto-send the recording to the station by e-mail when I log a QSO
</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">Voice keyer messages (F1F6)</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">PTT method</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">None (VOX)</SelectItem>
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
<SelectItem value="rts">Serial RTS</SelectItem>
<SelectItem value="dtr">Serial DTR</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('PTT test: ' + String(e?.message ?? e))); }}>
Test PTT
</Button>
)}
</div>
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
<>
<Label className="text-sm">PTT COM port</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="Pick a COM port" /></SelectTrigger>
<SelectContent>
<SelectItem value="_"> select </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(() => {})}>
Refresh
</Button>
</div>
</>
)}
</div>
</div>
{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={`Message ${m.slot} label (CQ, report, 73…)`}
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('Record: ' + String(err?.message ?? err)));
}}
onPointerUp={() => {
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
}}
>
{recHere ? '● Recording…' : '● Hold to rec'}
</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('Play: ' + String(err?.message ?? err))))}
>
{dvkStat.playing ? '■ Stop' : '▶ 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>
<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>
<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>
<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} />
<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"
/>
<span>
{t('gen.mwShow')}
<span className="block text-xs text-muted-foreground mt-0.5">
{t('gen.mwDesc')}
</span>
</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">
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
<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>
<div className="relative">
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
disabled={!emailCfg.auth}>
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
<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.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>
</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>
{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>
);
}
// Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = {
general: GeneralPanel,
email: EmailPanel,
station: StationPanel,
profiles: ProfilesPanel,
operating: OperatingPanelWrapper,
confirmations: ConfirmationsPanel,
'external-services': ExternalServicesPanel,
lookup: LookupPanel,
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
cluster: ClusterPanel,
udp: UDPIntegrationsPanelWrapper,
// Rendered as a real element (not called as a bare function) so its own hooks
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
// is a plain call and hook-holding panels must go through JSX like this.
adifmon: () => <ADIFMonitorPanel />,
relayauto: () => <RelayAutoPanel />,
backup: BackupPanel,
database: DatabasePanel,
uscounties: USCountiesPanel,
autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
rotator: RotatorPanel,
winkeyer: WinkeyerPanel,
antenna: UltrabeamPanel,
antgenius: AntGeniusPanelSettings,
pgxl: PGXLPanelSettings,
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
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>
{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>
);
}
// 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>
<Input type="number" min={1} max={65535} className="font-mono" value={s.port} onChange={(e) => update({ port: parseInt(e.target.value) || 7300 })} />
</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>
);
}