qsl designer
This commit is contained in:
+117
-25
@@ -14,6 +14,7 @@ import {
|
||||
WorkedBefore,
|
||||
SetCompactMode,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||
@@ -38,6 +39,8 @@ import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm,
|
||||
|
||||
import { Menubar, type Menu } from '@/components/Menubar';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
@@ -430,6 +433,18 @@ export default function App() {
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [error, setError] = useState('');
|
||||
const [migratedBanner, setMigratedBanner] = useState(false);
|
||||
// Secret vault (encrypted passwords): prompt to unlock at launch when a
|
||||
// passphrase is configured but not yet entered this session.
|
||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||
const [unlockPass, setUnlockPass] = useState('');
|
||||
const [unlockErr, setUnlockErr] = useState('');
|
||||
const [unlockBusy, setUnlockBusy] = useState(false);
|
||||
const doUnlock = async () => {
|
||||
setUnlockBusy(true); setUnlockErr('');
|
||||
try { await UnlockSecrets(unlockPass); setUnlockOpen(false); setUnlockPass(''); }
|
||||
catch (e: any) { setUnlockErr(String(e?.message ?? e)); }
|
||||
finally { setUnlockBusy(false); }
|
||||
};
|
||||
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
||||
// like "spot sent" where a blocking error banner would be overkill.
|
||||
const [toast, setToast] = useState('');
|
||||
@@ -460,9 +475,14 @@ export default function App() {
|
||||
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [recording, recTick]);
|
||||
// restartRecordingForNewTarget (re)starts the take for a new programmatic
|
||||
// target (clicked spot / external app via UDP) and resets the elapsed timer.
|
||||
const restartRecordingForNewTarget = () => {
|
||||
// The callsign the in-progress recording belongs to (uppercased; '' = none).
|
||||
// Lets us restart from zero when the operator edits the call to a different
|
||||
// station mid-recording, instead of continuing the old take.
|
||||
const recordingCallRef = useRef('');
|
||||
// restartRecordingForNewTarget (re)starts the take for a new target (clicked
|
||||
// spot / external app / edited callsign) and resets the elapsed timer.
|
||||
const restartRecordingForNewTarget = (forCall?: string) => {
|
||||
if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
|
||||
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||
};
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -474,6 +494,8 @@ export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('recent');
|
||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||
const [eqslQsoId, setEqslQsoId] = useState<number | null>(null); // QSO being sent as eQSL
|
||||
function closeQslTab() {
|
||||
setQslTabOpen(false);
|
||||
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
||||
@@ -786,7 +808,8 @@ export default function App() {
|
||||
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
|
||||
const offUploaded = EventsOn('extsvc:uploaded', ping);
|
||||
const offDone = EventsOn('qslmgr:done', ping);
|
||||
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
|
||||
const offEqsl = EventsOn('qsl:sent', ping);
|
||||
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||
}, [refresh]);
|
||||
|
||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||
@@ -917,9 +940,9 @@ export default function App() {
|
||||
if (s.band) setBand(s.band);
|
||||
}
|
||||
if (m) applyModeFromSpot(m);
|
||||
onCallsignInput(s.dx_call);
|
||||
onCallsignInput(s.dx_call, { force: true });
|
||||
applySpotPOTA((s as any).pota_ref);
|
||||
if (s.dx_call?.trim()) restartRecordingForNewTarget();
|
||||
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
@@ -930,6 +953,11 @@ export default function App() {
|
||||
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
||||
if ((st as any).migrated_from_app_data) setMigratedBanner(true);
|
||||
} catch {}
|
||||
// Prompt to unlock encrypted passwords if a passphrase is configured.
|
||||
try {
|
||||
const ss: any = await GetSecretStatus();
|
||||
if (ss?.has_passphrase && !ss?.unlocked) setUnlockOpen(true);
|
||||
} catch {}
|
||||
loadStation();
|
||||
loadLists();
|
||||
loadCATCfg();
|
||||
@@ -1063,15 +1091,15 @@ export default function App() {
|
||||
lastUdpCallRef.current = upper; // remember this broadcast either way
|
||||
if (current === upper) return false; // already shown → no-op
|
||||
if (current !== '' && current !== prev) return false; // user typed a different call → leave it
|
||||
onCallsignInput(call);
|
||||
onCallsignInput(call, { force: true }); // programmatic → always look up
|
||||
return true;
|
||||
};
|
||||
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
|
||||
// External app moved to a new station → fresh recording for the new target.
|
||||
if (applyUdpCall(p?.call)) restartRecordingForNewTarget();
|
||||
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? ''));
|
||||
});
|
||||
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
||||
if (applyUdpCall(raw)) restartRecordingForNewTarget();
|
||||
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? ''));
|
||||
});
|
||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||
@@ -1266,7 +1294,7 @@ export default function App() {
|
||||
function resetEntry() {
|
||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||
// log, or if the recorder is off).
|
||||
QSOAudioCancel(); setRecording(false);
|
||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||
setCallsign(''); setComment(''); setNote('');
|
||||
if (!locks.start) setQsoStartedAt(null);
|
||||
if (!locks.end) setQsoEndedAt(null);
|
||||
@@ -1470,17 +1498,23 @@ export default function App() {
|
||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||
}));
|
||||
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
||||
// Begin the recording once the call resolves (a real, ≥3-char callsign,
|
||||
// not 1–2 stray letters). Covers the fast CW workflow (type → Enter to log
|
||||
// via the WinKeyer, no blur). No-op if the recorder is off or already
|
||||
// running; the pre-roll covers the lead-in.
|
||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||
// Recording: tie it to the resolved callsign. Start once a real (≥3-char)
|
||||
// call resolves — covers the fast CW workflow (type → Enter, no blur). If
|
||||
// we're already recording a DIFFERENT call (the operator edited the
|
||||
// callsign), restart from zero instead of continuing the old take.
|
||||
const recCall = call.toUpperCase();
|
||||
if (recordingCallRef.current && recordingCallRef.current !== recCall) {
|
||||
restartRecordingForNewTarget(recCall);
|
||||
} else {
|
||||
recordingCallRef.current = recCall;
|
||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||
}
|
||||
} catch (e: any) {
|
||||
setLookupResult(null);
|
||||
setLookupError(String(e?.message ?? e));
|
||||
} finally { setLookupBusy(false); }
|
||||
}
|
||||
function scheduleLookup(value: string) {
|
||||
function scheduleLookup(value: string, force?: boolean) {
|
||||
setLookupError('');
|
||||
if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
|
||||
if (wbTimerRef.current) window.clearTimeout(wbTimerRef.current);
|
||||
@@ -1490,8 +1524,14 @@ export default function App() {
|
||||
if (lastLookedUpRef.current !== '') resetAutoFill();
|
||||
return;
|
||||
}
|
||||
lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400);
|
||||
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
||||
// Option: defer the (network) callsign lookup until the operator leaves the
|
||||
// Call field instead of firing it as they type. `force` (programmatic set —
|
||||
// clicked spot / external app) always looks up, since there's no blur to
|
||||
// wait for. Worked-before stays live (local, feeds the band matrix).
|
||||
if (force || localStorage.getItem('opslog.lookupOnBlur') !== '1') {
|
||||
lookupTimerRef.current = window.setTimeout(() => runLookup(call), force ? 0 : 400);
|
||||
}
|
||||
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
||||
}
|
||||
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's
|
||||
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh
|
||||
@@ -1501,7 +1541,7 @@ export default function App() {
|
||||
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
|
||||
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
|
||||
}
|
||||
function onCallsignInput(v: string) {
|
||||
function onCallsignInput(v: string, opts?: { force?: boolean }) {
|
||||
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
||||
// on every status packet. If it matches what's already in the entry,
|
||||
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
|
||||
@@ -1512,7 +1552,7 @@ export default function App() {
|
||||
// keeps the pre-roll from before this); clearing it discards the take.
|
||||
// Recording START happens on blur (leaving the callsign field), NOT here —
|
||||
// you may type a call and work it minutes later. Clearing it cancels.
|
||||
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); }
|
||||
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; }
|
||||
const wasEmpty = callsign.trim() === '';
|
||||
const isEmpty = v.trim() === '';
|
||||
if (wasEmpty && !isEmpty && !locks.start) {
|
||||
@@ -1526,7 +1566,10 @@ export default function App() {
|
||||
setQsoStartedAt(null);
|
||||
}
|
||||
setCallsign(v);
|
||||
scheduleLookup(v);
|
||||
// opts.force = the call was set programmatically (clicked spot / external
|
||||
// app): there's no "leaving the field", so look it up now regardless of the
|
||||
// lookup-on-blur option.
|
||||
scheduleLookup(v, opts?.force);
|
||||
}
|
||||
function markEdited(field: string) { userEditedRef.current.add(field); }
|
||||
|
||||
@@ -1608,6 +1651,7 @@ export default function App() {
|
||||
]},
|
||||
{ name: 'tools', label: 'Tools', items: [
|
||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||
{ type: 'item', label: 'QSL Card Designer…', action: 'tools.qsldesigner' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
||||
@@ -1632,6 +1676,7 @@ export default function App() {
|
||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
@@ -1749,9 +1794,14 @@ export default function App() {
|
||||
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
|
||||
value={callsign}
|
||||
onChange={(e) => onCallsignInput(e.target.value)}
|
||||
// Start the QSO recording when leaving the callsign field (the pre-roll
|
||||
// covers the seconds before). No-op when the recorder is off.
|
||||
onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
|
||||
onBlur={() => {
|
||||
const c = callsign.trim();
|
||||
if (!c) return;
|
||||
// Lookup-on-blur mode: run the deferred lookup now (it also starts the
|
||||
// recording). Otherwise just start the recording (lookup already ran).
|
||||
if (localStorage.getItem('opslog.lookupOnBlur') === '1') runLookup(c.toUpperCase());
|
||||
else QSOAudioBegin().then(setRecording).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2264,6 +2314,38 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
||||
skipping leaves lookups/uploads without their passwords until unlocked. */}
|
||||
{unlockOpen && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="w-[360px] rounded-lg border border-border bg-card shadow-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Lock className="size-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">Unlock saved passwords</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Enter your passphrase to decrypt your QRZ / HamQTH / LoTW / SMTP passwords for this session.
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={unlockPass}
|
||||
placeholder="Passphrase"
|
||||
onChange={(e) => { setUnlockPass(e.target.value); setUnlockErr(''); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && unlockPass) doUnlock(); }}
|
||||
className="mb-2"
|
||||
/>
|
||||
{unlockErr && <div className="text-xs text-destructive mb-2">{unlockErr}</div>}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => { setUnlockOpen(false); setUnlockPass(''); setUnlockErr(''); }}>Later</Button>
|
||||
<Button size="sm" disabled={!unlockPass || unlockBusy} onClick={doUnlock}>
|
||||
{unlockBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />} Unlock
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* "You have been spotted" banner — shows when our own callsign appears
|
||||
in a cluster spot (Log4OM-style). Floated as a bottom-center overlay
|
||||
@@ -2587,6 +2669,7 @@ export default function App() {
|
||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onSendTo={bulkSendTo}
|
||||
onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onExportSelected={exportSelectedADIF}
|
||||
onExportFiltered={exportFilteredADIF}
|
||||
onRowSelected={(id) => setSelectedId(id)}
|
||||
@@ -2933,7 +3016,8 @@ export default function App() {
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} />
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
@@ -3089,6 +3173,14 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
||||
<SendEQSLModal
|
||||
open={eqslQsoId !== null}
|
||||
qsoId={eqslQsoId}
|
||||
onClose={() => setEqslQsoId(null)}
|
||||
onOpenDesigner={() => setQslDesignerOpen(true)}
|
||||
/>
|
||||
|
||||
{deletingQSO && (
|
||||
<ConfirmDialog
|
||||
title="Delete QSO?"
|
||||
|
||||
Reference in New Issue
Block a user