aduio mail
This commit is contained in:
+68
-43
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, ExternalLink, Hash, Loader2, Lock,
|
||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
AddQSO, ListQSO, CountQSO,
|
||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus,
|
||||
WorkedBefore,
|
||||
@@ -76,6 +76,9 @@ type CATState = Omit<catModels.RigState, 'convertValues'>;
|
||||
|
||||
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
||||
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
||||
// Modes the QSO recorder captures (voice + CW). Mirrors recordableMode() in
|
||||
// app.go — digital modes carry no useful audio and are never recorded.
|
||||
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']);
|
||||
|
||||
const emptyDetails: DetailsState = {
|
||||
state: '', cnty: '', address: '',
|
||||
@@ -419,6 +422,16 @@ export default function App() {
|
||||
setToast(msg);
|
||||
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
|
||||
}, []);
|
||||
// Error banners auto-dismiss after a few seconds (longer than toasts since
|
||||
// they may be multi-line). The X button still closes them immediately.
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const t = window.setTimeout(() => setError(''), 6000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [error]);
|
||||
// True while the QSO recorder is capturing the current contact (set when we
|
||||
// leave the callsign field, cleared on log/cancel). Drives the "REC" badge.
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [filterCallsign, setFilterCallsign] = useState('');
|
||||
const [filterBand, setFilterBand] = useState('');
|
||||
@@ -843,7 +856,6 @@ export default function App() {
|
||||
const mine = myCallRef.current;
|
||||
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
||||
setSelfSpot({ spotter: cleanSpotter(sp.spotter ?? ''), freqKHz: sp.freq_khz, band: sp.band, comment: sp.comment, at: Date.now() });
|
||||
showToast(`You've been spotted by ${cleanSpotter(sp.spotter ?? '') || '?'} on ${sp.freq_khz?.toFixed(1)} kHz`);
|
||||
// Auto-hide 3 s after the last self-spot; a new one resets the timer.
|
||||
if (selfSpotTimerRef.current) window.clearTimeout(selfSpotTimerRef.current);
|
||||
selfSpotTimerRef.current = window.setTimeout(() => setSelfSpot(null), 3000);
|
||||
@@ -878,7 +890,11 @@ export default function App() {
|
||||
await LogUDPLoggedADIF(text);
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
setError('UDP auto-log: ' + String(e?.message ?? e));
|
||||
const msg = String(e?.message ?? e);
|
||||
// A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
|
||||
// show a quiet toast, not a red error.
|
||||
if (/duplicate/i.test(msg)) showToast('UDP QSO already logged — skipped');
|
||||
else setError('UDP auto-log: ' + msg);
|
||||
}
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); };
|
||||
@@ -1048,7 +1064,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();
|
||||
QSOAudioCancel(); setRecording(false);
|
||||
setCallsign(''); setComment(''); setNote('');
|
||||
if (!locks.start) setQsoStartedAt(null);
|
||||
if (!locks.end) setQsoEndedAt(null);
|
||||
@@ -1132,6 +1148,17 @@ export default function App() {
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function bulkSendRecording(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
showToast(`Sending ${ids.length} recording${ids.length > 1 ? 's' : ''} by e-mail…`);
|
||||
let ok = 0; const errs: string[] = [];
|
||||
for (const id of ids) {
|
||||
try { await SendQSORecordingEmail(id as any); ok++; }
|
||||
catch (e: any) { errs.push(String(e?.message ?? e)); }
|
||||
}
|
||||
if (errs.length) setError(`Recording e-mail: ${ok} sent, ${errs.length} failed — ${errs[0]}`);
|
||||
else showToast(`${ok} recording${ok > 1 ? 's' : ''} sent`);
|
||||
}
|
||||
// Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs
|
||||
// on demand (regardless of their current upload status). Runs in the
|
||||
// background; qslmgr:done refreshes the grid when finished.
|
||||
@@ -1238,8 +1265,9 @@ export default function App() {
|
||||
if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
|
||||
// QSO recorder: a non-empty callsign marks the QSO start (the recorder
|
||||
// keeps the pre-roll from before this); clearing it discards the take.
|
||||
// Both are no-ops when the recorder is off.
|
||||
if (v.trim() === '') QSOAudioCancel(); else QSOAudioBegin();
|
||||
// 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); }
|
||||
const wasEmpty = callsign.trim() === '';
|
||||
const isEmpty = v.trim() === '';
|
||||
if (wasEmpty && !isEmpty && !locks.start) {
|
||||
@@ -1454,24 +1482,6 @@ export default function App() {
|
||||
<div className="flex flex-col w-36">
|
||||
<Label className="mb-1 flex items-center gap-2 h-3.5">
|
||||
Callsign
|
||||
{callsign.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
const c = callsign.trim().toUpperCase();
|
||||
// Encode each segment but keep the '/' literal — QRZ's URL is
|
||||
// /db/5Z4/MM0ZBH, not /db/5Z4%2FMM0ZBH (which 404s).
|
||||
const path = c.split('/').map(encodeURIComponent).join('/');
|
||||
OpenExternalURL(`https://www.qrz.com/db/${path}`)
|
||||
.catch((err) => setError(String(err?.message ?? err)));
|
||||
}}
|
||||
title="Open this callsign on QRZ.com"
|
||||
className="inline-flex items-center justify-center size-3.5 rounded text-muted-foreground/60 hover:text-primary transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up…</Badge>}
|
||||
{!lookupBusy && lookupResult && (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline">
|
||||
@@ -1482,12 +1492,22 @@ export default function App() {
|
||||
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[9px] font-semibold tracking-wider text-red-600 whitespace-nowrap pointer-events-none">
|
||||
<span className="size-2 rounded-full bg-red-600 animate-pulse" />REC
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
ref={callsignRef}
|
||||
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(() => {}); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const rstTxBlock = (
|
||||
@@ -1866,14 +1886,6 @@ export default function App() {
|
||||
</header>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive border-b border-destructive/30 px-4 py-2 flex items-start gap-3 text-xs">
|
||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||
<pre className="flex-1 font-mono whitespace-pre-wrap m-0">{error}</pre>
|
||||
<button className="hover:text-destructive/70" onClick={() => setError('')}><X className="size-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QRZ profile photo lightbox — full size, in-app. Click anywhere or
|
||||
press Esc to close; click the image itself doesn't close. */}
|
||||
{photoModal && (
|
||||
@@ -1899,12 +1911,24 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transient success toast (bottom-right). */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<Satellite className="size-4 shrink-0" />
|
||||
<span>{toast}</span>
|
||||
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
|
||||
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
||||
success toast; both auto-dismiss. */}
|
||||
{(error || toast) && (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 max-w-md">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 text-destructive px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||
<pre className="flex-1 font-sans whitespace-pre-wrap m-0 leading-snug">{error}</pre>
|
||||
<button className="ml-1 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
)}
|
||||
{toast && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<Satellite className="size-4 shrink-0" />
|
||||
<span>{toast}</span>
|
||||
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1913,7 +1937,7 @@ export default function App() {
|
||||
so it never shifts the layout (push-down / spring-back) and never
|
||||
covers the entry fields; auto-hides 3s after the last self-spot. */}
|
||||
{!compact && selfSpot && (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<RadioTower className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
You've been spotted by <strong className="font-mono">{selfSpot.spotter || '?'}</strong>
|
||||
@@ -2213,6 +2237,7 @@ export default function App() {
|
||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onSendTo={bulkSendTo}
|
||||
onSendRecording={bulkSendRecording}
|
||||
onRowSelected={(id) => setSelectedId(id)}
|
||||
/>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||
@@ -2544,7 +2569,7 @@ 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} />
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
@@ -2735,8 +2760,8 @@ export default function App() {
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowExportChoice(false)}>Cancel</Button>
|
||||
<DialogFooter className="px-2 bg-transparent border-t-0">
|
||||
<Button variant="outline" onClick={() => setShowExportChoice(false)}>Cancel</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user