chore: release v0.27.12

This commit is contained in:
2026-09-05 19:07:21 +02:00
parent f93e1c5898
commit be889681a9
40 changed files with 4971 additions and 453 deletions
+78 -5
View File
@@ -19,6 +19,7 @@ import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref';
import { decoderName } from '@/lib/decoderName';
export type Decode = {
call: string;
@@ -99,6 +100,9 @@ interface Props {
// the decoder announces — see the drift warning.
rigBand?: string;
onCall: (d: Decode) => void;
// A single click: take the station without transmitting — fill the entry, and
// point the panels at it. Absent, a click falls back to onCall.
onSelect?: (d: Decode) => void;
myCall?: string;
// Drop every decode and transmit message held for this panel. The list is a
// live view, not data — clearing it costs nothing but the seconds until the
@@ -115,6 +119,12 @@ interface Props {
// machine off is not something to go hunting through a settings tree for.
autoCallOn?: boolean;
onToggleAutoCall?: () => void;
// The engine's own account of what it is doing, straight from the backend.
autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
// The chase list, here as well as in Preferences: naming the station you are
// waiting for is done WHILE watching the band, not in a settings tree.
autoCallOnly?: string;
onSetAutoCallOnly?: (list: string) => void;
}
// The "new" categories, as toggle badges — the same idea and the same colours as
@@ -542,12 +552,21 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
}));
}
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) {
const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/
// like every other portable preference.
const [colw, setColw] = useState<ColWidths>(loadWidths);
// The chase list as TYPED. Re-seeded whenever the stored value changes —
// from Preferences, or from another window — but never while the box has the
// focus, or a status arriving mid-word would rewrite what is being typed.
const [onlyText, setOnlyText] = useState(autoCallOnly ?? '');
useEffect(() => {
const el = document.activeElement as HTMLElement | null;
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
setOnlyText(autoCallOnly ?? '');
}, [autoCallOnly, t]);
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
const setColWidth = (key: ColKey, px: number) => {
@@ -710,7 +729,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
}
return instances.map((inst) => ({
key: inst,
label: inst,
// What the program is called, not the id it announces — see decoderName.
label: decoderName(inst),
tx: txStates?.[inst],
periods: buildPeriods(
filtered.filter((d) => (d.instance ?? '') === inst),
@@ -757,7 +777,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
<AlertTriangle className="size-3.5" />
{t('dec.bandDrift', {
app: driftInstance || t('dec.bandDriftApp'),
app: decoderName(driftInstance) || t('dec.bandDriftApp'),
dec: decoderBand.toUpperCase(),
rig: (rigBand ?? '').toUpperCase(),
})}
@@ -889,6 +909,54 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
filter. Halt goes LAST — it is the one that must be findable without
reading, and the end of the row is the one position that never moves
as filters come and go. */}
{/* Auto-call. Deliberately next to Halt: the two belong together, and
what it is doing right now — which station, how many calls of how
many — is on the button itself, because a thing that keys the
transmitter must never be a switch with no readout. */}
{onToggleAutoCall && (
<button
type="button"
onClick={onToggleAutoCall}
title={autoCall?.stopped ? t('dec.autoStoppedTip') : t('dec.autoCallTip')}
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border font-medium',
!autoCallOn
? 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
: autoCall?.stopped
? 'border-warning bg-warning text-warning-foreground'
: 'border-success bg-success text-success-foreground')}
>
<Bot className="size-3.5" />
{t('dec.autoCall')}
{autoCallOn && autoCall?.target && (
<span className="font-mono text-xs">
{autoCall.target} {autoCall.calls}/{autoCall.max}
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
</span>
)}
</button>
)}
{/* The chase list. Raw text while typing, committed on blur or Enter:
the stored value is upper-cased and trimmed, and binding the box to
that makes the space bar look dead — in a field whose whole purpose
is a list separated by spaces. */}
{onSetAutoCallOnly && (
<input
type="text"
value={onlyText}
onChange={(e) => setOnlyText(e.target.value)}
onBlur={() => { if (onlyText.toUpperCase() !== (autoCallOnly ?? '')) onSetAutoCallOnly(onlyText); }}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setOnlyText(autoCallOnly ?? '');
}}
placeholder={t('dec.chasePh')}
title={t('dec.chaseTip')}
className={cn('h-8 w-44 rounded-lg border px-2 text-sm font-mono uppercase bg-background',
(autoCallOnly ?? '').trim()
? 'border-primary text-foreground'
: 'border-border text-muted-foreground')}
/>
)}
{onHalt && (
<button
type="button"
@@ -955,7 +1023,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<span className="flex-1" />
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
{txState.instance && instances.length > 1 && (
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
<span className="text-xs text-muted-foreground shrink-0">{decoderName(txState.instance)}</span>
)}
</div>
)}
@@ -1095,7 +1163,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<button
key={`${d.call}-${d.freq_hz}-${i}`}
type="button"
onClick={() => onCall(d)}
// ONE click selects, TWO transmit — the cluster's rule, and
// the only safe one here: a single click used to hand the
// decode straight to WSJT-X as a Reply, so brushing a row
// while reading the band started calling a station.
onClick={() => (onSelect ?? onCall)(d)}
onDoubleClick={() => onCall(d)}
title={t('dec.callTitle', { call: d.call })}
style={{ gridTemplateColumns: template, width: tableW }}
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',