88 lines
3.8 KiB
TypeScript
88 lines
3.8 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { X, Loader2 } from 'lucide-react';
|
|
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
|
|
|
interface Props {
|
|
code: string; // award code, e.g. "POTA"
|
|
label: string; // display label
|
|
dxcc?: number; // contacted-station DXCC; used when "this country" is on
|
|
countryOnly: boolean;
|
|
value: string; // currently assigned reference
|
|
onChange: (ref: string) => void;
|
|
}
|
|
|
|
// AwardRefPicker — type-ahead search over an award's reference list (POTA parks,
|
|
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
|
|
// result assigns it to the QSO.
|
|
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
|
|
const { t } = useI18n();
|
|
const [q, setQ] = useState('');
|
|
const [results, setResults] = useState<Ref[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const t = window.setTimeout(async () => {
|
|
setBusy(true);
|
|
try {
|
|
const r = await SearchAwardReferences(code, q, countryOnly ? (dxcc ?? 0) : 0, 30);
|
|
setResults((r ?? []) as any);
|
|
} catch { setResults([]); }
|
|
finally { setBusy(false); }
|
|
}, 200);
|
|
return () => window.clearTimeout(t);
|
|
}, [q, open, code, dxcc, countryOnly]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const close = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
|
window.addEventListener('mousedown', close);
|
|
return () => window.removeEventListener('mousedown', close);
|
|
}, [open]);
|
|
|
|
return (
|
|
<div className="grid grid-cols-[60px_1fr] items-center gap-2">
|
|
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
|
<div className="relative" ref={boxRef}>
|
|
{value ? (
|
|
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-success-border bg-success-muted text-success-muted-foreground text-xs">
|
|
<span className="font-mono font-semibold">{value}</span>
|
|
<button className="ml-auto hover:text-success" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button>
|
|
</div>
|
|
) : (
|
|
<input
|
|
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
|
placeholder={t('awrp.searchLabel', { label })}
|
|
value={q}
|
|
onFocus={() => setOpen(true)}
|
|
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
|
/>
|
|
)}
|
|
{open && !value && (
|
|
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
|
|
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('awrp.searching')}</div>}
|
|
{!busy && results.length === 0 && (
|
|
<div className="px-3 py-2 text-muted-foreground">{countryOnly && dxcc ? t('awrp.noMatchDxcc') : t('awrp.noMatch')}</div>
|
|
)}
|
|
{results.map((r) => (
|
|
<button
|
|
key={r.code}
|
|
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
|
onClick={() => { onChange(r.code); setOpen(false); setQ(''); }}
|
|
>
|
|
<span className="font-mono font-semibold shrink-0">{r.code}</span>
|
|
<span className="text-muted-foreground truncate">{r.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|