chore: release v0.20.8

This commit is contained in:
2026-07-21 21:48:23 +02:00
parent 1b5b0c2e90
commit 968da5488c
15 changed files with 393 additions and 55 deletions
+32 -2
View File
@@ -61,6 +61,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
// Programmatic row selection in the on-air grid (auto-advance after logging).
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
// Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false);
@@ -177,10 +179,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Active → logged (end QSO, removed from session, written to the logbook).
// Then auto-select the FIRST remaining on-air station, so the operator can
// chain "log → log → log" without re-clicking a row each time.
async function deactivate(id?: number) {
if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
catch (e: any) { setError(String(e?.message ?? e)); }
const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
try {
await NetDeactivate(id);
await refreshActive();
onLogged?.();
if (next?.id != null) {
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
setSelectedActiveIds([next.id as number]);
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Log EVERYONE still on air (end of net): each draft is written to the logbook
// exactly as the single-log button would.
async function logAll() {
if (active.length === 0) return;
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
try {
for (const a of [...active]) {
if (a.id != null) await NetDeactivate(a.id as number);
}
await refreshActive();
onLogged?.();
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
}
// Edit-modal handlers (operate on the in-memory draft, not the DB).
@@ -286,6 +312,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
rows={active}
total={active.length}
storageKey="net.onair"
selectRowSignal={selectRow}
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)}
onRowSelected={setSelectedActiveIds}
@@ -297,6 +324,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
</Button>
</div>
)}
+19 -1
View File
@@ -30,6 +30,9 @@ type Props = {
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
// Bump `seq` to programmatically select the single row with this id (e.g. NET
// Control auto-advancing to the next on-air station after logging one).
selectRowSignal?: { id: number; seq: 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.
@@ -249,7 +252,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -406,6 +409,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
gridRef.current?.api?.selectAll();
}, [selectAllSignal]);
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
// tick so a row-data refresh in the same render settles first.
useEffect(() => {
if (!selectRowSignal || selectRowSignal.seq === 0) return;
const t = window.setTimeout(() => {
const api = gridRef.current?.api;
if (!api) return;
api.deselectAll();
api.forEachNode((n: any) => {
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
});
}, 0);
return () => window.clearTimeout(t);
}, [selectRowSignal?.seq]);
// ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
// state for "which columns are visible" — AG Grid's column state is the
+59 -23
View File
@@ -2853,11 +2853,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
function RotatorPanel() {
const isRG = (rotator as any).type === 'rotgenius';
const isARCO = (rotator as any).type === 'arco';
return (
<>
<SectionHeader
title="Rotator"
hint={isRG ? undefined : t('rot.hint')}
hint={isRG || isARCO ? undefined : t('rot.hint')}
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -2867,14 +2868,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('rot.type')}</Label>
{/* Switching to Rotator Genius moves the default port to its native
9006; back to PstRotator restores 12000. */}
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
<Select value={(rotator as any).type ?? 'pst'}
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -2891,34 +2893,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select>
</div>
)}
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
{isARCO && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={(rotator as any).transport ?? 'tcp'}
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tcp">Network (LAN)</SelectItem>
<SelectItem value="serial">USB (serial COM)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
className="font-mono"
/>
{isARCO && (rotator as any).transport === 'serial' ? (
<div className="space-y-1 max-w-xs">
<Label>COM port</Label>
<div className="flex items-center gap-2">
<Select value={(rotator as any).com_port || '_'}
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
<Input
type="number" min={1} max={65535}
value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
className="font-mono"
/>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input
type="number" min={1} max={65535}
value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
className="font-mono"
/>
</div>
</div>
</div>
{!isRG && (
)}
{!isRG && !isARCO && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
This rotator supports elevation (VHF / satellite)
</label>
)}
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
@@ -13,7 +13,7 @@ import {
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetPGXLSettings, GetPGXLStatus, PGXLSetFanMode,
GetPGXLSettings, GetPGXLStatus, PGXLSetFanMode, PGXLSetOperate,
GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
} from '../../wailsjs/go/main/App';
@@ -284,6 +284,12 @@ function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: a
<div className="p-3 space-y-2">
{isPGXL ? (
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={!st.connected}
onClick={() => { const want = !st.operate; setSt((s: any) => ({ ...s, operate: want })); PGXLSetOperate(want).catch(() => {}); }}
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.operate ? 'OPERATE' : 'STANDBY'}
</button>
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
<button key={m} type="button" disabled={!st.connected}
onClick={() => PGXLSetFanMode(m).catch(() => {})}