fix: fix a bug adjusting the length of Ultrabeam

This commit is contained in:
2026-07-17 20:19:54 +02:00
parent aeeb658269
commit 9f08df1c39
5 changed files with 65 additions and 72 deletions
+1 -1
View File
@@ -179,7 +179,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota'))); if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>; if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return ( return (
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}> <span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{badges.map((bd, i) => ( {badges.map((bd, i) => (
<span key={i} style={{ <span key={i} style={{
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1, display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
+18 -54
View File
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { QSOEditModal } from '@/components/QSOEditModal'; import { QSOEditModal } from '@/components/QSOEditModal';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
import { import {
@@ -59,13 +60,13 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses). // Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null); const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
// Add/edit-contact dialog. // Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false); const [contactOpen, setContactOpen] = useState(false);
const [contact, setContact] = useState<Station>(emptyStation()); const [contact, setContact] = useState<Station>(emptyStation());
const [looking, setLooking] = useState(false); const [looking, setLooking] = useState(false);
const activeGrid = useRef<any>(null);
const rosterGrid = useRef<any>(null); const rosterGrid = useRef<any>(null);
// Worked-before for the clicked station (see if/when we contacted it before). // Worked-before for the clicked station (see if/when we contacted it before).
@@ -230,18 +231,6 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
} catch (e: any) { setError(String(e?.message ?? e)); } } catch (e: any) { setError(String(e?.message ?? e)); }
} }
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
], [t]);
const rosterCols = useMemo<ColDef<Station>[]>(() => [ const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, { headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 }, { headerName: t('ncp.colName'), field: 'name', flex: 1 },
@@ -292,26 +281,20 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.onAirActive')} {t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span> <span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div> </div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}> <div className="flex flex-col min-h-0 flex-1">
<div style={{ position: 'absolute', inset: 0 }}> <RecentQSOsGrid
<AgGridReact<QSOForm> rows={active}
ref={activeGrid} total={active.length}
theme={hamlogTheme} storageKey="net.onair"
rowData={active} onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
columnDefs={activeCols} onRowDoubleClicked={(q) => setEditingDraft(q)}
defaultColDef={defaultColDef} onRowSelected={setSelectedActiveIds}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)} />
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false}
getRowId={(p) => String((p.data as any).id)}
/>
</div>
</div> </div>
{isOpen && active.length > 0 && ( {isOpen && active.length > 0 && (
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2"> <div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" <Button variant="ghost" size="sm" className="h-7 text-[11px]"
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}> onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')} <MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button> </Button>
</div> </div>
@@ -335,7 +318,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
</span> </span>
)} )}
</div> </div>
<div className="flex-1 min-h-0 overflow-auto"> <div className="flex-1 min-h-0 flex flex-col">
{!wbCall ? ( {!wbCall ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div> <div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
) : wbBusy ? ( ) : wbBusy ? (
@@ -343,30 +326,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
) : !wb || wb.count === 0 ? ( ) : !wb || wb.count === 0 ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div> <div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
) : ( ) : (
<table className="w-full text-xs"> <RecentQSOsGrid
<thead className="sticky top-0 bg-muted/30 text-muted-foreground"> rows={(wb.entries ?? []) as any}
<tr className="text-left"> total={wb.count}
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th> storageKey="net.wb"
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th> />
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
<th className="px-2 py-1 font-semibold">RST</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
</tr>
</thead>
<tbody>
{((wb.entries ?? []) as any[]).map((q, i) => (
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
<td className="px-2 py-1">{q.band}</td>
<td className="px-2 py-1">{q.mode}</td>
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
</tr>
))}
</tbody>
</table>
)} )}
</div> </div>
</div> </div>
+17 -8
View File
@@ -30,7 +30,12 @@ type Props = {
// Bump this number to programmatically select every row (e.g. after a search // Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected"). // in the QSL Manager, where the default is "all selected").
selectAllSignal?: number; selectAllSignal?: number;
// storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config.
storageKey?: string;
onRowDoubleClicked?: (q: QSOForm) => void; onRowDoubleClicked?: (q: QSOForm) => void;
onRowClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void; onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void; onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void; onUpdateFromQRZ?: (ids: number[]) => void;
@@ -49,7 +54,7 @@ type Props = {
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
}; };
const COL_STATE_KEY = 'hamlog.qsoColState.v2'; const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
function fmtMhzDots(hz?: number): string { function fmtMhzDots(hz?: number): string {
if (!hz) return ''; if (!hz) return '';
@@ -240,10 +245,13 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] => const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_')); (st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
// Column-layout persistence keys — scoped by storageKey so a reused instance
// (e.g. the Net panel) keeps its own layout independent of the main log.
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog — rebuilt when the language changes. // Localized column catalog — rebuilt when the language changes.
@@ -283,7 +291,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly // across a columnDefs rebuild instead of re-reading `hide` — which is exactly
// why previously-shown award columns kept vanishing on reopen. A dedicated set // why previously-shown award columns kept vanishing on reopen. A dedicated set
// is deterministic: it drives `hide` directly and is re-enforced below. // is deterministic: it drives `hide` directly and is re-enforced below.
const AWARD_SHOWN_KEY = 'hamlog.awardColsShown'; const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase()))); const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
useEffect(() => { useEffect(() => {
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache. // Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
@@ -341,21 +349,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
}), []); }), []);
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty // Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache. // (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(COL_STATE_KEY).then((remote) => { loadRemote(colStateKey).then((remote) => {
if (remote && !local) { if (remote && !local) {
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true }); e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote); seedLocal(colStateKey, remote);
} }
}); });
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, stripAwardCols(state)); if (state) saveState(colStateKey, stripAwardCols(state));
}, []); }, []);
// The award columns load asynchronously; when they arrive (or change) the // The award columns load asynchronously; when they arrive (or change) the
@@ -365,7 +373,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// every rebuild so the user's choices win. No-op before the grid is ready. // every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(colStateKey);
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true }); if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild. // Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0); const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
@@ -465,6 +473,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
onColumnVisible={saveColumnState} onColumnVisible={saveColumnState}
onSortChanged={saveColumnState} onSortChanged={saveColumnState}
onRowDoubleClicked={handleRowDoubleClicked} onRowDoubleClicked={handleRowDoubleClicked}
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
onSelectionChanged={onSelectionChanged} onSelectionChanged={onSelectionChanged}
onCellContextMenu={onCellContextMenu} onCellContextMenu={onCellContextMenu}
preventDefaultOnContextMenu preventDefaultOnContextMenu
@@ -112,6 +112,8 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
const [reading, setReading] = useState(false); const [reading, setReading] = useState(false);
const [busyEl, setBusyEl] = useState<number | null>(null); 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 run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
const isUB = ant.type !== 'steppir'; const isUB = ant.type !== 'steppir';
@@ -125,16 +127,24 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
// +/- starts from the real values rather than blind. // +/- starts from the real values rather than blind.
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]); useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
// Nudge one element by ±2 mm from its current known length. // Send an absolute length to one element and remember it as the new baseline.
const nudge = async (i: number, delta: number) => { const setLen = async (i: number, mm: number) => {
const cur = lengths[i] ?? 0; const next = Math.max(0, Math.round(mm));
const next = Math.max(0, cur + delta);
setBusyEl(i); setErr(''); setBusyEl(i); setErr('');
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
try { await MotorSetElement(i, next); refetch(); } try { await MotorSetElement(i, next); refetch(); }
catch (e: any) { setErr(String(e?.message ?? e)); } catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setBusyEl(null); } 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')]]; const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
return ( return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full"> <div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
@@ -191,9 +201,19 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0"> 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" /> <Minus className="size-3.5" />
</button> </button>
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums"> {editingEl === i ? (
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`} <input autoFocus type="number" value={editVal}
</span> 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} <button type="button" disabled={!ant.connected || busyEl !== null}
onClick={() => nudge(i, ELEMENT_STEP)} 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"> className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
+2 -2
View File
@@ -103,7 +103,7 @@ const en: Dict = {
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.', 'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
'uscty.backfillRun': 'Fill missing counties', 'uscty.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.', 'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer', 'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', 'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
// General panel // General panel
@@ -381,7 +381,7 @@ const fr: Dict = {
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.", 'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
'uscty.backfillRun': 'Remplir les comtés manquants', 'uscty.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.', 'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW', 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', 'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).', 'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',