Files
OpsLog/frontend/src/components/SendSpotModal.tsx
T
2026-05-30 01:35:50 +02:00

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$/, '');
}