update
This commit is contained in:
+124
-34
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Trash2, Unlock, X,
|
||||
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Square, Trash2, Unlock, X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||
RefreshCtyDat,
|
||||
RotatorGoTo, RotatorStop,
|
||||
GetCATSettings,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
|
||||
@@ -32,6 +33,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
@@ -344,6 +349,11 @@ export default function App() {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
||||
const [importDupsOpen, setImportDupsOpen] = useState(false);
|
||||
// ADIF import confirmation: after the user picks a file, hold the path
|
||||
// until they confirm the options (skip duplicates etc.).
|
||||
const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
|
||||
const [importSkipDups, setImportSkipDups] = useState(true);
|
||||
|
||||
// === Lookup + WB ===
|
||||
const [lookupResult, setLookupResult] = useState<LookupResult | null>(null);
|
||||
@@ -673,14 +683,29 @@ export default function App() {
|
||||
try {
|
||||
const path = await OpenADIFFile();
|
||||
if (!path) return;
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
setImportErrorsOpen(false);
|
||||
const res = await ImportADIF(path);
|
||||
// Stash the path and open the options dialog. The actual import
|
||||
// is fired from runImport() when the user clicks "Import".
|
||||
setPendingImportPath(path);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
const path = pendingImportPath;
|
||||
if (!path || importing) return;
|
||||
setPendingImportPath(null);
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
setImportErrorsOpen(false);
|
||||
setImportDupsOpen(false);
|
||||
try {
|
||||
const res = await ImportADIF(path, importSkipDups);
|
||||
setImportResult(res);
|
||||
await refresh();
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
finally { setImporting(false); }
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const menus: Menu[] = useMemo(() => [
|
||||
@@ -820,37 +845,55 @@ export default function App() {
|
||||
<div className="w-px h-4 bg-border mx-2" />
|
||||
<Badge variant="accent" className="font-mono">{band}</Badge>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono" variant="outline">{mode}</Badge>
|
||||
{/* Bearing pill — clickable hook for the future rotator action.
|
||||
Today it's a passive display; once the rotor backend lands we
|
||||
wire onClick → rotate to short-path azimuth. */}
|
||||
{/* Bearing controls — three separate buttons so SP and LP are
|
||||
both directly clickable, plus an always-visible Stop. The
|
||||
old Shift/Ctrl shortcuts were not discoverable enough. */}
|
||||
{(() => {
|
||||
const p = pathBetween(station.my_grid, grid);
|
||||
const disabled = !p;
|
||||
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => { /* TODO: rotor.gotoAzimuth(p.bearingShort) */ }}
|
||||
title={
|
||||
p
|
||||
? `Short-path ${Math.round(p.bearingShort)}° · ${Math.round(p.distanceShort).toLocaleString()} km\nLong-path ${Math.round(p.bearingLong)}° · ${Math.round(p.distanceLong).toLocaleString()} km\n(rotor control coming soon)`
|
||||
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')
|
||||
}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono font-semibold border transition-colors',
|
||||
disabled
|
||||
? 'bg-muted/40 text-muted-foreground/60 border-border/40 cursor-not-allowed'
|
||||
: 'bg-sky-100 text-sky-800 border-sky-300 hover:bg-sky-200 cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<Compass className="size-3" />
|
||||
{p ? `${Math.round(p.bearingShort)}°` : '—°'}
|
||||
{p && (
|
||||
<span className="text-[9px] text-sky-700/80 font-medium ml-0.5">
|
||||
LP {Math.round(p.bearingLong)}°
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="inline-flex items-center rounded-full border border-sky-300 bg-sky-100 overflow-hidden text-[11px] font-mono font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => p && goto(p.bearingShort)}
|
||||
title={p
|
||||
? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
|
||||
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
|
||||
disabled
|
||||
? 'text-muted-foreground/60 cursor-not-allowed'
|
||||
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
|
||||
)}
|
||||
>
|
||||
<Compass className="size-3" />
|
||||
{p ? `${Math.round(p.bearingShort)}°` : '—°'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => p && goto(p.bearingLong)}
|
||||
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 border-l border-sky-300 text-[10px] transition-colors',
|
||||
disabled
|
||||
? 'text-muted-foreground/60 cursor-not-allowed'
|
||||
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
|
||||
)}
|
||||
>
|
||||
LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
|
||||
title="Stop rotation"
|
||||
className="px-1.5 py-0.5 border-l border-sky-300 text-rose-700 hover:bg-rose-100 hover:text-rose-800 cursor-pointer transition-colors"
|
||||
>
|
||||
<Square className="size-2.5 fill-current" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{catState.enabled && (
|
||||
@@ -1226,8 +1269,19 @@ export default function App() {
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<strong>Import complete.</strong>
|
||||
<Badge variant="outline" className="bg-white/60 font-mono text-emerald-700 border-emerald-300">{importResult.imported} imported</Badge>
|
||||
{importResult.duplicates > 0 && (
|
||||
<Badge variant="outline" className="bg-white/60 font-mono text-sky-700 border-sky-300">{importResult.duplicates} duplicates</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="bg-white/60 font-mono text-amber-700 border-amber-300">{importResult.skipped} skipped</Badge>
|
||||
<Badge variant="outline" className="bg-white/60 font-mono">{importResult.total} total</Badge>
|
||||
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
|
||||
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
|
||||
{importDupsOpen ? 'Hide' : 'Show'} duplicates
|
||||
{importResult.duplicates > importResult.duplicate_samples.length
|
||||
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
|
||||
: ''}
|
||||
</button>
|
||||
)}
|
||||
{importResult.errors && importResult.errors.length > 0 && (
|
||||
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
|
||||
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''}
|
||||
@@ -1235,6 +1289,11 @@ export default function App() {
|
||||
)}
|
||||
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
|
||||
</div>
|
||||
{importDupsOpen && importResult.duplicate_samples && (
|
||||
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
|
||||
{importResult.duplicate_samples.map((d, i) => <li key={i}>{d}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{importErrorsOpen && importResult.errors && (
|
||||
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
|
||||
{importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
|
||||
@@ -1352,6 +1411,37 @@ export default function App() {
|
||||
onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }}
|
||||
/>
|
||||
)}
|
||||
{pendingImportPath && (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
|
||||
<DialogContent className="max-w-lg px-6">
|
||||
<DialogHeader className="px-2">
|
||||
<DialogTitle>Import ADIF</DialogTitle>
|
||||
<DialogDescription className="text-xs break-all">
|
||||
{pendingImportPath}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2 px-2 space-y-3">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={importSkipDups}
|
||||
onCheckedChange={(c) => setImportSkipDups(!!c)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Skip duplicates
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Records that match an existing QSO on (callsign + UTC minute + band + mode) are not re-inserted. Uncheck to import everything as-is — useful for merging two logs that overlap intentionally.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter className="px-2">
|
||||
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
|
||||
<Button onClick={runImport}>Import</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user