168 lines
6.3 KiB
TypeScript
168 lines
6.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Satellite, Loader2 } from 'lucide-react';
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
export interface RecentSpotQSO {
|
|
callsign: string;
|
|
freqKHz: number;
|
|
mode: string;
|
|
band?: string;
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
// Pre-fill values: callsign from the QSO entry (or last logged), the
|
|
// current TX freq in kHz, and the current mode (goes into the comment).
|
|
defaultCall: string;
|
|
defaultFreqKHz: number;
|
|
defaultMode: string;
|
|
// Master cluster name, shown so the user knows where the spot goes.
|
|
targetName?: string;
|
|
recent: RecentSpotQSO[];
|
|
onSend: (call: string, freqKHz: number, comment: string) => Promise<void>;
|
|
}
|
|
|
|
// SendSpotModal — Log4OM-style "Send Spot" window. Announces a DX spot on
|
|
// the master cluster: callsign + frequency (kHz) + a free message (defaults
|
|
// to the mode). A "Latest QSOs" list lets the operator one-click a recent
|
|
// contact into the form.
|
|
export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defaultMode, targetName, recent, onSend }: Props) {
|
|
const [call, setCall] = useState('');
|
|
const [freqKHz, setFreqKHz] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [ok, setOk] = useState(false);
|
|
const callRef = useRef<HTMLInputElement>(null);
|
|
|
|
// (Re)initialise the form each time the dialog opens.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setCall((defaultCall || '').toUpperCase());
|
|
setFreqKHz(defaultFreqKHz > 0 ? trimKHz(defaultFreqKHz) : '');
|
|
setMessage(defaultMode || '');
|
|
setError('');
|
|
setOk(false);
|
|
// Focus the freq if the call is already known, else the call.
|
|
setTimeout(() => callRef.current?.focus(), 50);
|
|
}, [open, defaultCall, defaultFreqKHz, defaultMode]);
|
|
|
|
async function send() {
|
|
const c = call.trim().toUpperCase();
|
|
const f = parseFloat(freqKHz);
|
|
if (!c) { setError('Callsign required'); return; }
|
|
if (!f || f <= 0) { setError('Frequency (kHz) required'); return; }
|
|
setBusy(true);
|
|
setError('');
|
|
try {
|
|
await onSend(c, f, message.trim());
|
|
setOk(true);
|
|
// Brief success flash, then close.
|
|
setTimeout(() => { setOk(false); onClose(); }, 700);
|
|
} catch (e: any) {
|
|
setError(String(e?.message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function pick(q: RecentSpotQSO) {
|
|
setCall(q.callsign.toUpperCase());
|
|
if (q.freqKHz > 0) setFreqKHz(trimKHz(q.freqKHz));
|
|
if (q.mode) setMessage(q.mode);
|
|
setError('');
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Satellite className="size-4 text-primary" /> Send DX Spot
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="px-5 py-3 space-y-3">
|
|
<div className="flex gap-3">
|
|
<div className="flex flex-col flex-1">
|
|
<Label className="mb-1">Callsign</Label>
|
|
<Input
|
|
ref={callRef}
|
|
className="font-mono uppercase font-bold"
|
|
value={call}
|
|
onChange={(e) => setCall(e.target.value.toUpperCase())}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
|
placeholder="DX call"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col w-32">
|
|
<Label className="mb-1">Frequency (kHz)</Label>
|
|
<Input
|
|
className="font-mono"
|
|
value={freqKHz}
|
|
onChange={(e) => setFreqKHz(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
|
placeholder="14205"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<Label className="mb-1">Message</Label>
|
|
<Input
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
|
placeholder="e.g. CW · TNX QSO"
|
|
/>
|
|
</div>
|
|
|
|
{recent.length > 0 && (
|
|
<div>
|
|
<Label className="mb-1 block">Latest QSOs</Label>
|
|
<div className="max-h-40 overflow-y-auto rounded-md border border-border divide-y divide-border/60">
|
|
{recent.map((q, i) => (
|
|
<button
|
|
key={`${q.callsign}-${i}`}
|
|
type="button"
|
|
onClick={() => pick(q)}
|
|
className="flex w-full items-center gap-2 px-2 py-1 text-left text-xs hover:bg-accent/40"
|
|
>
|
|
<span className="font-mono font-bold w-24 truncate">{q.callsign}</span>
|
|
<span className="font-mono text-muted-foreground w-20 text-right">{q.freqKHz > 0 ? trimKHz(q.freqKHz) : '—'}</span>
|
|
<span className="text-muted-foreground">{q.mode || ''}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="text-xs text-rose-600">{error}</div>}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<span className="text-[11px] text-muted-foreground mr-auto self-center">
|
|
{ok ? 'Spot sent ✓' : targetName ? `→ ${targetName}` : 'Master cluster'}
|
|
</span>
|
|
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
|
<Button onClick={send} disabled={busy}>
|
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
|
|
{busy ? 'Sending…' : 'Send spot'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// trimKHz formats a kHz value without a trailing ".0" (14205) but keeps
|
|
// sub-kHz precision when present (10138.7).
|
|
function trimKHz(khz: number): string {
|
|
return String(Math.round(khz * 10) / 10).replace(/\.0$/, '');
|
|
}
|