Files
OpsLog/frontend/src/components/SettingsModal.tsx
T
2026-06-06 01:43:27 +02:00

3030 lines
140 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,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetEmailSettings, SaveEmailSettings, TestEmail,
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,
GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
GetPOTAToken, SavePOTAToken, SyncPOTAHunterLog,
TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo,
} 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 { 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: '', 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,
created_at: '' as any,
updated_at: '' as any,
});
interface Props {
initialSection?: string;
onClose: () => void;
onSaved: () => void;
}
// Pretty little card showing what OpsLog will stamp on each QSO based on
// the callsign + grid in the Station Information form. Debounces the
// backend resolver so we don't fire on every keystroke; refreshes when
// inputs change. Empty card when no callsign yet.
/* ====== Tree definition ======
Section IDs are stable strings — adding new ones means adding a panel below.
`disabled: true` greys them out and shows the "coming soon" placeholder. */
type SectionId =
| 'general'
| 'email'
| 'station'
| 'profiles'
| 'operating'
| 'confirmations'
| 'external-services'
| 'udp'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
| 'cluster'
| 'backup'
| 'database'
| 'awards'
| 'cat'
| 'rotator'
| 'winkeyer'
| 'antenna'
| 'audio';
type TreeNode =
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
const TREE: TreeNode[] = [
{
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
{ kind: 'item', label: 'Station Information', id: 'station' },
{ kind: 'item', label: 'Profiles', id: 'profiles' },
{ kind: 'item', label: 'Operating conditions', id: 'operating' },
{ kind: 'item', label: 'Confirmations', id: 'confirmations' },
{ kind: 'item', label: 'External services', id: 'external-services' },
],
},
{
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
{ kind: 'item', label: 'General', id: 'general' },
{ kind: 'item', label: 'E-mail (SMTP)', id: 'email' },
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
]},
{ kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations', id: 'udp' },
{ kind: 'item', label: 'Database', id: 'database' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
],
},
{
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
],
},
];
// 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',
udp: 'UDP integrations',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
winkeyer: 'CW Keyer (WinKeyer)',
antenna: 'Antenna',
audio: 'Audio devices',
};
// ===== Tree component =====
interface TreeProps {
selected: SectionId;
onSelect: (id: SectionId) => void;
}
function Tree({ selected, onSelect }: TreeProps) {
return (
<nav className="text-sm">
{TREE.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 } }) {
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" />
Saved for profile <strong className="font-semibold">{profile?.name || '—'}</strong>
{profile?.callsign ? <span className="font-mono opacity-80">({profile.callsign})</span> : null}
switch profiles to edit another identity.
</span>
</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>
);
}
export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
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, 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);
// 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 (machine-local, applied live via localStorage).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
// E-mail / SMTP (send QSO recordings).
type EmailCfg = {
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
from: 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: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
});
const [emailMsg, setEmailMsg] = useState('');
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((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;
force_station_callsign: string;
tqsl_path: string; station_location: string; key_password: string;
upload_flag: string; write_log: boolean;
auto_upload: boolean; upload_mode: string;
};
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', email: '', username: '', password: '', callsign: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
upload_flag: 'R', write_log: false,
auto_upload: false, upload_mode: 'immediate',
});
const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: 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 [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' | 'hamqth' | '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 } | 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('');
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);
setBackupCfg(b as any);
setQslDefaults(qd as any);
setExtSvc(es as any);
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
try {
const locs: any = await ListTQSLStationLocations();
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
} catch { /* TQSL not installed — leave the dropdown empty */ }
try { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
reloadAudioDevices();
reloadDvk();
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setLoading(false);
}
})();
}, []);
// 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 SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg 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(() => SECTION_LABELS[selected] ?? selected, [selected]);
// === Section content renderers ===
function StationPanel() {
if (!activeProfile) {
return <div className="text-muted-foreground text-sm">Loading profile</div>;
}
const p = activeProfile;
return (
<>
<SectionHeader
title="Station Information"
hint={`Editing the active profile: ${p.name}. Switch profiles in the Profiles section to edit a different one.`}
/>
<div className="grid grid-cols-2 gap-3 max-w-2xl">
<div className="space-y-1">
<Label>Station callsign</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">What's transmitted (ADIF STATION_CALLSIGN).</div>
</div>
<div className="space-y-1">
<Label>Operator callsign</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">Who's at the radio (ADIF OPERATOR).</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Owner callsign</Label>
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
<div className="text-[10px] text-muted-foreground">Legal station owner only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
</div>
<div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
Auto-filled from the callsign editable (stamped as MY_* on each QSO)
</div>
<div className="space-y-1">
<Label>My grid</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>My country</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>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>CQ zone</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>ITU zone</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>Latitude</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>Longitude</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>State / pref</Label>
<Input value={p.my_state ?? ''} onChange={(e) => updateActive({ my_state: e.target.value })} />
</div>
<div className="space-y-1">
<Label>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>Street address</Label>
<Input value={p.my_street ?? ''} onChange={(e) => updateActive({ my_street: e.target.value })} />
</div>
<div className="space-y-1">
<Label>Postal code</Label>
<Input value={p.my_postal_code ?? ''} onChange={(e) => updateActive({ my_postal_code: e.target.value })} />
</div>
<div className="space-y-1">
<Label>City</Label>
<Input value={p.my_city ?? ''} onChange={(e) => updateActive({ my_city: e.target.value })} />
</div>
<div className="space-y-1">
<Label>SOTA ref</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>POTA ref</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(`Delete profile "${currentProfile.name}"? All its settings will be lost.`)) return;
try { await DeleteProfile(currentProfile.id as number); await reloadProfiles(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileDuplicate() {
if (!currentProfile) return;
const name = prompt(`Name for the new profile (copy of "${currentProfile.name}"):`, `${currentProfile.name} Copy`);
if (!name?.trim()) return;
try {
const dup = await DuplicateProfile(currentProfile.id as number, name.trim());
await reloadProfiles();
setProfileSelectedId(dup.id as number);
setProfileNameDraft(dup.name);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileCreateBlank() {
const name = prompt('Name for the new profile:', 'New profile');
if (!name?.trim()) return;
try {
const blank = emptyProfile();
blank.name = name.trim();
const saved = await SaveProfile(blank as any);
await reloadProfiles();
setProfileSelectedId(saved.id as number);
setProfileNameDraft(saved.name);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function profileRenameCurrent() {
if (!currentProfile || profileNameDraft.trim() === currentProfile.name) return;
try {
await SaveProfile({ ...currentProfile, name: profileNameDraft.trim() } as any);
await reloadProfiles();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
function ProfilesPanel() {
const current = currentProfile;
const active = activeProfileObj;
return (
<>
<SectionHeader
title="Profiles"
hint="Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile."
/>
<div className="space-y-4">
<div className="grid grid-cols-[140px_1fr] items-center gap-x-3 gap-y-3">
<Label>Configuration ID</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 ? ' (active)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Label>Description</Label>
<div className="flex items-center gap-2">
<Input
value={profileNameDraft}
onChange={(e) => setProfileNameDraft(e.target.value)}
onBlur={profileRenameCurrent}
placeholder="Profile name"
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">
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="Create a new empty profile">
<Plus className="size-3.5 mr-1" /> New
</Button>
<Button variant="outline" size="sm" onClick={profileDuplicate} disabled={!current} title="Clone the selected profile (keeps all its fields)">
<Copy className="size-3.5 mr-1" /> Duplicate
</Button>
<Button
variant="outline" size="sm"
onClick={profileActivate}
disabled={!current || current.is_active}
title="Activate the selected profile — new QSOs will use its MY_* fields"
>
<Star className="size-3.5 mr-1" /> Set active
</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 ? 'Cannot delete the last profile' : 'Delete the selected profile'}
>
<Trash2 className="size-3.5 mr-1" /> 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">
You're viewing <strong>{current.name}</strong>. The active profile is <strong>{active?.name}</strong> — its values are stamped on new QSOs. Click <em>Set active</em> to switch.
</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="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="Password"
autoComplete="off"
/>
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Button
variant="outline" size="sm"
onClick={() => testProvider(key)}
disabled={!hasCreds || testing}
title="Run a sample lookup against the active profile's callsign to verify credentials"
>
{testing ? 'Testing…' : '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="Callsign Lookup"
hint="Pick a Primary provider and an optional Failsafe (queried only when Primary returns no data). Click Test to verify credentials without saving."
/>
<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">Provider</th>
<th className="px-2 py-2 w-20">Primary</th>
<th className="px-2 py-2 w-20">Failsafe</th>
<th className="text-left px-2 py-2">User</th>
<th className="text-left px-2 py-2">Password</th>
<th className="px-2 py-2 w-20"></th>
<th className="text-left px-2 py-2">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">
Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely.
</p>
<div className="mt-6 pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">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>
Show QRZ profile pictures
<span className="block text-xs text-muted-foreground mt-0.5">
Display the photo from QRZ.com next to the worked-before matrix.
May noticeably slow lookups during busy contest days; turn off if you operate fast.
</span>
</span>
</label>
</div>
<div className="mt-6 pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Cache</h3>
<p className="text-xs text-muted-foreground mb-3">
Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.
</p>
<div className="flex gap-3 items-end">
<div className="space-y-1 w-40">
<Label>TTL (days)</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 ? 'Clearing' : 'Clear cache now'}
</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="Bands"
hint="Pick the bands you actually use. The entry strip, the band-slot grid and the band-map switcher only show what's on the right. Order on the right = display order."
/>
<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">
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">All catalog bands selected.</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="Custom band (e.g. 4m)"
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>Selected ({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">
No band selected — pick from the left.
</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="Modes & default RST"
hint="Pick the modes you actually use on the right. Anywhere the UI shows a mode picker, it iterates the right column. When you select a mode in the entry form, RST sent/rcvd auto-fill with the defaults below (unless you've typed something)."
/>
<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">
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">All catalog modes selected.</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={`Default RST: ${m.sent} / ${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="Custom mode"
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">Order</span>
<span>Mode</span>
<span>RST snt</span>
<span>RST rcv</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">
No mode selected — pick from the left.
</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" /> Add blank row
</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">RST report lists</div>
<div className="text-[11px] text-muted-foreground mb-2">
The choices offered in the entry form's RST dropdowns, per mode family. One value per line (or space-separated). The first one is the top of the list.
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-xs">Phone (SSB/AM/FM)</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">CW / RTTY / PSK</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">Digital (FT8/FT4/JT) dB</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="CAT interface (OmniRig)"
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — OpsLog just talks to it."
/>
<div className="space-y-4 max-w-lg">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
Enable CAT
</label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Backend</Label>
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem>
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>OmniRig rig slot</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>
<div className="space-y-1">
<Label>Poll interval (ms)</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>CAT delay (ms)</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>Default digital mode (when rig reports DIG)</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>
<p className="text-xs text-muted-foreground">
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
OpsLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
{' '}above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu).
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
{' '}is the specific mode OpsLog will surface (and log).
</p>
</div>
</>
);
}
async function testRotator() {
setRotatorTesting(true);
setRotatorTest(null);
try {
await TestRotator(rotator as any);
setRotatorTest({ ok: true, msg: 'Packet sent antenna should swing to 0° (north). If it didn\'t, check PstRotator host/port and that PstRotator\'s UDP listener is enabled.' });
} catch (e: any) {
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setRotatorTesting(false);
}
}
function RotatorPanel() {
return (
<>
<SectionHeader
title="Rotator (PstRotator)"
hint="OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
/>
<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 ? 'Sending…' : 'Test (point to 0°)'}
</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="CW Keyer (WinKeyer)"
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
/>
<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.
</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="DX Cluster"
hint="Connect to one or several DX cluster nodes (telnet). The first enabled server is the master — typed commands and init commands go through it."
/>
<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">Name</th>
<th className="text-left px-3 py-2">Host:port</th>
<th className="text-left px-3 py-2 w-28">Status</th>
<th className="px-3 py-2 w-32">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">
<Button variant="ghost" size="icon" className="size-6" disabled={i === 0} onClick={() => clusterMove(s, -1)} title="Move up"><ArrowUp className="size-3" /></Button>
<Button variant="ghost" size="icon" className="size-6" disabled={i === sorted.length - 1} onClick={() => clusterMove(s, 1)} title="Move down"><ArrowDown className="size-3" /></Button>
<Button variant="ghost" size="icon" className="size-6" onClick={() => setEditingServer(s)} title="Edit"><Cog className="size-3.5" /></Button>
<Button variant="ghost" size="icon" className="size-6 text-destructive hover:text-destructive" onClick={() => clusterDeleteServer(s)} title="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">No cluster nodes saved yet.</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" /> Add cluster
</Button>
<Button variant="outline" size="sm" onClick={async () => { await ConnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
Connect all
</Button>
<Button variant="outline" size="sm" onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
Disconnect all
</Button>
<label className="flex items-center gap-2 text-sm cursor-pointer ml-auto">
<Checkbox checked={clusterAutoConnect} onCheckedChange={(c) => setClusterAutoConnectState(!!c)} />
Auto-connect all enabled on app start
</label>
</div>
<p className="text-xs text-muted-foreground">
Free public nodes: <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: '— leave blank —' },
{ value: 'Y', label: 'Y (yes)' },
{ value: 'N', label: 'N (no)' },
{ value: 'R', label: 'R (requested)' },
{ value: 'Q', label: 'Q (queued)' },
{ value: 'I', label: 'I (ignore)' },
];
// 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="Confirmations"
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
/>
<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">Paper QSL</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
{renderSelect('qsl_sent', FULL_OPTIONS)}
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">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">Sent</Label>
{renderSelect('eqsl_sent', FULL_OPTIONS)}
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">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">Sent</Label>
{renderSelect('lotw_sent', FULL_OPTIONS)}
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">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">
"Sent" = the QSO's upload status to the service; "N" is typical so OpsLog tracks which QSOs still need uploading. Club Log &amp; HRDLog are upload-only (no "rcvd" in ADIF); QRZ.com also has "Rcvd" = confirmed back.
</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">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">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">Sent</Label>
{renderSelect('qrzcom_status', FULL_OPTIONS)}
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
</div>
</div>
</div>
</div>
</>
);
}
function UDPIntegrationsPanelWrapper() {
return (
<>
<SectionHeader
title="UDP integrations"
hint="Listen for QSO logs from WSJT-X / JTDX / MSHV, ADIF messages from JTAlert/GridTracker, or simple callsign packets from external tools. Outbound connections forward every QSO you log to a remote listener (Cloudlog UDP, N1MM, …)."
/>
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
</>
);
}
function OperatingPanelWrapper() {
return (
<>
<SectionHeader
title="Operating conditions"
hint="Define your rigs and the antennas you use on each band. The entry strip will auto-fill MY_RIG and MY_ANTENNA based on the default antenna for the band you're operating on."
/>
<OperatingPanel
bands={lists.bands ?? []}
onError={(m) => setErr(m)}
/>
</>
);
}
function BackupPanel() {
const fmtLast = (iso: string) => {
if (!iso) return '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: 'Backup written to ' + 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="Database backup"
hint="OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones."
/>
<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>Automatic backup when closing OpsLog (max once per day)</span>
</label>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Backup folder</Label>
<div className="flex gap-2">
<Input
className="font-mono text-xs flex-1"
placeholder={backupCfg.default_folder || 'leave empty for default'}
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) });
}
}}
>
Browse…
</Button>
</div>
<div className="text-[10px] text-muted-foreground">
{backupCfg.folder
? <>Effective folder: <span className="font-mono">{effectiveFolder}</span></>
: <>If empty, OpsLog uses the default: <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">Rotation (copies to keep)</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>ZIP backup (smaller file)</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 ? 'Backing up…' : 'Backup now'}
</Button>
<span className="text-xs text-muted-foreground">
Last backup: <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' },
{ k: 'eqsl', label: 'EQSL' },
{ k: 'hamqth', label: 'HAMQTH' },
{ k: 'lotw', label: 'LOTW', ready: true },
{ k: 'pota', label: 'POTA', ready: true },
];
async function syncPota() {
setPotaBusy(true);
setPotaResult(null);
try {
await SavePOTAToken(potaToken);
const r: any = await SyncPOTAHunterLog();
setPotaResult({ ok: true, msg: `${r.updated} QSO updated · ${r.already_tagged} already tagged · ${r.unmatched} unmatched (of ${r.fetched} hunter-log entries).` });
} 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 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="External services"
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first)."
/>
<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">API key</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">Force station callsign</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 })}
/>
Automatic upload on new QSO
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Upload timing</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">Immediate</SelectItem>
<SelectItem value="delayed">Delayed (12 min, lets you fix mistakes)</SelectItem>
<SelectItem value="on_close">On app close (batch)</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 ? 'Testing…' : 'Test connection'}
</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">Account email</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">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">Logbook callsign</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 })}
/>
Automatic upload on new QSO
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Upload timing</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">Immediate</SelectItem>
<SelectItem value="delayed">Delayed (12 min, lets you fix mistakes)</SelectItem>
<SelectItem value="on_close">On app close (batch)</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 ? 'Testing' : 'Test connection'}
</Button>
{clublogTest && (
<span className={cn('text-xs', clublogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
{clublogTest.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">Force station callsign</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">Upload flag</Label>
<div>
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="N">Upload when LoTW sent = No</SelectItem>
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem>
</SelectContent>
</Select>
<div className="text-[10px] text-muted-foreground mt-1">
Must match your default LoTW <em>sent</em> status in Confirmations, or new QSOs won't be picked up.
</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 ? 'Testing…' : 'Test connection'}
</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={syncPota} disabled={potaBusy || !potaToken.trim()}>
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> Syncing…</> : 'Sync hunter log'}
</Button>
<span className="text-[11px] text-muted-foreground">Matches by callsign + band within ±5 min. Only fills QSOs without a POTA ref.</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>
)}
<p className="text-[11px] text-muted-foreground">After a sync, rescan the POTA award to see the new references counted.</p>
</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="Database"
hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like another drive or a synced folder (Seafile, Dropbox) and back it up automatically."
/>
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
<Label>Current database</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">(custom location)</span>
: <span className="ml-2 text-[10px] text-muted-foreground">(default)</span>}
</div>
<div className="text-[10px] text-muted-foreground">Default: <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" /> New database…</Button>
<Button variant="outline" size="sm" onClick={openExisting}>Open existing…</Button>
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy &amp; switch…</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
</div>
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
<strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
<strong>Open existing</strong> points OpsLog at a file you already have.{' '}
<strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
Any database change takes effect on the next launch.
</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()}>Quit now</Button>
</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="Audio devices & voice keyer"/>
<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().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() {
return (
<>
<SectionHeader title="General" hint="App behaviour preferences (saved instantly, machine-local)." />
<div className="space-y-4 max-w-lg">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={autofocusWB}
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); localStorage.setItem('opslog.autofocusWB', v ? '1' : '0'); }}
className="mt-0.5"
/>
<span>
Auto-focus "Worked before" for stations already worked
<span className="block text-xs text-muted-foreground mt-0.5">
When you type a callsign you've contacted before, OpsLog jumps to the Worked before tab. Turn off to stay on your current tab.
</span>
</span>
</label>
<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="E-mail"/>
<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">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 })} />
SMTP requires authorization
</label>
<Label className="text-sm">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">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">From address</Label>
<Input className="h-8" placeholder="you@example.com" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
onClick={() => { setEmailMsg('Sending test…'); SaveEmailSettings(emailCfg as any).then(() => TestEmail('')).then(() => setEmailMsg('Test e-mail sent ✓')).catch((e: any) => setEmailMsg('Test failed: ' + String(e?.message ?? e))); }}>
Send test e-mail
</Button>
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
</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,
awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel,
rotator: RotatorPanel,
winkeyer: WinkeyerPanel,
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
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>Preferences</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} />
</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}>Cancel</Button>
<Button variant="outline" onClick={() => save(false)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => save(true)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save and close'}</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>
);
}