chore: release v0.26.2
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
// MotorAntennaWidget — the Ultrabeam / SteppIR control, in one place.
|
||||
//
|
||||
// Lives in its own file because it is shown in TWO: the Station Control tab and
|
||||
// the dock beside the entry strip. Two copies of a control that keys hardware is
|
||||
// how one of them quietly stops matching the other — a pattern button that sets
|
||||
// a different direction, a band list that tunes somewhere else.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Antenna as AntennaIcon, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
export type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[]; band_freqs?: Record<string, number> };
|
||||
|
||||
// Where each band button points the antenna.
|
||||
//
|
||||
// Not the arithmetic centre of the band. An antenna is tuned for where people
|
||||
// actually work: 20 m centres on 14175 but nobody lives there, and 10 m spans
|
||||
// 1.7 MHz of which the top half is empty. These are the points that leave the
|
||||
// elements closest to right for the whole band, and one nudge away from the rest.
|
||||
const ANT_BAND_KHZ: Record<string, number> = {
|
||||
'40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110,
|
||||
'15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150,
|
||||
};
|
||||
|
||||
// NUDGE_KHZ is the up/down step. 25 kHz because that is also the finest tracking
|
||||
// threshold: a nudge smaller than the tracking step would be undone by the next
|
||||
// poll while tracking is on.
|
||||
const NUDGE_KHZ = 25;
|
||||
|
||||
// bandOfKHz names the band a frequency sits in, so a band button can light up
|
||||
// when the ANTENNA is already there. Deliberately generous at the edges: the
|
||||
// antenna's reported frequency is where it was commanded, which can sit slightly
|
||||
// outside the allocation.
|
||||
function bandOfKHz(khz: number): string {
|
||||
const edges: [number, number, string][] = [
|
||||
[6900, 7300, '40m'], [10050, 10200, '30m'], [13900, 14400, '20m'],
|
||||
[18000, 18200, '17m'], [20900, 21500, '15m'], [24800, 25000, '12m'],
|
||||
[27900, 29800, '10m'], [49900, 50600, '6m'],
|
||||
];
|
||||
for (const [lo, hi, b] of edges) if (khz >= lo && khz <= hi) return b;
|
||||
return '';
|
||||
}
|
||||
|
||||
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||||
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||||
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||||
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||||
|
||||
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||||
// 1 = driven element, then Director 1, 2, 3…
|
||||
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||||
if (i === 0) return t('station.reflector');
|
||||
if (i === 1) return t('station.driven');
|
||||
return `${t('station.director')} ${i - 1}`;
|
||||
}
|
||||
|
||||
export function MotorAntennaWidget({ ant, refetch, t, onClose, essentialsOnly }: {
|
||||
ant: AntStatus;
|
||||
refetch: () => void;
|
||||
t: (k: string, v?: any) => string;
|
||||
// onClose adds the dock's close button. The Station Control tab passes none:
|
||||
// there the widget is a tile in a grid, and a tile that closes itself would
|
||||
// leave a hole the operator cannot fill back in.
|
||||
onClose?: () => void;
|
||||
// essentialsOnly drops what an operator sets once and then leaves alone:
|
||||
// tracking with its mode and step, and the per-element lengths. They belong in
|
||||
// Station Control, where there is room to think; in the dock they are height
|
||||
// spent on controls nobody touches between contacts, pushing the ones they do
|
||||
// touch — pattern, bands, nudge — off the bottom.
|
||||
essentialsOnly?: boolean;
|
||||
}) {
|
||||
const [err, setErr] = useState('');
|
||||
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||
const [reading, setReading] = useState(false);
|
||||
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
||||
const [editVal, setEditVal] = useState('');
|
||||
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||
const isUB = ant.type !== 'steppir';
|
||||
|
||||
const readLengths = useCallback(async () => {
|
||||
setReading(true); setErr('');
|
||||
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setReading(false); }
|
||||
}, []);
|
||||
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||
// +/- starts from the real values rather than blind.
|
||||
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||
|
||||
// Send an absolute length to one element and remember it as the new baseline.
|
||||
const setLen = async (i: number, mm: number) => {
|
||||
const next = Math.max(0, Math.round(mm));
|
||||
const prev = lengths[i] ?? 0;
|
||||
setBusyEl(i); setErr('');
|
||||
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||
try { await MotorSetElement(i, next); refetch(); }
|
||||
catch (e: any) {
|
||||
// Rejected by the controller — most often the element is at its travel
|
||||
// limit for this band (extending on a low band). Revert the optimistic
|
||||
// value so the display stays truthful, and explain the likely cause when
|
||||
// the failed move was an extension.
|
||||
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
||||
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
||||
}
|
||||
finally { setBusyEl(null); }
|
||||
};
|
||||
// Nudge one element by ±2 mm from its current known length.
|
||||
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
||||
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
||||
// the baseline when the auto-read is off, so +/- then work reliably.
|
||||
const commitEdit = (i: number) => {
|
||||
const v = parseInt(editVal, 10);
|
||||
setEditingEl(null);
|
||||
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
||||
};
|
||||
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border/60 bg-muted/30 shrink-0">
|
||||
<AntennaIcon className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||
</div>
|
||||
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose} title={t('station.hideWidget')}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2 space-y-2 flex-1 min-h-0 overflow-auto">
|
||||
<div>
|
||||
{/* No heading: N / 180° / Bi say what they are, and in a docked widget
|
||||
a line of height costs more than the word explains. */}
|
||||
<div className="flex gap-1">
|
||||
{dirs.map(([d, lbl]) => (
|
||||
<button key={d} type="button" disabled={!ant.connected}
|
||||
onClick={() => run(SetUltrabeamDirection(d))}
|
||||
className={cn('flex-1 rounded-md border py-1 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Bands, then a nudge, then tracking — in the order an operator uses
|
||||
them: get to the band, fine-tune inside it, decide whether the
|
||||
antenna should follow the rig from here. */}
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-0.5">{t('station.bands')}</div>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{(ant.bands ?? []).map((b: string) => {
|
||||
// Where this band tunes is resolved by the backend — the operator's
|
||||
// per-band choice from Settings, or the default. ANT_BAND_KHZ is only
|
||||
// the floor for a status poll that hasn't landed yet.
|
||||
const khz = ant.band_freqs?.[b] || ANT_BAND_KHZ[b];
|
||||
if (!khz) return null;
|
||||
// "On this band" from the antenna's own frequency, not the rig's:
|
||||
// the widget must show where the ANTENNA is, which is the whole
|
||||
// reason for tuning it by hand.
|
||||
const here = ant.frequency > 0 && bandOfKHz(ant.frequency) === b;
|
||||
return (
|
||||
<button key={b} type="button" disabled={!ant.connected}
|
||||
onClick={() => run(MotorTuneKHz(khz))}
|
||||
title={`${(khz / 1000).toFixed(3)} MHz`}
|
||||
className={cn('rounded-md border py-0.5 text-[11px] font-mono font-semibold transition-colors disabled:opacity-40',
|
||||
here ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||
{b}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(MotorNudgeKHz(-NUDGE_KHZ))}
|
||||
title={t('station.nudgeDown', { n: NUDGE_KHZ })}
|
||||
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
||||
<ChevronDown className="size-3.5" /> {NUDGE_KHZ}
|
||||
</button>
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(MotorNudgeKHz(NUDGE_KHZ))}
|
||||
title={t('station.nudgeUp', { n: NUDGE_KHZ })}
|
||||
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
||||
<ChevronUp className="size-3.5" /> {NUDGE_KHZ}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tracking. Here rather than only in Settings because it is an operating
|
||||
decision — off to park the antenna, on to resume — not something set
|
||||
up once. Mode and step only show when tracking is on: settings for
|
||||
something switched off are questions the operator cannot act on. And
|
||||
the step only shows in step mode, where it is the one thing it means. */}
|
||||
{!essentialsOnly && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
|
||||
className={cn('flex-1 rounded-md border py-1 text-xs font-semibold transition-colors',
|
||||
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
||||
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
||||
</button>
|
||||
{ant.follow && (ant.track_mode || 'step') === 'step' && (
|
||||
<select
|
||||
value={String(ant.step_khz || 50)}
|
||||
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10), ''))}
|
||||
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
||||
title={t('station.trackStepTip')}
|
||||
>
|
||||
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{ant.follow && (
|
||||
<select
|
||||
value={ant.track_mode || 'step'}
|
||||
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
|
||||
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
|
||||
title={t('station.trackModeTip')}
|
||||
>
|
||||
<option value="always" title={t('station.trackAlwaysTip')}>{t('station.trackAlways')}</option>
|
||||
<option value="step" title={t('station.trackStepTipMode')}>{t('station.trackStep')}</option>
|
||||
<option value="band" title={t('station.trackBandTip')}>{t('station.trackBand')}</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(UltrabeamRetract())}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||
</button>
|
||||
|
||||
{isUB && !essentialsOnly && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||
</button>
|
||||
</div>
|
||||
{lengths.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
||||
up structural bytes past the real data (varying by band), which
|
||||
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
||||
and ModifyElement addresses elements 0..2 — so show just those. */}
|
||||
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
{editingEl === i ? (
|
||||
<input autoFocus type="number" value={editVal}
|
||||
onChange={(e) => setEditVal(e.target.value)}
|
||||
onBlur={() => commitEdit(i)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
||||
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
||||
) : (
|
||||
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
||||
title={t('station.setExactLen')}
|
||||
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
||||
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,8 @@ import {
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||
GetScpStatus, SetScpEnabled, DownloadScp,
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
|
||||
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
|
||||
@@ -203,6 +204,7 @@ type SectionId =
|
||||
| 'database'
|
||||
| 'autostart'
|
||||
| 'uscounties'
|
||||
| 'databases'
|
||||
| 'awards'
|
||||
| 'cat'
|
||||
| 'ftx'
|
||||
@@ -320,6 +322,15 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Maintenance is about DATA rather than settings: nothing in here changes
|
||||
// how OpsLog behaves, it refreshes what it knows. That is why the reference
|
||||
// lists moved out of the Awards manager — an operator updating SOTA is not
|
||||
// editing an award, and had to open one to do it.
|
||||
kind: 'group', label: t('nav.maintenance'), icon: Database, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: t('sec.databases'), id: 'databases' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'group', label: t('nav.hardware'), icon: Server, defaultOpen: true, children: hardware,
|
||||
},
|
||||
@@ -335,6 +346,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||
foldersync: 'sec.foldersync',
|
||||
webpublish: 'sec.webpublish',
|
||||
uscounties: 'sec.uscounties',
|
||||
databases: 'sec.databases',
|
||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
||||
antgenius: 'sec.antgenius', tunergenius: 'sec.tunergenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
||||
relayauto: 'sec.relayauto',
|
||||
@@ -628,6 +640,19 @@ function AutostartPanelComponent() {
|
||||
<Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
|
||||
onChange={(e) => patch(p.id, { args: e.target.value })} />
|
||||
</div>
|
||||
{/* Only what OpsLog started is ever closed — a copy the operator
|
||||
opened themselves is theirs. The hint says so, because the
|
||||
difference is invisible from here. */}
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={!!(p as any).close_on_exit}
|
||||
onCheckedChange={(c) => patch(p.id, { close_on_exit: !!c } as any)} />
|
||||
<span>
|
||||
Close it when OpsLog closes{' '}
|
||||
<span className="text-muted-foreground">
|
||||
(asks it to close, the way clicking its cross would — and only if OpsLog started it)
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
|
||||
</div>
|
||||
))}
|
||||
@@ -1720,6 +1745,45 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
try { await DownloadULSCounties(); }
|
||||
catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
|
||||
};
|
||||
// cty.dat is not downloaded by OpsLog — it is shipped and reloaded from disk —
|
||||
// so the page reports it rather than offering a button it cannot honour.
|
||||
const [ctyInfo, setCtyInfo] = useState<{ entities: number; file_mod_time?: string }>({ entities: 0 });
|
||||
const [ctyBusy, setCtyBusy] = useState(false);
|
||||
useEffect(() => { GetCtyDatInfo().then((i) => setCtyInfo(i as any)).catch(() => {}); }, []);
|
||||
const refreshCty = async () => {
|
||||
setCtyBusy(true);
|
||||
try { const i: any = await RefreshCtyDat(); setCtyInfo(i); } catch { /* the line keeps its old count */ }
|
||||
finally { setCtyBusy(false); }
|
||||
};
|
||||
// Award reference lists, and which of them have an online updater.
|
||||
const [refMeta, setRefMeta] = useState<Array<{ code: string; count: number; updated_at?: string; can_update: boolean }>>([]);
|
||||
const [refBusy, setRefBusy] = useState('');
|
||||
const [refMsg, setRefMsg] = useState('');
|
||||
const reloadRefMeta = () => { GetAwardReferenceMeta().then((m) => setRefMeta((m ?? []) as any)).catch(() => {}); };
|
||||
useEffect(reloadRefMeta, []);
|
||||
const updateRefList = async (code: string) => {
|
||||
setRefBusy(code); setRefMsg('');
|
||||
try { const m: any = await UpdateAwardReferenceList(code); setRefMsg(t('db.refUpdated', { code, n: m?.count ?? 0 })); reloadRefMeta(); }
|
||||
catch (e: any) { setRefMsg(String(e?.message ?? e)); }
|
||||
finally { setRefBusy(''); }
|
||||
};
|
||||
// A date for a person: the day, not the timestamp the backend stores.
|
||||
const fmtDay = (v?: string) => (v ? String(v).slice(0, 10) : '—');
|
||||
const [rdaCount, setRdaCount] = useState(0);
|
||||
const [rdaBusy, setRdaBusy] = useState(false);
|
||||
const [rdaUseCurrent, setRdaUseCurrent] = useState(true);
|
||||
const [rdaMsg, setRdaMsg] = useState<string | null>(null);
|
||||
useEffect(() => { RDADatabaseCount().then((n) => setRdaCount(Number(n) || 0)).catch(() => {}); }, []);
|
||||
const runRDABackfill = async () => {
|
||||
setRdaBusy(true); setRdaMsg(null);
|
||||
try {
|
||||
const r: any = await BackfillRDA(rdaUseCurrent);
|
||||
setRdaMsg(t('rda.backfillDone', {
|
||||
d: r?.dated ?? 0, c: r?.current ?? 0, u: r?.unknown ?? 0, k: r?.skipped ?? 0, s: r?.scanned ?? 0,
|
||||
}));
|
||||
} catch (e: any) { setRdaMsg(String(e?.message ?? e)); }
|
||||
finally { setRdaBusy(false); }
|
||||
};
|
||||
const runBackfill = async () => {
|
||||
setBackfillBusy(true); setBackfillMsg(null);
|
||||
try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
|
||||
@@ -6914,6 +6978,141 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
);
|
||||
}
|
||||
|
||||
// One line per database: what it holds, when it was refreshed, and the button
|
||||
// that refreshes it. Every one of these already had an updater somewhere —
|
||||
// buried in the Awards manager, in a corner of the Lookup page, behind the
|
||||
// US Counties section — and an operator wanting them all current had to know
|
||||
// where each one lived. The point of this page is that they are in one place,
|
||||
// named, with their own line.
|
||||
function DatabasesPanel() {
|
||||
const rows: Array<{
|
||||
key: string; name: string; detail: string; busy: boolean; run?: () => void; note?: string;
|
||||
}> = [
|
||||
{
|
||||
key: 'cty', name: t('db.cty'),
|
||||
detail: ctyInfo?.entities ? t('db.ctyDetail', { n: ctyInfo.entities, d: fmtDay(ctyInfo.file_mod_time) }) : t('db.never'),
|
||||
busy: ctyBusy, run: refreshCty,
|
||||
},
|
||||
{
|
||||
key: 'clublog', name: t('db.clublog'),
|
||||
detail: clubInfo?.count ? t('db.clublogDetail', { n: clubInfo.count, d: clubInfo.date || '—' }) : t('db.never'),
|
||||
busy: clubBusy,
|
||||
run: () => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); },
|
||||
},
|
||||
{
|
||||
key: 'lotw', name: t('db.lotwUsers'),
|
||||
detail: lotwUsers?.count ? t('db.lotwDetail', { n: lotwUsers.count, d: fmtDay(lotwUsers.updated) }) : t('db.never'),
|
||||
busy: lotwUsersBusy, run: downloadLotwUsers,
|
||||
},
|
||||
{
|
||||
key: 'scp', name: t('db.scp'),
|
||||
detail: scp?.count ? t('db.scpDetail', { n: scp.count, d: fmtDay(scp.updated) }) : t('db.never'),
|
||||
busy: scpBusy, run: downloadScp,
|
||||
},
|
||||
{
|
||||
key: 'uls', name: t('db.uls'),
|
||||
detail: ulsStatus?.count ? t('db.ulsDetail', { n: ulsStatus.count, d: fmtDay(ulsStatus.updated_at) }) : t('db.never'),
|
||||
busy: ulsBusy, run: downloadUls,
|
||||
},
|
||||
{
|
||||
key: 'rda', name: t('db.rda'),
|
||||
detail: t('db.rdaDetail', { n: rdaCount.toLocaleString() }),
|
||||
busy: false, note: t('db.rdaNote'),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<SectionHeader title={t('sec.databases')} hint={t('db.hint')} />
|
||||
<div className="rounded-md border border-border divide-y divide-border">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="flex items-center gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{r.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{r.detail}</div>
|
||||
</div>
|
||||
{r.run ? (
|
||||
<Button size="sm" variant="secondary" onClick={r.run} disabled={r.busy}>
|
||||
{r.busy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||
{t('db.update')}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted-foreground italic shrink-0">{r.note}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The award reference lists. Their own block because they are one kind
|
||||
of thing with one updater each, and because the list depends on which
|
||||
awards are defined — a shared WAPC brought in by an operator appears
|
||||
here the day it can be updated. */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">{t('db.refLists')}</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('db.refListsHint')}</p>
|
||||
<div className="rounded-md border border-border divide-y divide-border">
|
||||
{refMeta.filter((m) => m.can_update).map((m) => (
|
||||
<div key={m.code} className="flex items-center gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium font-mono">{m.code}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{m.count ? t('db.refDetail', { n: m.count, d: fmtDay(m.updated_at) }) : t('db.never')}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" disabled={refBusy === m.code}
|
||||
onClick={() => updateRefList(m.code)}>
|
||||
{refBusy === m.code ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||
{t('db.update')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{refMeta.filter((m) => m.can_update).length === 0 && (
|
||||
<div className="p-3 text-[11px] text-muted-foreground">{t('db.noRefLists')}</div>
|
||||
)}
|
||||
</div>
|
||||
{refMsg && <p className="text-[11px] text-muted-foreground">{refMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Russian districts: the offline RDA database and the backfill it feeds.
|
||||
//
|
||||
// Its own section rather than a corner of the Awards manager: it is a
|
||||
// database and a bulk operation on the log, which is what every other item in
|
||||
// this part of the sidebar is.
|
||||
function RDAPanel() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<SectionHeader title={t('sec.rda')} hint={t('rda.hint')} />
|
||||
<div className="rounded-md border border-border p-3 space-y-1">
|
||||
<div className="text-xs font-medium">{t('rda.dbTitle')}</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t('rda.dbCount', { n: rdaCount.toLocaleString() })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<div className="text-xs font-medium">{t('rda.backfillTitle')}</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('rda.backfillIntro')}</p>
|
||||
{/* The choice is the whole point of the panel, so it is a checkbox and
|
||||
not a hidden default: a dated record is a fact, a current district
|
||||
on a ten-year-old QSO is an assumption. */}
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<Checkbox checked={rdaUseCurrent} className="mt-0.5" onCheckedChange={(c) => setRdaUseCurrent(!!c)} />
|
||||
<span>{t('rda.useCurrent')} <span className="text-muted-foreground">{t('rda.useCurrentHint')}</span></span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="secondary" onClick={runRDABackfill} disabled={rdaBusy}>
|
||||
{rdaBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||
{t('rda.backfillRun')}
|
||||
</Button>
|
||||
{rdaMsg && <span className="text-xs text-muted-foreground">{rdaMsg}</span>}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('rda.neverOverwrites')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
general: GeneralPanel,
|
||||
@@ -6940,8 +7139,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
backup: BackupPanel,
|
||||
database: DatabasePanel,
|
||||
uscounties: USCountiesPanel,
|
||||
databases: DatabasesPanel,
|
||||
autostart: () => <AutostartPanelComponent />,
|
||||
awards: () => <AwardsSelectionPanel profile={activeProfile ?? undefined} />,
|
||||
awards: () => (<div className="space-y-6"><AwardsSelectionPanel profile={activeProfile ?? undefined} /><RDAPanel /></div>),
|
||||
cat: CATPanel,
|
||||
rotator: RotatorPanel,
|
||||
winkeyer: WinkeyerPanel,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||
@@ -185,267 +186,6 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
);
|
||||
}
|
||||
|
||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[]; band_freqs?: Record<string, number> };
|
||||
|
||||
// Where each band button points the antenna.
|
||||
//
|
||||
// Not the arithmetic centre of the band. An antenna is tuned for where people
|
||||
// actually work: 20 m centres on 14175 but nobody lives there, and 10 m spans
|
||||
// 1.7 MHz of which the top half is empty. These are the points that leave the
|
||||
// elements closest to right for the whole band, and one nudge away from the rest.
|
||||
const ANT_BAND_KHZ: Record<string, number> = {
|
||||
'40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110,
|
||||
'15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150,
|
||||
};
|
||||
|
||||
// NUDGE_KHZ is the up/down step. 25 kHz because that is also the finest tracking
|
||||
// threshold: a nudge smaller than the tracking step would be undone by the next
|
||||
// poll while tracking is on.
|
||||
const NUDGE_KHZ = 25;
|
||||
|
||||
// bandOfKHz names the band a frequency sits in, so a band button can light up
|
||||
// when the ANTENNA is already there. Deliberately generous at the edges: the
|
||||
// antenna's reported frequency is where it was commanded, which can sit slightly
|
||||
// outside the allocation.
|
||||
function bandOfKHz(khz: number): string {
|
||||
const edges: [number, number, string][] = [
|
||||
[6900, 7300, '40m'], [10050, 10200, '30m'], [13900, 14400, '20m'],
|
||||
[18000, 18200, '17m'], [20900, 21500, '15m'], [24800, 25000, '12m'],
|
||||
[27900, 29800, '10m'], [49900, 50600, '6m'],
|
||||
];
|
||||
for (const [lo, hi, b] of edges) if (khz >= lo && khz <= hi) return b;
|
||||
return '';
|
||||
}
|
||||
|
||||
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||||
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||||
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||||
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||||
|
||||
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||||
// 1 = driven element, then Director 1, 2, 3…
|
||||
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||||
if (i === 0) return t('station.reflector');
|
||||
if (i === 1) return t('station.driven');
|
||||
return `${t('station.director')} ${i - 1}`;
|
||||
}
|
||||
|
||||
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
|
||||
const [err, setErr] = useState('');
|
||||
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||
const [reading, setReading] = useState(false);
|
||||
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
||||
const [editVal, setEditVal] = useState('');
|
||||
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||
const isUB = ant.type !== 'steppir';
|
||||
|
||||
const readLengths = useCallback(async () => {
|
||||
setReading(true); setErr('');
|
||||
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
finally { setReading(false); }
|
||||
}, []);
|
||||
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||
// +/- starts from the real values rather than blind.
|
||||
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||
|
||||
// Send an absolute length to one element and remember it as the new baseline.
|
||||
const setLen = async (i: number, mm: number) => {
|
||||
const next = Math.max(0, Math.round(mm));
|
||||
const prev = lengths[i] ?? 0;
|
||||
setBusyEl(i); setErr('');
|
||||
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||
try { await MotorSetElement(i, next); refetch(); }
|
||||
catch (e: any) {
|
||||
// Rejected by the controller — most often the element is at its travel
|
||||
// limit for this band (extending on a low band). Revert the optimistic
|
||||
// value so the display stays truthful, and explain the likely cause when
|
||||
// the failed move was an extension.
|
||||
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
||||
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
||||
}
|
||||
finally { setBusyEl(null); }
|
||||
};
|
||||
// Nudge one element by ±2 mm from its current known length.
|
||||
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
||||
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
||||
// the baseline when the auto-read is off, so +/- then work reliably.
|
||||
const commitEdit = (i: number) => {
|
||||
const v = parseInt(editVal, 10);
|
||||
setEditingEl(null);
|
||||
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
||||
};
|
||||
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<AntennaIcon className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||
</div>
|
||||
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||
</div>
|
||||
<div className="p-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
|
||||
<div className="flex gap-1">
|
||||
{dirs.map(([d, lbl]) => (
|
||||
<button key={d} type="button" disabled={!ant.connected}
|
||||
onClick={() => run(SetUltrabeamDirection(d))}
|
||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Bands, then a nudge, then tracking — in the order an operator uses
|
||||
them: get to the band, fine-tune inside it, decide whether the
|
||||
antenna should follow the rig from here. */}
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.bands')}</div>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{(ant.bands ?? []).map((b: string) => {
|
||||
// Where this band tunes is resolved by the backend — the operator's
|
||||
// per-band choice from Settings, or the default. ANT_BAND_KHZ is only
|
||||
// the floor for a status poll that hasn't landed yet.
|
||||
const khz = ant.band_freqs?.[b] || ANT_BAND_KHZ[b];
|
||||
if (!khz) return null;
|
||||
// "On this band" from the antenna's own frequency, not the rig's:
|
||||
// the widget must show where the ANTENNA is, which is the whole
|
||||
// reason for tuning it by hand.
|
||||
const here = ant.frequency > 0 && bandOfKHz(ant.frequency) === b;
|
||||
return (
|
||||
<button key={b} type="button" disabled={!ant.connected}
|
||||
onClick={() => run(MotorTuneKHz(khz))}
|
||||
title={`${(khz / 1000).toFixed(3)} MHz`}
|
||||
className={cn('rounded-md border py-1 text-[11px] font-mono font-semibold transition-colors disabled:opacity-40',
|
||||
here ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||
{b}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(MotorNudgeKHz(-NUDGE_KHZ))}
|
||||
title={t('station.nudgeDown', { n: NUDGE_KHZ })}
|
||||
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1.5 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
||||
<ChevronDown className="size-3.5" /> {NUDGE_KHZ}
|
||||
</button>
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(MotorNudgeKHz(NUDGE_KHZ))}
|
||||
title={t('station.nudgeUp', { n: NUDGE_KHZ })}
|
||||
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1.5 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
||||
<ChevronUp className="size-3.5" /> {NUDGE_KHZ}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tracking. Here rather than only in Settings because it is an operating
|
||||
decision — off to park the antenna, on to resume — not something set
|
||||
up once. Mode and step only show when tracking is on: settings for
|
||||
something switched off are questions the operator cannot act on. And
|
||||
the step only shows in step mode, where it is the one thing it means. */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
|
||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
|
||||
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
||||
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
||||
</button>
|
||||
{ant.follow && (ant.track_mode || 'step') === 'step' && (
|
||||
<select
|
||||
value={String(ant.step_khz || 50)}
|
||||
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10), ''))}
|
||||
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
||||
title={t('station.trackStepTip')}
|
||||
>
|
||||
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{ant.follow && (
|
||||
<select
|
||||
value={ant.track_mode || 'step'}
|
||||
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
|
||||
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
|
||||
title={t('station.trackModeTip')}
|
||||
>
|
||||
<option value="always" title={t('station.trackAlwaysTip')}>{t('station.trackAlways')}</option>
|
||||
<option value="step" title={t('station.trackStepTipMode')}>{t('station.trackStep')}</option>
|
||||
<option value="band" title={t('station.trackBandTip')}>{t('station.trackBand')}</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(UltrabeamRetract())}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||
</button>
|
||||
|
||||
{isUB && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||
</button>
|
||||
</div>
|
||||
{lengths.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
||||
up structural bytes past the real data (varying by band), which
|
||||
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
||||
and ModifyElement addresses elements 0..2 — so show just those. */}
|
||||
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
{editingEl === i ? (
|
||||
<input autoFocus type="number" value={editVal}
|
||||
onChange={(e) => setEditVal(e.target.value)}
|
||||
onBlur={() => commitEdit(i)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
||||
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
||||
) : (
|
||||
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
||||
title={t('station.setExactLen')}
|
||||
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
||||
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
Reference in New Issue
Block a user