External services (QRZ/Clublog/LoTW) + QSL Manager
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UploadCloud, Search, Loader2 } from 'lucide-react';
|
||||
import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations } from '../../wailsjs/go/main/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
|
||||
type UploadRow = {
|
||||
@@ -17,6 +17,11 @@ type UploadRow = {
|
||||
band: string; mode: string; country: string; status: string;
|
||||
};
|
||||
|
||||
type Confirmation = {
|
||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
||||
};
|
||||
|
||||
const SERVICES = [
|
||||
{ v: 'qrz', label: 'QRZ.com' },
|
||||
{ v: 'clublog', label: 'Club Log' },
|
||||
@@ -48,18 +53,30 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [addNotFound, setAddNotFound] = useState(false);
|
||||
|
||||
// 'upload' shows the Select-required search results; 'confirmations' shows
|
||||
// the rows returned by a Download.
|
||||
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
|
||||
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
|
||||
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const [logTitle, setLogTitle] = useState('');
|
||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [uploadDone, setUploadDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} uploaded —`]);
|
||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||
setUploadDone(true);
|
||||
});
|
||||
return () => { offLog(); offDone(); };
|
||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||
setConfirmations((list ?? []) as Confirmation[]);
|
||||
setViewMode('confirmations');
|
||||
});
|
||||
return () => { offLog(); offDone(); offConf(); };
|
||||
}, []);
|
||||
|
||||
const selectedCount = selected.size;
|
||||
@@ -73,6 +90,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
const list = (r ?? []) as UploadRow[];
|
||||
setRows(list);
|
||||
setSelected(new Set(list.map((x) => x.id))); // auto-select all found
|
||||
setViewMode('upload');
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
setRows([]);
|
||||
@@ -98,6 +116,8 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
if (ids.length === 0) return;
|
||||
setLogLines([]);
|
||||
setUploadDone(false);
|
||||
setLogAction('upload');
|
||||
setLogTitle('Uploading to ' + serviceLabel);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
await UploadQSOsManual(service, ids);
|
||||
@@ -107,10 +127,25 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
setLogLines([]);
|
||||
setUploadDone(false);
|
||||
setLogAction('download');
|
||||
setLogTitle('Downloading confirmations from ' + serviceLabel);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
await DownloadConfirmations(service, addNotFound);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
|
||||
setUploadDone(true);
|
||||
}
|
||||
}
|
||||
|
||||
function closeLog() {
|
||||
setLogOpen(false);
|
||||
// Refresh the list so uploaded QSOs drop out of the current filter.
|
||||
selectRequired();
|
||||
// After an upload, refresh the search so uploaded QSOs drop out of the
|
||||
// filter. After a download, leave the confirmations list on screen.
|
||||
if (logAction === 'upload') selectRequired();
|
||||
}
|
||||
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
@@ -150,14 +185,56 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rows.length} found · {selectedCount} selected
|
||||
{viewMode === 'confirmations'
|
||||
? `${confirmations.length} confirmation(s) received`
|
||||
: `${rows.length} found · ${selectedCount} selected`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results grid */}
|
||||
<div className="overflow-auto px-4 py-2 min-h-[200px]">
|
||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||
{rows.length === 0 ? (
|
||||
|
||||
{viewMode === 'confirmations' ? (
|
||||
confirmations.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">No new confirmations.</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1.5 px-2">Date UTC</th>
|
||||
<th className="py-1.5 px-2">Callsign</th>
|
||||
<th className="py-1.5 px-2">Band</th>
|
||||
<th className="py-1.5 px-2">Mode</th>
|
||||
<th className="py-1.5 px-2">Country</th>
|
||||
<th className="py-1.5 px-2">New?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{confirmations.map((c, i) => (
|
||||
<tr key={i} className="border-b border-border/40">
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||
<td className="py-1 px-2">{c.band}</td>
|
||||
<td className="py-1 px-2">{c.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||
<td className="py-1 px-2">
|
||||
{c.new_dxcc ? (
|
||||
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
|
||||
) : c.new_band ? (
|
||||
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
|
||||
) : c.new_slot ? (
|
||||
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
Pick a service + sent status, then “Select required”.
|
||||
</div>
|
||||
@@ -197,12 +274,24 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t border-border">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
|
||||
<UploadCloud className="size-3.5" />
|
||||
Upload {selectedCount} to {serviceLabel}
|
||||
</Button>
|
||||
<DialogFooter className="px-4 py-3 border-t border-border sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={download} title="Fetch confirmations from the service and update received status">
|
||||
<DownloadCloud className="size-3.5" />
|
||||
Download confirmations
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
Add not-found
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
|
||||
<UploadCloud className="size-3.5" />
|
||||
Upload {selectedCount} to {serviceLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -211,8 +300,8 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Uploading to {serviceLabel}</DialogTitle>
|
||||
<DialogDescription className="sr-only">Upload progress log.</DialogDescription>
|
||||
<DialogTitle>{logTitle || 'Working…'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">Progress log.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
|
||||
{logLines.length === 0 ? (
|
||||
|
||||
@@ -350,7 +350,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
// 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;
|
||||
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;
|
||||
@@ -358,7 +358,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', email: '', password: '', callsign: '',
|
||||
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',
|
||||
@@ -2022,6 +2022,21 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
) : 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}
|
||||
|
||||
Vendored
+2
@@ -45,6 +45,8 @@ export function DisconnectAllClusters():Promise<void>;
|
||||
|
||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
|
||||
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
|
||||
|
||||
@@ -70,6 +70,10 @@ export function DisconnectClusterServer(arg1) {
|
||||
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadConfirmations(arg1, arg2) {
|
||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DuplicateProfile(arg1, arg2) {
|
||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ export namespace extsvc {
|
||||
export class ServiceConfig {
|
||||
api_key: string;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
callsign: string;
|
||||
force_station_callsign: string;
|
||||
@@ -207,6 +208,7 @@ export namespace extsvc {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.api_key = source["api_key"];
|
||||
this.email = source["email"];
|
||||
this.username = source["username"];
|
||||
this.password = source["password"];
|
||||
this.callsign = source["callsign"];
|
||||
this.force_station_callsign = source["force_station_callsign"];
|
||||
|
||||
Reference in New Issue
Block a user