@@ -3,7 +3,7 @@ import {
ArrowDown , ArrowUp , ArrowLeft , ArrowRight , Copy , Plus , Star , StarOff , Trash2 ,
ChevronDown , ChevronRight ,
User , Database , Radio , Cog , Server , Award , Antenna as AntennaIcon ,
Compass , Wifi , Construction ,
Compass , Wifi , Construction , UploadCloud ,
} from 'lucide-react' ;
import {
GetLookupSettings , SaveLookupSettings , ClearLookupCache , TestLookupProvider ,
@@ -17,6 +17,7 @@ import {
ConnectAllClusters , DisconnectAllClusters , GetClusterStatus ,
GetBackupSettings , SaveBackupSettings , RunBackupNow , PickBackupFolder ,
GetQSLDefaults , SaveQSLDefaults ,
GetExternalServices , SaveExternalServices , TestQRZUpload , TestClublogUpload ,
ComputeStationInfo ,
} from '../../wailsjs/go/main/App' ;
import type { profile as profileModels } from '../../wailsjs/go/models' ;
@@ -167,6 +168,7 @@ type SectionId =
| 'profiles'
| 'operating'
| 'confirmations'
| 'external-services'
| 'udp'
| 'lookup'
| 'lists-bands'
@@ -190,6 +192,7 @@ const TREE: TreeNode[] = [
{ kind : 'item' , label : 'Profiles (portable, home, contest)' , id : 'profiles' } ,
{ kind : 'item' , label : 'Operating conditions (rigs & antennas)' , id : 'operating' } ,
{ kind : 'item' , label : 'Confirmations (QSL / eQSL / LoTW defaults)' , id : 'confirmations' } ,
{ kind : 'item' , label : 'External services (QRZ.com, Clublog, LoTW…)' , id : 'external-services' } ,
] ,
} ,
{
@@ -221,6 +224,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
profiles : 'Profiles' ,
operating : 'Operating conditions' ,
confirmations : 'Confirmations' ,
'external-services' : 'External services' ,
lookup : 'Callsign Lookup' ,
'lists-bands' : 'Bands' ,
'lists-modes' : 'Modes & default RST' ,
@@ -368,15 +372,38 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
qsl_sent : string ; qsl_rcvd : string ;
lotw_sent : string ; lotw_rcvd : string ;
eqsl_sent : string ; eqsl_rcvd : string ;
clublog_status : string ; hrdlog_status : 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 : '' ,
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 ; password : string ; callsign : string ;
force_station_callsign : string ;
auto_upload : boolean ; upload_mode : string ;
} ;
type ExternalServices = { qrz : ExtServiceCfg ; clublog : ExtServiceCfg } ;
const emptyExtCfg = ( ) : ExtServiceCfg = > ( {
api_key : '' , email : '' , password : '' , callsign : '' ,
force_station_callsign : '' , auto_upload : false , upload_mode : 'immediate' ,
} ) ;
const [ extSvc , setExtSvc ] = useState < ExternalServices > ( {
qrz : emptyExtCfg ( ) , clublog : 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 ) ;
// 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 : '' ,
@@ -450,9 +477,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
useEffect ( ( ) = > {
( async ( ) = > {
try {
const [ l , ls , c , ap , r , b , qd ] = await Promise . all ( [
const [ l , ls , c , ap , r , b , qd , es ] = await Promise . all ( [
GetLookupSettings ( ) , GetListsSettings ( ) , GetCATSettings ( ) , GetActiveProfile ( ) ,
GetRotatorSettings ( ) , GetBackupSettings ( ) , GetQSLDefaults ( ) ,
GetRotatorSettings ( ) , GetBackupSettings ( ) , GetQSLDefaults ( ) , GetExternalServices ( ) ,
] ) ;
setLookup ( l ) ;
setActiveProfile ( ap as Profile ) ;
@@ -463,6 +490,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setRotator ( r ) ;
setBackupCfg ( b as any ) ;
setQslDefaults ( qd as any ) ;
setExtSvc ( es as any ) ;
} catch ( e : any ) {
setErr ( String ( e ? . message ? ? e ) ) ;
} finally {
@@ -582,6 +610,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
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.' ) ;
@@ -1555,7 +1584,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="text-[11px] text-muted-foreground">
Upload status fields (Clublog / HRDLog) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
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">
@@ -1575,6 +1604,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</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>
</>
@@ -1727,12 +1765,227 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
);
}
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' },
];
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);
}
}
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 - [ 9 px ] opacity - 60 ">soon</span>}
</button>
))}
</div>
{extSvcTab === 'qrz' ? (
<div className=" space - y - 4 max - w - 2 xl ">
<div className=" grid grid - cols - [ 170 px_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=" text - [ 11 px ] text - muted - foreground bg - amber - 50 border border - amber - 200 rounded - md p - 2.5 leading - relaxed ">
QRZ.com discards station calls that differ from the one registered on the logbook.
Setting your registered callsign here rewrites <span className=" font - mono ">STATION_CALLSIGN</span> on
upload, so a QSO logged with a <span className=" font - mono ">/P</span> or <span className=" font - mono ">/QRP</span> suffix
is still accepted. Note this also applies to QSOs made with a country prefix/suffix.
</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 - [ 170 px_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>
</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 - 2 xl ">
<div className=" grid grid - cols - [ 170 px_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="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
Club Log uploads each QSO in real time using your account email, password and the
logbook callsign — no API key needed for QSO upload.
</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>
</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>
) : (
<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 >
) }
< / >
) ;
}
// 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 ,