chore: release v0.26.0
This commit is contained in:
@@ -13,10 +13,11 @@
|
||||
// come from the same resolver the cluster uses, so a call means the same thing in
|
||||
// both panels rather than being judged twice by two rules.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Radio, Search, X, Signal, ArrowUpRight, Timer } from 'lucide-react';
|
||||
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
export type Decode = {
|
||||
call: string;
|
||||
@@ -38,6 +39,12 @@ export type Decode = {
|
||||
// Replayed verbatim when answering the station — see AnswerDecode.
|
||||
ms?: number;
|
||||
low_conf?: boolean;
|
||||
// The mode field as the Decode carried it (the "~"/"+" marker). `mode` above
|
||||
// is the resolved name, for display and for the log; a Reply has to send this
|
||||
// one back or the receiving application finds no matching decode.
|
||||
mode_raw?: string;
|
||||
// The message as sent, untrimmed — replayed in a Reply for the same reason.
|
||||
msg_raw?: string;
|
||||
};
|
||||
|
||||
export type TxMsg = {
|
||||
@@ -49,6 +56,9 @@ export type TxMsg = {
|
||||
freq_hz?: number;
|
||||
instance?: string;
|
||||
transmitting?: boolean;
|
||||
// The sender's Enable Tx toggle. The watchdog clears it while leaving the DX
|
||||
// call set — the only sign an exchange was abandoned rather than paused.
|
||||
tx_enabled?: boolean;
|
||||
at: string;
|
||||
};
|
||||
|
||||
@@ -62,6 +72,9 @@ type StatusEntry = {
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
new_grid?: boolean;
|
||||
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
|
||||
grid_state?: string;
|
||||
grid?: string;
|
||||
lotw?: boolean;
|
||||
};
|
||||
|
||||
@@ -71,9 +84,27 @@ interface Props {
|
||||
// txState is the LIVE transmit state — what is going out right now and to
|
||||
// whom. Separate from txMsgs, which is the history threaded into the periods.
|
||||
txState?: TxMsg | null;
|
||||
// The same, keyed by instance. In a split view the shared txState is whichever
|
||||
// receiver reported last, which is a coin toss — each pane needs its own.
|
||||
txStates?: Record<string, TxMsg>;
|
||||
spotStatus: Record<string, StatusEntry>;
|
||||
onCall: (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
|
||||
// next period lands.
|
||||
// instance = clear just that receiver's decodes; empty = all of them. A split
|
||||
// view needs the narrow form: clearing both because you wanted one is the same
|
||||
// loss of context the split exists to prevent.
|
||||
onClear?: (instance?: string) => void;
|
||||
// Stop the decoding application transmitting, mid-over. Routed to one
|
||||
// instance by program id — see HaltDecodeTx.
|
||||
onHalt?: (instance: string) => void;
|
||||
// Auto-call on/off, mirrored here as well as in Preferences: it is the one
|
||||
// setting an operator wants to reach WITHOUT leaving the band — turning the
|
||||
// machine off is not something to go hunting through a settings tree for.
|
||||
autoCallOn?: boolean;
|
||||
onToggleAutoCall?: () => void;
|
||||
}
|
||||
|
||||
// The "new" categories, as toggle badges — the same idea and the same colours as
|
||||
@@ -113,6 +144,33 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
|
||||
}
|
||||
|
||||
const CAT_KEY = 'opslog.decodeCats';
|
||||
const SPLIT_KEY = 'opslog.decodeSplit';
|
||||
const FILTER_KEY = 'opslog.decodeFilters';
|
||||
|
||||
// usePersisted is useState that survives the panel being unmounted.
|
||||
//
|
||||
// All the filters share ONE stored object rather than a key each: they are read
|
||||
// and written together, and a dozen localStorage entries for one filter bar is a
|
||||
// dozen things to keep in step when one of them is renamed.
|
||||
function readFilters(): Record<string, unknown> {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}');
|
||||
return v && typeof v === 'object' ? v : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
function usePersisted<T>(key: string, fallback: T): [T, (v: T) => void] {
|
||||
const [v, setV] = useState<T>(() => {
|
||||
const stored = readFilters()[key];
|
||||
return (typeof stored === typeof fallback ? stored as T : fallback);
|
||||
});
|
||||
const set = (next: T) => {
|
||||
setV(next);
|
||||
try {
|
||||
localStorage.setItem(FILTER_KEY, JSON.stringify({ ...readFilters(), [key]: next }));
|
||||
} catch { /* quota / private mode — the filter still works this session */ }
|
||||
};
|
||||
return [v, set];
|
||||
}
|
||||
|
||||
// DEFAULT_TR is the slot length assumed when nothing better is known. Fifteen
|
||||
// seconds is FT8, the overwhelming majority of what arrives here; a wrong guess
|
||||
@@ -144,15 +202,17 @@ function trSeconds(mode?: string, reported?: number): number {
|
||||
return DEFAULT_TR;
|
||||
}
|
||||
|
||||
// ROW is the column template, shared by the header and every row so the two can
|
||||
// never drift. Full width and left-aligned — an earlier pass centred it inside a
|
||||
// maximum width, which on a wide screen opened a huge dead margin down the left
|
||||
// before the first callsign.
|
||||
// ROW is the grid shell, shared by the header and every row so the two can
|
||||
// never drift. The column TEMPLATE is no longer baked in: every column has a
|
||||
// width the operator can drag, so the table takes exactly the room it needs and
|
||||
// no more.
|
||||
//
|
||||
// Message is the one elastic column, with a floor so it does not collapse; the
|
||||
// slack lands there rather than between two fixed columns, which is what read as
|
||||
// a hole in the middle of every line.
|
||||
const ROW = 'grid grid-cols-[64px_50px_44px_56px_50px_44px_minmax(240px,1fr)_140px_186px] items-stretch';
|
||||
// Message used to be an elastic 1fr column. On a wide screen that handed it
|
||||
// every spare pixel — a message column hundreds of pixels wider than its longest
|
||||
// line, with Country and Status marooned against the far edge. Fixed widths
|
||||
// throughout mean the slack stays OUTSIDE the table, which is where a second
|
||||
// panel can go.
|
||||
const ROW = 'grid items-stretch';
|
||||
|
||||
// CELL draws the column rule. items-stretch above plus a right border here is
|
||||
// what makes the lines run unbroken from the header to the bottom of the list —
|
||||
@@ -160,6 +220,84 @@ const ROW = 'grid grid-cols-[64px_50px_44px_56px_50px_44px_minmax(240px,1fr)_140
|
||||
const CELL = 'flex items-center min-w-0 px-2 border-r border-border/30';
|
||||
const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
|
||||
|
||||
// ── Columns ──────────────────────────────────────────────────────────────────
|
||||
// One declaration per column, in display order: the header, the widths and the
|
||||
// resize handles all read from this, so a column cannot be resized in the header
|
||||
// and stay the old width in the body.
|
||||
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'country' | 'status';
|
||||
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
||||
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
|
||||
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
|
||||
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
|
||||
{ key: 'band', tkey: 'dec.colBand', def: 50, min: 36 },
|
||||
{ key: 'mode', tkey: 'dec.colMode', def: 44, min: 36 },
|
||||
// 300 px holds the longest real FT8 line ("<3B9/SQ9UM> LZ3BS KN22") with room
|
||||
// to spare; anything past that was the dead space this replaces.
|
||||
{ key: 'msg', tkey: 'dec.colMsg', def: 300, min: 120 },
|
||||
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
|
||||
// an operator chasing squares could see the verdict but never the square.
|
||||
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
||||
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
||||
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
||||
];
|
||||
const COL_MAX = 600;
|
||||
const COLW_KEY = 'opslog.decodeColWidths';
|
||||
|
||||
type ColWidths = Record<ColKey, number>;
|
||||
const defaultWidths = (): ColWidths =>
|
||||
Object.fromEntries(COLS.map((c) => [c.key, c.def])) as ColWidths;
|
||||
|
||||
// Stored widths are merged ONTO the defaults rather than used as-is: a saved set
|
||||
// from before a column existed would otherwise leave that column at zero width.
|
||||
function loadWidths(): ColWidths {
|
||||
const out = defaultWidths();
|
||||
try {
|
||||
const raw = localStorage.getItem(COLW_KEY);
|
||||
if (!raw) return out;
|
||||
const saved = JSON.parse(raw) as Partial<Record<ColKey, unknown>>;
|
||||
for (const c of COLS) {
|
||||
const v = saved[c.key];
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[c.key] = Math.min(COL_MAX, Math.max(c.min, Math.round(v)));
|
||||
}
|
||||
}
|
||||
} catch { /* corrupt value — the defaults are a fine answer */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
// The drag handle sitting on a column's right edge. Pointer capture, so a fast
|
||||
// drag that outruns the cursor still tracks instead of dropping the column.
|
||||
// Double-click restores that one column's default width.
|
||||
function ColResizer({ onResize, onReset }: { onResize: (dx: number) => void; onReset: () => void }) {
|
||||
return (
|
||||
<span
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); onReset(); }}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const el = e.currentTarget;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
const move = (ev: PointerEvent) => onResize(ev.clientX - startX);
|
||||
const up = () => {
|
||||
el.removeEventListener('pointermove', move);
|
||||
el.removeEventListener('pointerup', up);
|
||||
};
|
||||
el.addEventListener('pointermove', move);
|
||||
el.addEventListener('pointerup', up);
|
||||
}}
|
||||
// Wider than it looks: a 1px target is unusable, so the hit area is 7px
|
||||
// with only the hairline painted on hover.
|
||||
className="absolute top-0 right-0 z-10 h-full w-[7px] translate-x-[3px] cursor-col-resize
|
||||
before:absolute before:inset-y-0 before:left-1/2 before:w-px before:bg-transparent
|
||||
hover:before:bg-primary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The "new" badges. Every one of these is a REASON TO CALL, which is why they
|
||||
// get a column of their own rather than a coloured edge: a stripe says something
|
||||
// is special, a badge says what, and the operator is deciding whether to break
|
||||
@@ -305,10 +443,77 @@ function snrTone(snr: number): string {
|
||||
return 'text-muted-foreground/70';
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myCall }: Props) {
|
||||
// buildPeriods cuts a decode list into T/R periods. A plain function, not a
|
||||
// memo, because it is run ONCE PER PANE: with two receivers on two bands the
|
||||
// panel shows two independent period lists side by side, and each has to be cut
|
||||
// with its own slot boundaries.
|
||||
function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
const by = new Map<number, { decodes: Decode[]; tx: TxMsg[] }>();
|
||||
for (const d of filtered) {
|
||||
const at = Date.parse(d.at);
|
||||
if (!Number.isFinite(at)) continue;
|
||||
const k = periodStartMs(at, trSeconds(d.mode, d.tr_period));
|
||||
let g = by.get(k);
|
||||
if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); }
|
||||
g.decodes.push(d);
|
||||
}
|
||||
for (const m of txMsgs) {
|
||||
const at = Date.parse(m.at);
|
||||
if (!Number.isFinite(at)) continue;
|
||||
const k = periodStartMs(at, trSeconds(m.mode, undefined));
|
||||
// A transmit slot CREATES its period when there is none.
|
||||
//
|
||||
// This is the whole alternation, and getting it wrong hid the feature
|
||||
// completely: FT8 transmits and receives in opposite slots, so the period
|
||||
// you were sending in is exactly the one with no decodes in it. Dropping
|
||||
// the message when its period was empty meant it never appeared at all.
|
||||
let g = by.get(k);
|
||||
if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); }
|
||||
g.tx.push(m);
|
||||
}
|
||||
return [...by.entries()]
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.map(([start, g]) => ({
|
||||
start,
|
||||
// The slot length this period was cut with, so its heading is labelled
|
||||
// the same way it was grouped.
|
||||
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
||||
tx: g.tx,
|
||||
// Strongest first inside a period: the eye should land on what is
|
||||
// workable, and time within a slot means nothing — they were all
|
||||
// transmitting simultaneously.
|
||||
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
|
||||
}));
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [cqOnly, setCqOnly] = useState(false);
|
||||
const [lotwOnly, setLotwOnly] = useState(false);
|
||||
// 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);
|
||||
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) => {
|
||||
const col = COLS.find((c) => c.key === key)!;
|
||||
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
|
||||
setColw((prev) => {
|
||||
if (prev[key] === w) return prev;
|
||||
const next = { ...prev, [key]: w };
|
||||
writeUiPref(COLW_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// Split the table into one column per receiver. Off by default: it only means
|
||||
// anything with two instances running, and a single-receiver operator should
|
||||
// not have to turn something off to get the layout they had.
|
||||
const [splitByInstance, setSplitByInstance] = useState(() => localStorage.getItem(SPLIT_KEY) === '1');
|
||||
// Every filter below is persisted, NOT just the NEW badges. The panel is
|
||||
// remounted on a tab change, so anything held only in React state comes back
|
||||
// cleared — which left the badges lit and the rest silently reset, a list that
|
||||
// looks filtered and is not.
|
||||
const [cqOnly, setCqOnly] = usePersisted('cqOnly', false);
|
||||
const [lotwOnly, setLotwOnly] = usePersisted('lotwOnly', false);
|
||||
const [cats, setCats] = useState<Set<NewCat>>(() => {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(CAT_KEY) || '[]');
|
||||
@@ -321,11 +526,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
try { localStorage.setItem(CAT_KEY, JSON.stringify([...next])); } catch { /* not worth failing over */ }
|
||||
return next;
|
||||
});
|
||||
const [bandSel, setBandSel] = useState('');
|
||||
const [modeSel, setModeSel] = useState('');
|
||||
const [contSel, setContSel] = useState('');
|
||||
const [minSnr, setMinSnr] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [bandSel, setBandSel] = usePersisted('bandSel', '');
|
||||
const [modeSel, setModeSel] = usePersisted('modeSel', '');
|
||||
const [contSel, setContSel] = usePersisted('contSel', '');
|
||||
const [minSnr, setMinSnr] = usePersisted('minSnr', '');
|
||||
const [search, setSearch] = usePersisted('search', '');
|
||||
|
||||
const statusOf = (d: Decode): StatusEntry | undefined =>
|
||||
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
||||
@@ -395,44 +600,27 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
|
||||
// Group into periods, newest first, and drop the operator's transmissions into
|
||||
// the slot they went out in.
|
||||
const groups = useMemo(() => {
|
||||
const by = new Map<number, { decodes: Decode[]; tx: TxMsg[] }>();
|
||||
for (const d of filtered) {
|
||||
const at = Date.parse(d.at);
|
||||
if (!Number.isFinite(at)) continue;
|
||||
const k = periodStartMs(at, trSeconds(d.mode, d.tr_period));
|
||||
let g = by.get(k);
|
||||
if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); }
|
||||
g.decodes.push(d);
|
||||
// One PANE per receiver when the split is on. Two receivers on two bands is
|
||||
// the day this panel earns its keep, and merging their decodes into one list
|
||||
// throws away the only thing that tells them apart.
|
||||
//
|
||||
// Each pane cuts its own periods: the bands differ, so the slot boundaries and
|
||||
// the transmit messages belong to one receiver and not the other.
|
||||
const panes = useMemo(() => {
|
||||
if (!splitByInstance || instances.length < 2) {
|
||||
return [{ key: '', label: '', tx: txState ?? undefined, periods: buildPeriods(filtered, txMsgs) }];
|
||||
}
|
||||
for (const m of txMsgs) {
|
||||
const at = Date.parse(m.at);
|
||||
if (!Number.isFinite(at)) continue;
|
||||
const k = periodStartMs(at, trSeconds(m.mode, undefined));
|
||||
// A transmit slot CREATES its period when there is none.
|
||||
//
|
||||
// This is the whole alternation, and getting it wrong hid the feature
|
||||
// completely: FT8 transmits and receives in opposite slots, so the period
|
||||
// you were sending in is exactly the one with no decodes in it. Dropping
|
||||
// the message when its period was empty meant it never appeared at all.
|
||||
let g = by.get(k);
|
||||
if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); }
|
||||
g.tx.push(m);
|
||||
}
|
||||
return [...by.entries()]
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.map(([start, g]) => ({
|
||||
start,
|
||||
// The slot length this period was cut with, so its heading is labelled
|
||||
// the same way it was grouped.
|
||||
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
||||
tx: g.tx,
|
||||
// Strongest first inside a period: the eye should land on what is
|
||||
// workable, and time within a slot means nothing — they were all
|
||||
// transmitting simultaneously.
|
||||
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
|
||||
}));
|
||||
}, [filtered, txMsgs]);
|
||||
return instances.map((inst) => ({
|
||||
key: inst,
|
||||
label: inst,
|
||||
tx: txStates?.[inst],
|
||||
periods: buildPeriods(
|
||||
filtered.filter((d) => (d.instance ?? '') === inst),
|
||||
txMsgs.filter((m) => (m.instance ?? '') === inst),
|
||||
),
|
||||
}));
|
||||
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates]);
|
||||
|
||||
|
||||
const resetFilters = () => {
|
||||
setCqOnly(false); setLotwOnly(false); setBandSel('');
|
||||
@@ -467,10 +655,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
<PeriodClock trSec={liveTr} mode={liveMode} />
|
||||
<span className="w-px h-5 bg-border/60 mx-1" />
|
||||
|
||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly((v) => !v)}>
|
||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
||||
{t('dec.cqOnly')}
|
||||
</button>
|
||||
<button type="button" className={chip(lotwOnly)} onClick={() => setLotwOnly((v) => !v)}>
|
||||
<button type="button" className={chip(lotwOnly)} onClick={() => setLotwOnly(!lotwOnly)}>
|
||||
{t('dec.lotwOnly')}
|
||||
</button>
|
||||
|
||||
@@ -545,6 +733,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
<span className="flex-1" />
|
||||
{/* Which receivers are feeding this. Only with more than one — with a
|
||||
single MSHV it is a label stating the obvious. */}
|
||||
{/* Only with two receivers running: with one it would be a control that
|
||||
does nothing, which is worse than no control. */}
|
||||
{instances.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSplitByInstance((v) => { try { localStorage.setItem(SPLIT_KEY, v ? '0' : '1'); } catch { /* quota */ } return !v; })}
|
||||
title={t('dec.splitTip')}
|
||||
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border',
|
||||
splitByInstance
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground')}
|
||||
>
|
||||
<Columns2 className="size-3.5" /> {t('dec.split')}
|
||||
</button>
|
||||
)}
|
||||
{instances.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('dec.instances', { n: instances.length })}
|
||||
@@ -553,6 +756,55 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t('dec.count', { shown: filtered.length, total: decodes.length })}
|
||||
</span>
|
||||
|
||||
{/* Halt and Clear sit together at the right, away from the filters: one
|
||||
talks to the radio, the other only to this list, and neither is a
|
||||
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. */}
|
||||
{onToggleAutoCall && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAutoCall}
|
||||
title={t('dec.autoCallTip')}
|
||||
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border',
|
||||
// Lit when it is armed, because what matters at a glance is not
|
||||
// "where is the switch" but "is this thing about to transmit".
|
||||
autoCallOn
|
||||
? 'border-warning bg-warning text-warning-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground')}
|
||||
>
|
||||
<Bot className="size-3.5" /> {t('dec.autoCall')}
|
||||
</button>
|
||||
)}
|
||||
{onHalt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHalt(txState?.instance ?? '')}
|
||||
title={t('dec.haltTitle')}
|
||||
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border',
|
||||
// Hot while the carrier is actually up: that is when the button is
|
||||
// urgent, and an always-red control stops being read as a warning.
|
||||
txState?.transmitting
|
||||
? 'border-danger bg-danger text-danger-foreground hover:brightness-110'
|
||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground')}
|
||||
>
|
||||
<Ban className="size-3.5" /> {t('dec.halt')}
|
||||
</button>
|
||||
)}
|
||||
{onClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClear()}
|
||||
disabled={decodes.length === 0 && txMsgs.length === 0}
|
||||
title={t('dec.clearAllTitle')}
|
||||
className="h-8 px-2.5 rounded-lg text-sm text-muted-foreground border border-border
|
||||
hover:bg-muted hover:text-foreground inline-flex items-center gap-1.5
|
||||
disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
<Trash2 className="size-3.5" /> {t('dec.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── What I am sending, and to whom ─────────────────────────── */}
|
||||
@@ -560,8 +812,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
Its own strip rather than a line in the list: it is the one thing on this
|
||||
screen that is about the operator and not about the band, and while a
|
||||
period scrolls away this stays put. It appears as soon as a Status
|
||||
arrives, so it says who is being called even on a sender that never
|
||||
reports its transmit text.
|
||||
arrives, so it says who is being called even before anything is sent.
|
||||
*/}
|
||||
{txState && (txState.msg || txState.dx_call) && (
|
||||
<div className={cn('flex items-center gap-3 px-3 py-2 shrink-0 border-b',
|
||||
@@ -571,9 +822,18 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
{txState.transmitting && <span className="size-2 rounded-full bg-primary animate-pulse" />}
|
||||
{txState.transmitting ? t('dec.txNow') : t('dec.txIdle')}
|
||||
</span>
|
||||
{/* An EMPTY tx_message is the ordinary state between overs — WSJT-X,
|
||||
JTDX and MSHV all send Status about once a second with nothing in
|
||||
that field when nothing is queued. Reading it as "this application
|
||||
does not report its transmit text" accused the sender of a
|
||||
shortcoming on the strength of one empty string, and said so for
|
||||
most of every cycle. Only a sender that is KEYED and still tells us
|
||||
nothing has actually withheld anything. */}
|
||||
{txState.msg
|
||||
? <span className="font-mono text-base font-semibold text-foreground truncate">{txState.msg}</span>
|
||||
: <span className="text-sm text-muted-foreground italic">{t('dec.txUnknown')}</span>}
|
||||
: <span className="text-sm text-muted-foreground italic">
|
||||
{txState.transmitting ? t('dec.txUnknown') : t('dec.txNothing')}
|
||||
</span>}
|
||||
{txState.dx_call && (
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider">{t('dec.working')}</span>
|
||||
@@ -588,24 +848,83 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* One table per receiver when the split is on, side by side. Each pane
|
||||
carries its OWN column header: they are independent tables at their own
|
||||
widths, and one shared header row would line up with none of them. */}
|
||||
<div className="flex-1 min-h-0 flex gap-px bg-border/40">
|
||||
{panes.map((pane) => (
|
||||
<div key={pane.key}
|
||||
className={cn('flex-1 min-w-0 flex flex-col min-h-0 bg-card',
|
||||
pane.tx?.transmitting && 'ring-2 ring-primary ring-inset')}>
|
||||
{pane.label && (
|
||||
<div className={cn('shrink-0 flex items-center gap-1.5 px-2 py-1 border-b border-border',
|
||||
pane.tx?.transmitting ? 'bg-primary/15 border-primary/40' : 'bg-muted/40')}>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate">
|
||||
{pane.label}
|
||||
</span>
|
||||
{/* This pane's OWN transmit line. The shared strip shows whichever
|
||||
instance reported last, which in a split view is a coin toss. */}
|
||||
{pane.tx?.msg && (
|
||||
<span className={cn('font-mono text-[11px] truncate',
|
||||
pane.tx.transmitting ? 'text-primary font-semibold' : 'text-muted-foreground')}>
|
||||
{pane.tx.transmitting && (
|
||||
<span className="inline-flex items-center gap-1 mr-1.5 font-sans font-bold uppercase tracking-wider">
|
||||
<span className="inline-block size-1.5 rounded-full bg-primary animate-pulse" />
|
||||
{t('dec.txNow')}
|
||||
</span>
|
||||
)}
|
||||
{pane.tx.msg}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{onHalt && (
|
||||
<button type="button"
|
||||
onClick={() => onHalt(pane.key)}
|
||||
title={t('dec.haltOne', { n: pane.label })}
|
||||
className={cn('h-6 px-1.5 rounded text-[11px] inline-flex items-center gap-1 border',
|
||||
pane.tx?.transmitting
|
||||
? 'border-danger bg-danger text-danger-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground')}>
|
||||
<Ban className="size-3" /> {t('dec.halt')}
|
||||
</button>
|
||||
)}
|
||||
{onClear && (
|
||||
<button type="button"
|
||||
onClick={() => onClear(pane.key)}
|
||||
title={t('dec.clearOne', { n: pane.label })}
|
||||
className="h-6 px-1.5 rounded text-[11px] inline-flex items-center gap-1 border border-border text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* ── Column header ──────────────────────────────────────────── */}
|
||||
<div className="shrink-0 border-b border-border bg-background">
|
||||
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}>
|
||||
<span className={CELL}>{t('dec.colTime')}</span>
|
||||
<span className={cn(CELL, 'justify-end')}>{t('dec.colSnr')}</span>
|
||||
<span className={cn(CELL, 'justify-end')} title={t('dec.colDtTitle')}>{t('dec.colDt')}</span>
|
||||
<span className={cn(CELL, 'justify-end')} title={t('dec.colFreqTitle')}>{t('dec.colFreq')}</span>
|
||||
<span className={CELL}>{t('dec.colBand')}</span>
|
||||
<span className={CELL}>{t('dec.colMode')}</span>
|
||||
<span className={CELL}>{t('dec.colMsg')}</span>
|
||||
<span className={CELL}>{t('dec.colCountry')}</span>
|
||||
<span className={CELL_LAST}>{t('dec.colStatus')}</span>
|
||||
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
|
||||
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
|
||||
style={{ gridTemplateColumns: template, width: tableW }}>
|
||||
{COLS.map((c, i) => (
|
||||
<span key={c.key}
|
||||
// Not CELL_LAST for the final column: its overflow-hidden would
|
||||
// clip that column's own resize handle.
|
||||
className={cn('relative flex items-center min-w-0 px-2',
|
||||
i < COLS.length - 1 && 'border-r border-border/30',
|
||||
// The three numeric columns label their own right edge, where the
|
||||
// figures are.
|
||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
|
||||
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
||||
<span className="truncate">{t(c.tkey)}</span>
|
||||
<ColResizer
|
||||
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
||||
onReset={() => setColWidth(c.key, c.def)}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Periods ────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{groups.length === 0 && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
{pane.periods.length === 0 && (
|
||||
<div className="h-full flex items-center justify-center px-6 text-center">
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{decodes.length === 0 ? t('dec.empty') : t('dec.emptyFiltered')}
|
||||
@@ -613,7 +932,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((g, gi) => (
|
||||
{pane.periods.map((g, gi) => (
|
||||
<section key={g.start}>
|
||||
{/* Period header — sticky, so the slot being read is always named. */}
|
||||
<header className="sticky top-0 z-10 px-3 bg-muted/95 backdrop-blur border-y border-border/60">
|
||||
@@ -666,7 +985,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
type="button"
|
||||
onClick={() => onCall(d)}
|
||||
title={t('dec.callTitle', { call: d.call })}
|
||||
className={cn(ROW, 'w-full text-left border-b border-border/20 transition-colors',
|
||||
style={{ gridTemplateColumns: template, width: tableW }}
|
||||
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
||||
replying ? 'bg-success/20 hover:bg-success/25'
|
||||
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
||||
: mine ? 'bg-info/10'
|
||||
@@ -716,6 +1036,16 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
|
||||
</span>
|
||||
|
||||
{/* The decode's own grid first, the remembered one as the
|
||||
fallback: this period's message is more current than
|
||||
whatever was cached from an earlier one. Dimmed when it is
|
||||
the remembered value, so "heard it now" and "known from
|
||||
before" are not read as the same thing. */}
|
||||
<span className={cn(CELL, 'font-mono text-[11px]',
|
||||
d.grid ? 'text-foreground/85' : 'text-muted-foreground/70')}>
|
||||
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
||||
</span>
|
||||
|
||||
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
||||
<span className="truncate">{e?.country ?? ''}</span>
|
||||
</span>
|
||||
@@ -737,13 +1067,25 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
{t(entity.label)}
|
||||
</span>
|
||||
)}
|
||||
{extras.map((b) => (
|
||||
<span key={b.key as string}
|
||||
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
||||
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
|
||||
{t(b.label)}
|
||||
</span>
|
||||
))}
|
||||
{extras.map((b) => {
|
||||
// A square WORKED but not yet confirmed is a different job
|
||||
// from one never worked: a QSL to chase, not a QSO to make.
|
||||
// Same hue, held back — the badge reads as "less" of the
|
||||
// same thing, and its label says which. One badge for both
|
||||
// is what made a station worked an hour ago read as new.
|
||||
const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf';
|
||||
const c = markerColour(b.marker);
|
||||
return (
|
||||
<span key={b.key as string}
|
||||
title={unconf ? t('dec.bgGridUnconfTip') : undefined}
|
||||
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
||||
style={unconf
|
||||
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
||||
: { borderColor: c, color: c }}>
|
||||
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{replying && (
|
||||
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-success text-success-foreground shrink-0">
|
||||
{t('dec.toYou')}
|
||||
@@ -755,6 +1097,9 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user