2371 lines
104 KiB
TypeScript
2371 lines
104 KiB
TypeScript
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,
|
||
} from 'lucide-react';
|
||
import {
|
||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||
GetListsSettings, SaveListsSettings,
|
||
GetCATSettings, SaveCATSettings,
|
||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||
GetClusterAutoConnect, SetClusterAutoConnect,
|
||
ConnectClusterServer, DisconnectClusterServer,
|
||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp,
|
||
GetQSLDefaults, SaveQSLDefaults,
|
||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||
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 =
|
||
| 'station'
|
||
| 'profiles'
|
||
| 'operating'
|
||
| 'confirmations'
|
||
| 'external-services'
|
||
| 'udp'
|
||
| 'lookup'
|
||
| 'lists-bands'
|
||
| 'lists-modes'
|
||
| 'cluster'
|
||
| 'backup'
|
||
| 'database'
|
||
| 'awards'
|
||
| 'cat'
|
||
| 'rotator'
|
||
| '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: '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 (WSJT-X, JTDX, MSHV…)', id: 'udp' },
|
||
{ kind: 'item', label: 'Database backup', id: 'backup' },
|
||
{ kind: 'item', label: 'Database location', 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: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
||
],
|
||
},
|
||
];
|
||
|
||
// 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 location',
|
||
udp: 'UDP integrations',
|
||
awards: 'Awards',
|
||
cat: 'CAT interface',
|
||
rotator: 'Rotator',
|
||
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>
|
||
);
|
||
}
|
||
|
||
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);
|
||
|
||
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;
|
||
};
|
||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||
qsl_sent: '', qsl_rcvd: '',
|
||
lotw_sent: '', lotw_rcvd: '',
|
||
eqsl_sent: '', eqsl_rcvd: '',
|
||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||
});
|
||
|
||
// 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'>('qrz');
|
||
|
||
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 */ }
|
||
} 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() {
|
||
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 SaveBackupSettings(backupCfg as any);
|
||
await SaveQSLDefaults(qslDefaults as any);
|
||
await SaveExternalServices(extSvc as any);
|
||
await SetClusterAutoConnect(clusterAutoConnect);
|
||
|
||
setMsg('Settings saved.');
|
||
onSaved();
|
||
setTimeout(onClose, 500);
|
||
} 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(); 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 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."
|
||
/>
|
||
<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">
|
||
Upload status fields (Clublog / HRDLog / QRZ.com) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
|
||
</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">Upload status</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">Upload status</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">Upload status</Label>
|
||
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
||
</div>
|
||
<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 },
|
||
];
|
||
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 1–2 min delay so a mis-logged QSO can still be fixed first)."
|
||
/>
|
||
|
||
{/* 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 (1–2 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 (1–2 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">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>
|
||
) : (
|
||
<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 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 location"
|
||
hint="Keep your log database wherever you like — another drive or a synced folder (Seafile, Dropbox…) — so it survives a Windows reinstall. Everything (QSOs, settings, lookup cache) lives in this one file."
|
||
/>
|
||
<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 variant="outline" size="sm" onClick={openExisting}>Open existing database…</Button>
|
||
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy & switch to it…</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>Open existing</strong> points OpsLog at a database file you already have (e.g. after reinstalling Windows).{' '}
|
||
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '}
|
||
A 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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Map sections to their content + icon (for placeholder).
|
||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||
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,
|
||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||
audio: () => <ComingSoon id="audio" icon={Server} />,
|
||
};
|
||
|
||
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 onClick={save} disabled={saving || loading}>{saving ? 'Saving…' : 'Save all'}</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>
|
||
);
|
||
}
|