qsl designer

This commit is contained in:
2026-06-11 21:54:35 +02:00
parent 6150498a9e
commit 408b29896c
252 changed files with 13989 additions and 277 deletions
+117 -25
View File
@@ -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 12 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?"