After the mode, which is the order a cluster line is read in: "CW POTA FR-11553". Two sources, because the QSO can be at either stage. While it is being typed the entry panel holds the references as "CODE@REF;CODE@REF"; once logged they live on the row as the materialised award_refs. The Send Spot window takes the entry's when there are any and falls back to the last logged QSO — the same fallback the callsign and frequency defaults already use — and picking a QSO from the Latest list fills in that QSO's own. A self-spot is the opposite case and gets its own builder: it announces OUR station, so it carries MY_POTA_REF and friends. Using the QSO's award references there would spot us with the park number of the station we just worked, announcing us from somewhere we are not. Both cap at the 30 characters a cluster node keeps, and add a reference whole or not at all — a truncated park number is worse than none, since nobody can act on it, and it still costs everyone who reads the spot. Only the comment we BUILD is held to that; what the operator types is their own business.
199 lines
7.7 KiB
TypeScript
199 lines
7.7 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';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
export interface RecentSpotQSO {
|
|
callsign: string;
|
|
freqKHz: number;
|
|
mode: string;
|
|
band?: string;
|
|
// Award references logged for this QSO, each one complete and ready to read
|
|
// on a cluster line ("POTA FR-11553"). Empty for an ordinary contact.
|
|
refs?: string[];
|
|
}
|
|
|
|
// SPOT_COMMENT_MAX is the DX cluster comment field: 30 characters, and nodes
|
|
// truncate what does not fit without saying so. Only the comment BUILT here is
|
|
// held to it — whatever the operator types is their own business.
|
|
const SPOT_COMMENT_MAX = 30;
|
|
|
|
// spotComment puts the award references after the mode, the order a cluster
|
|
// line is read in: "CW POTA FR-11553".
|
|
//
|
|
// A reference goes in whole or not at all. Truncation would leave "CW POTA" or
|
|
// half a park number on the air, which is worse than saying nothing: a spot
|
|
// nobody can act on still costs everyone who reads it.
|
|
export function spotComment(mode: string, refs?: string[]): string {
|
|
let out = (mode || '').trim();
|
|
for (const ref of refs ?? []) {
|
|
const one = ref.trim();
|
|
if (!one) continue;
|
|
const next = out ? `${out} ${one}` : one;
|
|
if (next.length > SPOT_COMMENT_MAX) continue; // a shorter one may still fit
|
|
out = next;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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;
|
|
// Award references on the QSO being entered (or on the last logged one),
|
|
// appended to the comment after the mode.
|
|
defaultRefs?: 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, defaultRefs, targetName, recent, onSend }: Props) {
|
|
const { t } = useI18n();
|
|
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(spotComment(defaultMode, defaultRefs));
|
|
setError('');
|
|
setOk(false);
|
|
// Focus the freq if the call is already known, else the call.
|
|
setTimeout(() => callRef.current?.focus(), 50);
|
|
}, [open, defaultCall, defaultFreqKHz, defaultMode, defaultRefs]);
|
|
|
|
async function send() {
|
|
const c = call.trim().toUpperCase();
|
|
const f = parseFloat(freqKHz);
|
|
if (!c) { setError(t('spm.callRequired')); return; }
|
|
if (!f || f <= 0) { setError(t('spm.freqRequired')); 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));
|
|
setMessage(spotComment(q.mode, q.refs));
|
|
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" /> {t('spm.title')}
|
|
</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">{t('spm.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={t('spm.callPh')}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col w-32">
|
|
<Label className="mb-1">{t('spm.frequency')}</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">{t('spm.message')}</Label>
|
|
<Input
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
|
placeholder={t('spm.messagePh')}
|
|
/>
|
|
</div>
|
|
|
|
{recent.length > 0 && (
|
|
<div>
|
|
<Label className="mb-1 block">{t('spm.latestQsos')}</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-danger">{error}</div>}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<span className="text-[11px] text-muted-foreground mr-auto self-center">
|
|
{ok ? t('spm.spotSent') : targetName ? `→ ${targetName}` : t('spm.masterCluster')}
|
|
</span>
|
|
<Button variant="outline" onClick={onClose} disabled={busy}>{t('spm.cancel')}</Button>
|
|
<Button onClick={send} disabled={busy}>
|
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Satellite className="size-3.5" />}
|
|
{busy ? t('spm.sending') : t('spm.sendSpot')}
|
|
</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$/, '');
|
|
}
|