aduio mail

This commit is contained in:
2026-06-05 02:29:49 +02:00
parent a2a29c66d2
commit 95fdc1ccd1
14 changed files with 673 additions and 126 deletions
+68 -43
View File
@@ -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>