chore: release v0.21.3
This commit is contained in:
+73
-10
@@ -22,7 +22,7 @@ import {
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
GetDBConnectionInfo, GetLogbookRevision,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
|
||||
GetScpStatus, ScpLookup,
|
||||
@@ -249,6 +249,13 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
|
||||
function shortCatError(err?: string): string {
|
||||
if (!err) return '';
|
||||
const e = err.toLowerCase();
|
||||
// Checked BEFORE the not-found cases: a privilege mismatch used to be condensed
|
||||
// into "OmniRig not found", which is actively misleading — OmniRig is running,
|
||||
// visibly, and the operator goes looking for a driver or COM-port fault. The
|
||||
// full explanation is in the tooltip and the log.
|
||||
if (e.includes('privilege level') || e.includes('elevation') || e.includes('élévation')) {
|
||||
return 'OmniRig: run as admin?';
|
||||
}
|
||||
if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found';
|
||||
if (e.includes('not connected')) return 'not connected';
|
||||
if (e.includes('coinitialize')) return 'COM error';
|
||||
@@ -1452,6 +1459,17 @@ export default function App() {
|
||||
// entries synchronously (the two run on separate debounce timers).
|
||||
const wbRef = useRef<WB | null>(null);
|
||||
useEffect(() => { wbRef.current = wb; }, [wb]);
|
||||
// Which callsign wbRef.current actually belongs to. Worked-before is replaced
|
||||
// only when its query RESOLVES, so between two calls the ref still holds the
|
||||
// previous station — and the backfill below would happily copy that station's
|
||||
// name, QTH and grid onto the new one.
|
||||
const wbCallRef = useRef('');
|
||||
// A backfill the lookup asked for before the history had arrived. Parked here
|
||||
// and replayed by runWorkedBefore: with no QRZ/HamQTH configured the lookup
|
||||
// answers from local cty.dat almost instantly, while the history query — a
|
||||
// round trip to a possibly remote MySQL logbook — is still in flight, so the
|
||||
// backfill found nothing and nothing ever ran it again.
|
||||
const pendingBackfillRef = useRef<{ call: string; r?: any } | null>(null);
|
||||
const [wbBusy, setWbBusy] = useState(false);
|
||||
|
||||
// Per-award columns for the Recent QSOs / Worked-before grids: load the award
|
||||
@@ -2886,9 +2904,26 @@ export default function App() {
|
||||
|
||||
async function runWorkedBefore(call: string, dxccHint: number = 0) {
|
||||
setWbBusy(true);
|
||||
try { setWb(await WorkedBefore(call, dxccHint)); }
|
||||
catch { setWb(null); }
|
||||
finally { setWbBusy(false); }
|
||||
try {
|
||||
const w = await WorkedBefore(call, dxccHint);
|
||||
setWb(w);
|
||||
// Mirrored synchronously rather than through the effect above: a backfill
|
||||
// parked by the lookup has to read this on the very next line, not a
|
||||
// render later.
|
||||
wbRef.current = w;
|
||||
wbCallRef.current = call;
|
||||
// The lookup finished before this history did and parked its backfill —
|
||||
// run it now that we can answer "who did we work last?".
|
||||
const p = pendingBackfillRef.current;
|
||||
if (p && p.call === call) {
|
||||
pendingBackfillRef.current = null;
|
||||
fillFromLastQso(p.r, call);
|
||||
}
|
||||
} catch {
|
||||
setWb(null);
|
||||
wbRef.current = null;
|
||||
wbCallRef.current = '';
|
||||
} finally { setWbBusy(false); }
|
||||
}
|
||||
// fillFromLastQso enriches the entry from the LAST QSO we logged with this call
|
||||
// when the live lookup came up short — the callsign isn't on QRZ/HamQTH, or no
|
||||
@@ -2896,11 +2931,32 @@ export default function App() {
|
||||
// ONLY the fields the provider left empty and the operator hasn't edited, so a
|
||||
// real QRZ/HamQTH hit is never overridden. `r` is the provider result (omitted
|
||||
// on a lookup error, where every provider field counts as empty).
|
||||
function fillFromLastQso(r?: any) {
|
||||
function fillFromLastQso(r: any, call: string) {
|
||||
// Only ever fill from THIS callsign's history. If worked-before hasn't
|
||||
// resolved for it yet, park the request instead of reading whatever the ref
|
||||
// happens to hold — that would be the previously entered station.
|
||||
if (wbCallRef.current !== call) {
|
||||
pendingBackfillRef.current = { call, r };
|
||||
UILog(`backfill ${call}: history not in yet (have "${wbCallRef.current}") — parked`).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Parked backfills can land late; drop it if the operator has moved on.
|
||||
if (call !== callsignValRef.current.trim().toUpperCase()) {
|
||||
UILog(`backfill ${call}: abandoned, entry now holds "${callsignValRef.current}"`).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const last: any = wbRef.current?.entries?.[0]; // entries are qso_date DESC → most recent
|
||||
if (!last) return;
|
||||
if (!last) {
|
||||
UILog(`backfill ${call}: no prior QSO in the log (count=${wbRef.current?.count ?? 0}, entries=${wbRef.current?.entries?.length ?? 0})`).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const ue = userEditedRef.current;
|
||||
const empty = (v: any) => (v ?? '') === '';
|
||||
// One line saying what we found and what blocked each field, so this can be
|
||||
// diagnosed from another operator's log instead of guessed at.
|
||||
UILog(`backfill ${call}: last QSO ${last.qso_date ?? '?'} name="${last.name ?? ''}" qth="${last.qth ?? ''}" grid="${last.grid ?? ''}"`
|
||||
+ ` | provider name="${r?.name ?? ''}" grid="${r?.grid ?? ''}" src=${r?.source ?? 'none'}`
|
||||
+ ` | edited=[${[...ue].join(',')}]`).catch(() => {});
|
||||
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
|
||||
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
|
||||
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
|
||||
@@ -2985,7 +3041,7 @@ export default function App() {
|
||||
}));
|
||||
// Backfill anything the provider didn't supply from the last time we worked
|
||||
// this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only).
|
||||
fillFromLastQso(r);
|
||||
fillFromLastQso(r, call);
|
||||
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
||||
// The DX location is now known (grid set above) — force the world map to
|
||||
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
|
||||
@@ -3006,7 +3062,7 @@ export default function App() {
|
||||
setLookupResult(null);
|
||||
setLookupError(String(e?.message ?? e));
|
||||
// Lookup failed outright — still borrow from the last logged QSO.
|
||||
fillFromLastQso();
|
||||
fillFromLastQso(undefined, call);
|
||||
}
|
||||
} finally {
|
||||
// Only clear the spinner if we're still the current lookup — a newer one
|
||||
@@ -3021,6 +3077,9 @@ export default function App() {
|
||||
const call = value.trim().toUpperCase();
|
||||
if (call.length < 3) {
|
||||
setLookupResult(null); setWb(null);
|
||||
// Drop the history and any backfill waiting on it, so clearing the field
|
||||
// can't let a late one repopulate the next callsign typed.
|
||||
wbRef.current = null; wbCallRef.current = ''; pendingBackfillRef.current = null;
|
||||
if (lastLookedUpRef.current !== '') resetAutoFill();
|
||||
return;
|
||||
}
|
||||
@@ -4082,7 +4141,9 @@ export default function App() {
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
</div>
|
||||
);
|
||||
case 'flex':
|
||||
@@ -5491,7 +5552,9 @@ export default function App() {
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
|
||||
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
const { t } = useI18n();
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [draft, setDraft] = useState<Rule | null>(null);
|
||||
// Raw text of the callsigns box, kept separately from draft.calls. The list is
|
||||
// normalised (trim + drop empties) on the way into the rule, so binding the
|
||||
// textarea straight to calls.join('\n') round-trips every keystroke through
|
||||
// that normalisation — a trailing newline or space is stripped before React
|
||||
// re-renders, and Enter/Space appear to do nothing. Editing the raw text and
|
||||
// deriving the list from it keeps typing intact.
|
||||
const [callsText, setCallsText] = useState('');
|
||||
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
|
||||
const [emailTo, setEmailTo] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
@@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
}, []);
|
||||
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
|
||||
|
||||
// loadDraft opens a rule in the editor — always through here, so the raw
|
||||
// callsigns text is re-seeded from whatever rule is now being edited.
|
||||
const loadDraft = (r: Rule | null) => {
|
||||
setDraft(r);
|
||||
setCallsText((r?.calls ?? []).join('\n'));
|
||||
};
|
||||
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
|
||||
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
|
||||
if (!d) return d;
|
||||
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
async function save() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
|
||||
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
|
||||
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function del() {
|
||||
if (!draft) return;
|
||||
if (!draft.id) { setDraft(null); return; }
|
||||
if (!draft.id) { loadDraft(null); return; }
|
||||
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
|
||||
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
|
||||
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
@@ -116,12 +129,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
|
||||
{rules.map((r) => (
|
||||
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||
<button key={r.id} onClick={() => { loadDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
||||
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
<Label className="text-xs">{t('altm.callsigns')}</Label>
|
||||
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
|
||||
placeholder={'DL1ABC\nIW3*\n*/P'}
|
||||
value={(draft.calls ?? []).join('\n')}
|
||||
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
|
||||
value={callsText}
|
||||
onChange={(e) => {
|
||||
setCallsText(e.target.value);
|
||||
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
|
||||
}} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('altm.countries')}</Label>
|
||||
|
||||
@@ -377,7 +377,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
|
||||
{/* Left: award list */}
|
||||
<div className="border-r flex flex-col min-h-0">
|
||||
<div className="border-r flex flex-col min-w-0 min-h-0">
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
@@ -422,8 +422,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right: tabbed editor for selected award */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Right: tabbed editor for selected award.
|
||||
min-w-0 is load-bearing: this sits in a `1fr` grid track, and a 1fr
|
||||
track has min-width:auto — it refuses to shrink below its content's
|
||||
intrinsic width, so a wide row (the band/mode chip lists, a long
|
||||
translated label) grew the track and pushed the whole editor out
|
||||
past the dialog instead of scrolling inside it. Same reason on the
|
||||
Tabs below: a flex item defaults to min-width:auto too. */}
|
||||
<div className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||
apply it — that would destroy their work — so we offer it, and say
|
||||
@@ -459,7 +465,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
{!cur ? (
|
||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||
) : (
|
||||
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs defaultValue="info" className="flex flex-col min-w-0 min-h-0 overflow-hidden">
|
||||
<TabsList className="px-3 justify-start">
|
||||
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||
@@ -729,7 +735,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||
{/* Eight buttons on one unwrappable row fitted in English and overflowed
|
||||
in French, where the labels are half again as long. Wrap instead of
|
||||
widening the dialog; gap-2 replaces the base sm:space-x-2, whose
|
||||
margin-left approach leaves no vertical gap once a row wraps. */}
|
||||
<DialogFooter className="px-5 py-3 border-t !flex-row flex-wrap sm:space-x-0 gap-2">
|
||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
|
||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||
|
||||
@@ -34,6 +34,8 @@ const FIELDS: FieldDef[] = [
|
||||
// My station / operator
|
||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
||||
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||
|
||||
@@ -73,6 +73,24 @@ function attOptions(model?: string): { v: string; l: string }[] {
|
||||
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||||
}
|
||||
|
||||
// bandOfHz names the amateur band a frequency falls in, so the band row can show
|
||||
// where the rig actually is. The buttons only ever SENT a frequency and carried
|
||||
// no active state at all, so nothing was highlighted whatever the rig reported.
|
||||
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
|
||||
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
|
||||
function bandOfHz(hz?: number): string {
|
||||
if (!hz || hz <= 0) return '';
|
||||
const mhz = hz / 1_000_000;
|
||||
const bands: [string, number, number][] = [
|
||||
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
|
||||
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
|
||||
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
|
||||
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0], ['70', 430.0, 450.0],
|
||||
];
|
||||
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||||
return '';
|
||||
}
|
||||
|
||||
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||
function fmtVFO(hz?: number): string {
|
||||
@@ -731,12 +749,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
{/* Band buttons + antenna selection. */}
|
||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{BANDS.map((b) => (
|
||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
|
||||
{b.l}
|
||||
</button>
|
||||
))}
|
||||
{BANDS.map((b) => {
|
||||
const here = bandOfHz(mainHz) === b.l;
|
||||
return (
|
||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||
here
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
|
||||
: 'border-border bg-card text-foreground hover:bg-muted')}>
|
||||
{b.l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Row label={t('icmp.antenna')}>
|
||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||
|
||||
@@ -4528,6 +4528,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
|
||||
function AudioPanel() {
|
||||
// Names the CAT backend the operator has configured, so the PTT dropdown can
|
||||
// say "CAT — Icom CI-V (USB)" rather than the flatly wrong "CAT (OmniRig)".
|
||||
const catBackendLabel = ({
|
||||
omnirig: t('cat.optOmnirig'),
|
||||
flex: t('cat.optFlex'),
|
||||
icom: t('cat.optIcom'),
|
||||
'icom-net': t('cat.optIcomNet'),
|
||||
tci: t('cat.optTci'),
|
||||
} as Record<string, string>)[catCfg.backend] ?? '';
|
||||
|
||||
const deviceSelect = (
|
||||
field: keyof AudioSettings,
|
||||
devices: AudioDev[],
|
||||
@@ -4539,9 +4549,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">— none / system default —</SelectItem>
|
||||
<SelectItem value="_">{t('aud.noneDefault')}</SelectItem>
|
||||
{devices.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</SelectItem>
|
||||
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' ' + t('aud.defaultTag') : ''}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -4552,24 +4562,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SectionHeader
|
||||
title={t('hw.audioVoice')}/>
|
||||
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
|
||||
Refresh devices
|
||||
{t('aud.refreshDevices')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">From Radio (RX in)</Label>
|
||||
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
|
||||
<Label className="text-sm">To Radio (TX out)</Label>
|
||||
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
|
||||
<Label className="text-sm">Recording mic</Label>
|
||||
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
|
||||
<Label className="text-sm">Listening (preview)</Label>
|
||||
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
|
||||
<Label className="text-sm">{t('aud.fromRadio')}</Label>
|
||||
{deviceSelect('from_radio', audioInputs, t('aud.phFromRadio'))}
|
||||
<Label className="text-sm">{t('aud.toRadio')}</Label>
|
||||
{deviceSelect('to_radio', audioOutputs, t('aud.phToRadio'))}
|
||||
<Label className="text-sm">{t('aud.recMic')}</Label>
|
||||
{deviceSelect('recording_device', audioInputs, t('aud.phRecMic'))}
|
||||
<Label className="text-sm">{t('aud.listening')}</Label>
|
||||
{deviceSelect('listening_device', audioOutputs, t('aud.phListening'))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
||||
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
||||
<strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
|
||||
<strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
@@ -4578,14 +4588,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
className="h-8"
|
||||
onClick={toggleMonitor}
|
||||
disabled={!monitorOn && !audioCfg.from_radio}
|
||||
title="Hear the rig's RX audio (From Radio) through your Listening device"
|
||||
title={t('aud.monitorTitle')}
|
||||
>
|
||||
{monitorOn ? '■ Stop listening' : '▶ Listen to radio'}
|
||||
{monitorOn ? t('aud.stopListening') : t('aud.listenRadio')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{monitorOn
|
||||
? 'RX monitor running — From Radio → Listening device.'
|
||||
: 'Live-monitor the rig here (USB codec now; network audio later).'}
|
||||
{monitorOn ? t('aud.monitorOn') : t('aud.monitorHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -4595,101 +4603,106 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
className="h-8"
|
||||
onClick={toggleTX}
|
||||
disabled={!txOn && !audioCfg.to_radio}
|
||||
title="Key PTT and pipe your live mic into the rig (To Radio device)"
|
||||
title={t('aud.txTitle')}
|
||||
>
|
||||
{txOn ? '■ Stop talking (TX)' : '🎙 Talk to radio (TX)'}
|
||||
{txOn ? t('aud.stopTalk') : t('aud.talkRadio')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{txOn
|
||||
? 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.'
|
||||
: 'Live mic → rig with PTT (USB now; network TX later).'}
|
||||
{txOn ? t('aud.txOn') : t('aud.txHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
||||
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('aud.recorder')}</h4>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
|
||||
Record every QSO to an audio file (From Radio + your mic)
|
||||
{t('aud.recordEvery')}
|
||||
</label>
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">Recordings folder</Label>
|
||||
<Label className="text-sm">{t('aud.recFolder')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
|
||||
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
|
||||
<Button variant="outline" size="sm" className="h-8 shrink-0"
|
||||
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
|
||||
Browse…
|
||||
{t('aud.browse')}
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-sm">Pre-roll (seconds)</Label>
|
||||
<Label className="text-sm">{t('aud.preroll')}</Label>
|
||||
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
|
||||
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
|
||||
className="h-8 w-24 font-mono" />
|
||||
<Label className="text-sm">File format</Label>
|
||||
<Label className="text-sm">{t('aud.format')}</Label>
|
||||
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wav">WAV (lossless, larger)</SelectItem>
|
||||
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
|
||||
<SelectItem value="wav">{t('aud.wav')}</SelectItem>
|
||||
<SelectItem value="mp3">{t('aud.mp3')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="text-sm">From Radio level</Label>
|
||||
<Label className="text-sm">{t('aud.fromLevel')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
|
||||
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
|
||||
</div>
|
||||
<Label className="text-sm">Mic level</Label>
|
||||
<Label className="text-sm">{t('aud.micLevel')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
|
||||
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
|
||||
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">If your voice is louder than the station, lower Mic level.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('aud.levelHint')}</p>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
|
||||
Auto-send the recording to the station by e-mail when I log a QSO
|
||||
{t('aud.autoSend')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
|
||||
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1–F6)</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('aud.dvkTitle')}</h4>
|
||||
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">PTT method</Label>
|
||||
<Label className="text-sm">{t('aud.pttMethod')}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (VOX)</SelectItem>
|
||||
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
|
||||
<SelectItem value="rts">Serial RTS</SelectItem>
|
||||
<SelectItem value="dtr">Serial DTR</SelectItem>
|
||||
<SelectItem value="none">{t('aud.pttNone')}</SelectItem>
|
||||
{/* This has ALWAYS driven whichever CAT backend is active —
|
||||
it calls the generic manager, and every backend (OmniRig,
|
||||
FlexRadio, Icom CI-V over USB and over the network, TCI)
|
||||
implements SetPTT. The label said "CAT (OmniRig)", so
|
||||
operators on a CI-V rig concluded there was no CAT PTT for
|
||||
them and reached for RTS/DTR or VOX instead. Name the
|
||||
backend actually configured. */}
|
||||
<SelectItem value="cat">{t('aud.pttCat')}{catBackendLabel ? ` — ${catBackendLabel}` : ''}</SelectItem>
|
||||
<SelectItem value="rts">{t('aud.pttRts')}</SelectItem>
|
||||
<SelectItem value="dtr">{t('aud.pttDtr')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{audioCfg.ptt_method !== 'none' && (
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
||||
Test PTT
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr(t('aud.errPttTest') + String(e?.message ?? e))); }}>
|
||||
{t('aud.testPtt')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||||
<>
|
||||
<Label className="text-sm">PTT COM port</Label>
|
||||
<Label className="text-sm">{t('aud.pttPort')}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue placeholder="Pick a COM port" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue placeholder={t('aud.pickPort')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">— select —</SelectItem>
|
||||
<SelectItem value="_">{t('aud.selectPort')}</SelectItem>
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
Refresh
|
||||
{t('aud.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -4706,7 +4719,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
|
||||
<Input
|
||||
className="h-8 flex-1"
|
||||
placeholder={`Message ${m.slot} label (CQ, report, 73…)`}
|
||||
placeholder={t('aud.msgPlaceholder', { n: m.slot })}
|
||||
value={m.label}
|
||||
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
|
||||
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
|
||||
@@ -4722,21 +4735,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
setDvkErr('');
|
||||
DVKStartRecord(m.slot).catch((err) => setDvkErr('Record: ' + String(err?.message ?? err)));
|
||||
DVKStartRecord(m.slot).catch((err) => setDvkErr(t('aud.errRecord') + String(err?.message ?? err)));
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
|
||||
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr(t('aud.errSave') + String(err?.message ?? err)));
|
||||
}}
|
||||
>
|
||||
{recHere ? '● Recording…' : '● Hold to rec'}
|
||||
{recHere ? t('aud.recordingNow') : t('aud.holdRec')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline" size="sm" className="h-8 w-20 shrink-0"
|
||||
disabled={!m.has_audio || dvkStat.recording}
|
||||
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr('Play: ' + String(err?.message ?? err))))}
|
||||
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr(t('aud.errPlay') + String(err?.message ?? err))))}
|
||||
>
|
||||
{dvkStat.playing ? '■ Stop' : '▶ Play'}
|
||||
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -415,7 +415,11 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
);
|
||||
};
|
||||
|
||||
const widgets: { id: string; node: React.ReactNode }[] = [];
|
||||
// `wide` cards span the whole dashboard instead of sitting in one masonry
|
||||
// column. The amplifier and tuner carry meter rows and a channel selector that
|
||||
// are unreadable squeezed into a ~430px column — they are the same cards the
|
||||
// FlexRadio panel shows full-width, and they need that room here too.
|
||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
@@ -423,9 +427,9 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||
}
|
||||
// One card per configured amplifier (identical to the Flex panel's card).
|
||||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} /> });
|
||||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
||||
// Tuner Genius XL card (identical to the Flex panel's).
|
||||
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} /> });
|
||||
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||
|
||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||
@@ -470,7 +474,12 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
||||
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
||||
{ordered.map((w) => (
|
||||
// column-span:all lifts a wide card out of the columns and across the
|
||||
// full container, while keeping it in document order — so drag-reorder
|
||||
// still works between wide and normal cards. Capped so it stays a card
|
||||
// and not a banner on an ultra-wide window.
|
||||
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
|
||||
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||
<div draggable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Gauge, Radio, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
@@ -66,9 +66,42 @@ function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
|
||||
}
|
||||
|
||||
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
|
||||
// Peak-hold, exactly as AmpCard does it. Both cards read the same transmitter,
|
||||
// but the tuner is sampled by a 400 ms TCP poll: on SSB that lands in the gaps
|
||||
// between syllables as often as on a peak, so the raw reading collapses to zero
|
||||
// several times a second mid-transmission and reads as a dropped link. The
|
||||
// amplifier looked steady next to it only because it already smoothed this way.
|
||||
// Hold the highest value seen, decaying after 2 s so it follows a real drop.
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
const connected = !!status.connected;
|
||||
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const fwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
||||
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
||||
const fwdW = peakHold('fwd', rawFwdW);
|
||||
|
||||
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||
// meaningless 1.00, which would wipe the figure the operator actually wants —
|
||||
// the one measured while transmitting. So show the live value during TX, keep it
|
||||
// briefly afterwards so it can be read, then let it go.
|
||||
//
|
||||
// That expiry is the point: the first version of this held the last TX value
|
||||
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||
// Same 2 s window as the power peak above, so the two meters clear together.
|
||||
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||
const heldSwr = swrHold.current;
|
||||
const vswr = rawFwdW >= 1
|
||||
? rawVswr
|
||||
: heldSwr && Date.now() - heldSwr.t < 2000
|
||||
? heldSwr.v
|
||||
: undefined;
|
||||
const active = status.active ?? 1;
|
||||
const a: TGChannel = status.a ?? {};
|
||||
const b: TGChannel = status.b ?? {};
|
||||
@@ -115,8 +148,11 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
||||
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
|
||||
</div>
|
||||
|
||||
{/* PWR + SWR meters — same grid as the Flex/amp meters so they match size. */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{/* PWR + SWR meters, each taking half the card. The amp card's grid is
|
||||
3-wide because it shows three meters; copying it here left the tuner's
|
||||
two meters in the first two of three columns, with a third of the card
|
||||
empty to the right of SWR. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
|
||||
@@ -35,6 +35,10 @@ type Props = {
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// One column per defined award (cell = the reference this QSO counts for).
|
||||
awardCols?: { code: string; name: string }[];
|
||||
@@ -50,7 +54,7 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -247,6 +251,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
|
||||
mean "the whole logbook under the active column filters", but this grid
|
||||
is a per-callsign view rather than a filter over the log, so the entry
|
||||
would quietly export everything — not what it would appear to do here.
|
||||
The selection-based exports and bulk edit apply unchanged. */}
|
||||
<QSOContextMenu
|
||||
menu={menu}
|
||||
onClose={() => setMenu(null)}
|
||||
@@ -256,6 +265,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.21.2';
|
||||
export const APP_VERSION = '0.21.3';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -946,6 +946,8 @@ export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
|
||||
|
||||
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function UILog(arg1:string):Promise<void>;
|
||||
|
||||
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||
|
||||
export function UltrabeamRetract():Promise<void>;
|
||||
|
||||
@@ -1842,6 +1842,10 @@ export function TunerGeniusSetOperate(arg1) {
|
||||
return window['go']['main']['App']['TunerGeniusSetOperate'](arg1);
|
||||
}
|
||||
|
||||
export function UILog(arg1) {
|
||||
return window['go']['main']['App']['UILog'](arg1);
|
||||
}
|
||||
|
||||
export function ULSStatus() {
|
||||
return window['go']['main']['App']['ULSStatus']();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user