Files
OpsLog/frontend/src/components/SettingsModal.tsx
T

4117 lines
191 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,
} 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,
GetPGXLSettings, SavePGXLSettings,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetSecretStatus, SetPassphrase, RemovePassphrase,
GetEmailSettings, SaveEmailSettings, TestEmail,
QSLGetEmailTemplates, QSLSaveEmailTemplates,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetDataDir,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
GetLiveStatusEnabled, SetLiveStatusEnabled,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo,
GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
} 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 { 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
}
// 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'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
| 'cluster'
| 'backup'
| 'database'
| 'autostart'
| 'awards'
| 'cat'
| 'rotator'
| 'winkeyer'
| 'antenna'
| 'antgenius'
| 'pgxl'
| 'flex'
| '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.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.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',
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',
};
// 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',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'PstRotator',
winkeyer: 'CW Keyer',
antenna: 'UltraBeam',
antgenius: 'Antenna Genius',
pgxl: 'Power Genius',
flex: 'FlexRadio',
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 =====
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 [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(() => {}); }} />
Send anonymous usage statistics
<span className="text-xs text-muted-foreground">(install ID + version + OS, once a day no callsign or QSO data)</span>
</label>
);
}
// LiveStatusToggle publishes this operator's current activity (call + band +
// freq + mode) to the shared MySQL `live_status` table every ~15s, for multi-op
// events — a small web script on your server renders it for the QRZ page. Only
// useful on a MySQL logbook. Self-contained component (owns its async state).
function LiveStatusToggle() {
const [on, setOn] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
GetLiveStatusEnabled().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); SetLiveStatusEnabled(v).catch(() => {}); }} />
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL feeds a QRZ live page)</span>
</label>
);
}
// 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_OPTIONS: { value: string; label: string }[] = [
{ value: 'map1', label: 'Map — great-circle + beam' },
{ value: 'map2', label: 'Map — locator (street)' },
{ value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' },
{ value: 'recent', label: 'Recent QSOs' },
];
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
// FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z.
const options = (flexAvailable
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
: [...MAIN_PANE_OPTIONS]
).sort((a, b) => a.label.localeCompare(b.label));
useEffect(() => {
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === 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">Main view</h4>
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</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">Left pane</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">Right pane</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-amber-700 bg-amber-50 border border-amber-200 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>
);
}
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: 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,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
});
const [rotator, setRotator] = useState<RotatorSettings>({
enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false,
});
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Ultrabeam antenna (TCP) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({
enabled: false, host: '', port: 23, follow: false, step_khz: 50,
});
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 }>({ enabled: false, host: '' });
// PowerGenius XL (4O3A) amp fan-control settings.
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
// 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; 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, 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('');
// 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 [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('Passphrases do not match'); return; }
setPpBusy(true); setPpErr('');
try { await SetPassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
catch (e: any) { setPpErr(String(e?.message ?? e)); }
finally { setPpBusy(false); }
};
const removePassphrase = async () => {
setPpBusy(true); setPpErr('');
try { await RemovePassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
catch (e: any) { setPpErr(String(e?.message ?? e)); }
finally { setPpBusy(false); }
};
// E-mail / SMTP (send QSO recordings).
type EmailCfg = {
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
from: string; reply_to: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
};
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
});
const [emailMsg, setEmailMsg] = useState('');
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// eQSL card e-mail (subject/body templates + auto-send on log).
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
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(() => {}); }, []);
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);
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 };
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 [dataDir, setDataDir] = useState('');
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 { setPgxl(await GetPGXLSettings() as any); } 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 { setDataDir(await GetDataDir()); } 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 { setPgxl(await GetPGXLSettings() as any); } 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 SavePGXLSettings(pgxl 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-emerald-100 text-emerald-800 border border-emerald-300">
{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-emerald-700' : '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="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>
</>
)}
{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 col-span-2">
<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 })); }} />
<p className="text-xs text-muted-foreground">{t('cat.civHint')}</p>
</div>
</>
)}
{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') && (
<>
<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: 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() {
return (
<>
<SectionHeader
title={t('hw.ultrabeam')}
/>
<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 }))} />
Enable Ultrabeam control
</label>
<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>
<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>
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !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-emerald-50 text-emerald-800 border-emerald-200'
: '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>
</>
);
}
function PGXLPanelSettings() {
return (
<>
<SectionHeader
title="Power Genius XL"
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
Enable PowerGenius fan control
</label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={pgxl.host ?? ''}
onChange={(e) => setPgxl((s) => ({ ...s, 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={pgxl.port}
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
className="font-mono"
/>
</div>
</div>
</div>
</>
);
}
function RotatorPanel() {
return (
<>
<SectionHeader
title="Rotator"
hint={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 }))} />
Enable PstRotator control
</label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host</Label>
<Input
value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder="127.0.0.1"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>UDP port</Label>
<Input
type="number" min={1} max={65535}
value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))}
className="font-mono"
/>
</div>
</div>
<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>
<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>
<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-emerald-50 text-emerald-800 border-emerald-200'
: '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 })} />
Enable CW keyer (shows the keyer panel)
</label>
<div className="grid grid-cols-4 gap-3 items-end">
<div className="space-y-1">
<Label>Keyer engine</Label>
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
<SelectItem value="tci" disabled>TCI (coming soon)</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 })} />
ESC clears the callsign too (otherwise ESC only stops transmission)
</label>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1 col-span-2">
<Label>Serial port</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="— COM port —" /></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 variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
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>
<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>Speed (WPM)</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>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>Lead-in (ms)</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>Tail (ms)</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>Ratio (33-66)</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>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>Sidetone (Hz)</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>Paddle mode</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 })} /> Swap paddles
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
</label>
</div>
{/* Macro editor */}
<div className="border-t border-border/60 pt-3">
<Label className="text-sm font-medium">CW message macros (F1)</Label>
<p className="text-[11px] text-muted-foreground mb-2">
Use variables: <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> (cut numbers: 9N, 0T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call. <span className="font-mono">&lt;LOGQSO&gt;</span> = log the QSO when the macro is sent (e.g. <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="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-amber-100 text-amber-800 border border-amber-300">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-emerald-100 text-emerald-800 border-emerald-300' :
state === 'connecting' || state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
'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-emerald-600 hover:text-emerald-700"
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-emerald-50 border-emerald-300 text-emerald-800'
: 'bg-rose-50 border-rose-300 text-rose-800',
)}>
{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((t) => (
<button
key={t.k}
type="button"
onClick={() => setExtSvcTab(t.k)}
className={cn(
'px-3 py-1.5 text-xs font-semibold rounded-t-md border-x border-t -mb-px transition-colors',
extSvcTab === t.k
? 'bg-card border-border text-foreground'
: 'bg-muted/40 border-transparent text-muted-foreground hover:text-foreground',
)}
>
{t.label}
{!t.ready && <span className="ml-1 text-[9px] opacity-60">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="QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)"
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="e.g. F4BPO — optional"
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-emerald-700' : 'text-rose-700')}>
{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="your Club Log account email"
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="Club Log account password"
className="text-xs"
/>
<Label className="text-sm">{t('es.logbookCall')}</Label>
<Input
value={clublog.callsign}
onChange={(e) => setClublog({ callsign: e.target.value.toUpperCase() })}
placeholder="defaults to the active profile's callsign"
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-emerald-700' : 'text-rose-700')}>
{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">Station callsign</Label>
<Input
value={hrdlog.callsign}
onChange={(e) => setHrdlog({ callsign: e.target.value.toUpperCase() })}
placeholder="defaults to the active profile's callsign"
className="font-mono text-xs"
/>
<Label className="text-sm">Upload code</Label>
<Input
type="password"
value={hrdlog.code}
onChange={(e) => setHrdlog({ code: e.target.value })}
placeholder="HRDLog account → My Account → Upload code"
className="text-xs"
/>
</div>
<div className="text-[10px] text-muted-foreground -mt-1">
Find the upload code on HRDLog.net under <em>My Account</em>. It authorises uploads for this callsign.
</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-emerald-700' : 'text-rose-700')}>
{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">Username (callsign)</Label>
<Input
value={eqsl.username}
onChange={(e) => setEqsl({ username: e.target.value.toUpperCase() })}
placeholder="your eQSL.cc login (callsign)"
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="eQSL.cc account password"
className="text-xs"
/>
<Label className="text-sm">QTH nickname</Label>
<Input
value={eqsl.qth_nickname}
onChange={(e) => setEqsl({ qth_nickname: e.target.value })}
placeholder="optional — required only if your eQSL account has several QTHs"
className="text-xs"
/>
</div>
<div className="text-[10px] text-muted-foreground -mt-1">
The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.
</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-emerald-700' : 'text-rose-700')}>
{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">LoTW user</Label>
<Input
value={lotw.username}
onChange={(e) => setLotw({ username: e.target.value })}
placeholder="LoTW website login (for downloading confirmations)"
className="font-mono text-xs"
/>
<Label className="text-sm">LoTW password</Label>
<Input
type="password"
value={lotw.password}
onChange={(e) => setLotw({ password: e.target.value })}
placeholder="LoTW website password"
className="text-xs"
/>
<Label className="text-sm">TQSL path</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">Station location</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="— pick a TQSL location —" /></SelectTrigger>
<SelectContent>
{stationLocations.length === 0 && <SelectItem value="_" disabled>No TQSL locations found</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="Reload locations from TQSL">
<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="e.g. F4BPO/P — leave blank to use the QSO's own call"
className="font-mono uppercase w-64"
/>
<div className="text-[10px] text-muted-foreground mt-1">
Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls
(F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.
</div>
</div>
<Label className="text-sm">Key password</Label>
<Input
type="password"
value={lotw.key_password}
onChange={(e) => setLotw({ key_password: e.target.value })}
placeholder="only if your certificate key has a password"
className="text-xs"
/>
<Label className="text-sm">Consider as unsent</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' ? 'No (N)' : 'Requested (R)'}
</label>
);
})}
</div>
<div className="text-[10px] text-muted-foreground mt-1">
At app close, every QSO whose LoTW <em>sent</em> status is one of these is signed and
uploaded in one TQSL batch including QSOs imported from an ADIF. Uploaded QSOs become
<em> Y</em> and won't be re-sent. Must include your default <em>sent</em> status from Confirmations.
</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' })}
/>
Automatic upload on application close
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={lotw.write_log}
onCheckedChange={(c) => setLotw({ write_log: !!c })}
/>
Write TQSL diagnostic log (-t)
</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-emerald-700' : 'text-rose-700')}>
{lotwTest.msg}
</span>
)}
</div>
</div>
</div>
) : extSvcTab === 'pota' ? (
<div className="space-y-4 max-w-2xl">
<p className="text-xs text-muted-foreground leading-relaxed">
Update your QSOs with the park reference from your <strong>pota.app hunter log</strong>.
Paste your session token: log in at <span className="font-mono">pota.app</span>, open the browser
DevTools → <strong>Network</strong> tab, click any <span className="font-mono">api.pota.app</span> request,
and copy the full <strong>Authorization</strong> header value. The token expires after a while — re-copy it if the sync fails.
</p>
<div className="grid grid-cols-[170px_1fr] gap-3 items-start">
<Label className="text-sm pt-2">Session token</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" /> Saving…</> : 'Save token'}
</Button>
<span className="text-[11px] text-muted-foreground">
Then run the sync from the <strong>QSL Manager</strong> tab → service <strong>POTA hunter log</strong> (you can see and fix unmatched QSOs there).
</span>
</div>
{potaResult && (
<div className={cn('text-xs rounded-md px-3 py-2 border',
potaResult.ok ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : '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">
{TABS.find((t) => t.k === extSvcTab)?.label} — coming soon
</div>
<div className="text-xs">This external service isn't wired up yet.</div>
</div>
)}
</>
);
}
function DatabasePanel() {
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
async function openExisting() {
try {
const p = await PickOpenDatabase();
if (!p) return;
await OpenDatabase(p);
await refreshDb();
setDbMsg(`Database set to:\n${p}\nRestart OpsLog to apply.`);
} 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(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
} 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(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function resetDefault() {
try {
await ResetDatabaseToDefault();
await refreshDb();
setDbMsg('Database reset to the default location. Restart OpsLog to apply.');
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
return (
<>
<SectionHeader
title={t('sec.database')}
/>
{/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can
target its own database; choosing here and Save switches the live
logbook immediately (no restart). */}
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
<Label className="text-sm">Backend</Label>
<Select
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}
>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">
{t('db.profileHint')}
</p>
{/* Save (always visible) applies the active profile's DB target live. */}
<div className="max-w-2xl mb-4 flex items-center gap-3">
<Button size="sm" className="h-8"
onClick={() => {
SaveMySQLSettings(mysqlCfg as any)
.then(() => setRestartMsg(mysqlCfg.enabled
? t('db.switchedMysql')
: t('db.switchedSqlite')))
.catch((e: any) => setErr(String(e?.message ?? e)));
}}>
{t('db.saveSwitch')}
</Button>
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
</div>
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
{backendStatus && (
<div className="max-w-2xl mb-4">
{backendStatus.fallback ? (
<div className="text-xs bg-amber-50 border border-amber-300 text-amber-800 rounded-md px-3 py-2">
{t('db.fallback')}
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
</div>
) : backendStatus.active === 'mysql' ? (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
<strong>{t('db.logbook')}</strong> {t('db.mysqlConnected')}
<span className="mx-1.5 text-muted-foreground">·</span>
<strong>{t('db.config')}</strong> {t('db.localSqlite')}
<div className="text-[10px] text-muted-foreground mt-1">
{t('db.mysqlNote')}
</div>
</div>
) : (
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
{t('db.activeBackend')} <strong className="uppercase">{backendStatus.active}</strong>
</div>
)}
</div>
)}
{!mysqlCfg.enabled && (
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
<Label>{t('db.current')}</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-emerald-700">{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>
</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}>{t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={saveCopy}>{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-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
<span>{dbMsg}</span>
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>{t('db.quitNow')}</Button>
</div>
)}
</div>
)}
{/* Shared MySQL database (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">
<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>
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
</div>
</div>
)}
{/* Data location */}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
<div>
<div className="text-sm font-medium">{t('db.dataLocation')}</div>
</div>
<div className="space-y-1">
<Label>{t('db.currentDataDir')}</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dataDir || '—'}
</div>
</div>
</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>
<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>
<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={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={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 />
<LiveStatusToggle />
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
{secret.has_passphrase ? (
<>
<p className="text-xs text-muted-foreground">
Passwords encrypted{' '}
{secret.unlocked
? <span className="text-emerald-700 font-medium"> unlocked</span>
: <span className="text-amber-600 font-medium"> locked (unlock at launch)</span>}.
</p>
{secret.unlocked && (
<>
<div className="flex items-center gap-2 max-w-md">
<Input type="password" placeholder="New passphrase (to change)" value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
<Input type="password" placeholder="Confirm" value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
<Button size="sm" disabled={!ppNew || ppBusy} onClick={applyPassphrase}>Change</Button>
</div>
<Button variant="outline" size="sm" disabled={ppBusy} onClick={removePassphrase}>Remove encryption (store passwords in clear)</Button>
</>
)}
</>
) : (
<>
<p className="text-xs text-muted-foreground">
Encrypt saved passwords with a passphrase (asked at launch). Stays portable re-enter it on another PC.
</p>
<div className="flex items-center gap-2 max-w-md">
<Input type="password" placeholder="Passphrase" value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
<Input type="password" placeholder="Confirm" value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
<Button size="sm" disabled={!ppNew || ppNew !== ppConfirm || ppBusy} onClick={applyPassphrase}>Encrypt</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">ClubLog exceptions (DXpedition overrides)</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>
Use ClubLog Country File for callsign resolution
<span className="block text-xs text-muted-foreground mt-0.5">
Applies ClubLog's date-ranged full-callsign <strong>exceptions</strong> that cty.dat lacks — e.g. VK2/SP9FIH
resolves to Lord Howe Island (not Australia) for the DXpedition dates. Used on entry, import, UDP, and the
right-click <em>Update from ClubLog</em>.
</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 ? 'Downloading' : (clubInfo.loaded ? 'Update ClubLog data' : 'Download ClubLog data')}
</Button>
<span className="text-[11px] text-muted-foreground">
{clubInfo.loaded
? `${clubInfo.count.toLocaleString()} exceptions${clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : ''}`
: 'not downloaded'}
</span>
</div>
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</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>
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<Label className="text-sm">{t('em.fromAddr')}</Label>
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
<Label className="text-sm">{t('em.replyTo')}</Label>
<div>
<Input className="h-8" placeholder={t('em.replyToPh')} value={emailCfg.reply_to} onChange={(e) => setEmailField({ reply_to: e.target.value })} />
<div className="text-[10px] text-muted-foreground mt-1">{t('em.replyToHint')}</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
onClick={() => { setEmailMsg(t('em.sendingTest')); SaveEmailSettings(emailCfg as any).then(() => TestEmail('')).then(() => setEmailMsg(t('em.testSent'))).catch((e: any) => setEmailMsg(t('em.testFailed') + String(e?.message ?? e))); }}>
{t('em.sendTest')}
</Button>
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
</div>
<div className="pt-2 mt-2 border-t border-border space-y-2">
<Label className="text-sm font-semibold">{t('em.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>
</>
);
}
// 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,
backup: BackupPanel,
database: DatabasePanel,
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>
);
}