chore: release v0.26.0
This commit is contained in:
@@ -69,6 +69,7 @@ export type SpotStatusEntry = {
|
||||
spotter_continent?: string;
|
||||
grid?: string;
|
||||
new_grid?: boolean;
|
||||
grid_state?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -266,12 +267,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
||||
if (s?.new_grid) parts.push(t('clg2.newGrid'));
|
||||
if (s?.new_grid) parts.push(s.grid_state === 'unconf' ? t('clg2.newGridUnconf') : t('clg2.newGrid'));
|
||||
return parts.join(' ');
|
||||
},
|
||||
cellRenderer: (p: any) => {
|
||||
const s = statusFor(p);
|
||||
const parts: { text: string; color: string }[] = [];
|
||||
const parts: { text: string; color: string; dim?: boolean }[] = [];
|
||||
const main = statusColor(s);
|
||||
if (main) {
|
||||
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||
@@ -287,12 +288,19 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
|
||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
|
||||
if (s?.new_grid) parts.push({ text: t('clg2.newGrid'), color: markerColour('new_grid') });
|
||||
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
|
||||
// held back, so it reads as "less" of the same thing rather than a
|
||||
// different fact — and the label says which.
|
||||
if (s?.new_grid) {
|
||||
parts.push(s.grid_state === 'unconf'
|
||||
? { text: t('clg2.newGridUnconf'), color: markerColour('new_grid'), dim: true }
|
||||
: { text: t('clg2.newGrid'), color: markerColour('new_grid') });
|
||||
}
|
||||
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||
return (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
{parts.map((pt, i) => (
|
||||
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
|
||||
<span key={i} style={{ color: pt.color, fontWeight: 700, opacity: pt.dim ? 0.5 : 1 }}>
|
||||
{i > 0 ? ' · ' : ''}{pt.text}
|
||||
</span>
|
||||
))}
|
||||
@@ -638,6 +646,17 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
{t('clg2.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Whole-dialog controls. showAll/hideAll with no group already mean
|
||||
"every column"; only the buttons were missing, so hiding the lot
|
||||
took one click per group. */}
|
||||
<div className="flex items-center gap-1 px-1 text-[11px] text-muted-foreground">
|
||||
<span>{t('clg2.allGroups')}</span>
|
||||
<button type="button" className="text-[11px] text-primary hover:underline px-1"
|
||||
onClick={() => showAll()}>{t('clg2.all')}</button>
|
||||
<span className="opacity-40">·</span>
|
||||
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
|
||||
onClick={() => hideAll()}>{t('clg2.none')}</button>
|
||||
</div>
|
||||
<div className="max-h-[60vh] overflow-y-auto py-2">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -86,6 +86,13 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
||||
return [...m.entries()];
|
||||
}, [official]);
|
||||
|
||||
// Every tag the dialog can offer — the extras AND every group — so "select
|
||||
// all" means what it says rather than "all the official ones".
|
||||
const everyTag = useMemo(
|
||||
() => [...extras, ...groups.flatMap(([, list]) => list.map((d) => d.name))],
|
||||
[extras, groups],
|
||||
);
|
||||
|
||||
const toggle = (name: string, on: boolean) =>
|
||||
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
||||
const setMany = (names: string[], on: boolean) =>
|
||||
@@ -114,11 +121,25 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
||||
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Whole-dialog select/clear, next to the count they change. Per-group
|
||||
links alone meant clearing the selection was one click PER GROUP —
|
||||
a dozen of them to get to "just the handful I want". */}
|
||||
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||
{t('exf.defaults')}
|
||||
</Button>
|
||||
<span className="ml-auto inline-flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[11px]"
|
||||
onClick={() => setSel(new Set(everyTag))}>
|
||||
{t('exf.selectAll')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[11px]"
|
||||
disabled={sel.size === 0}
|
||||
onClick={() => setSel(new Set())}>
|
||||
{t('exf.selectNone')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||
{t('exf.defaults')}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { Loader2, RefreshCw } from 'lucide-react';
|
||||
import { GridSquares } from '../../wailsjs/go/main/App';
|
||||
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
|
||||
// GridSquareMap — every Maidenhead square in the log, drawn on a world map.
|
||||
//
|
||||
// A square, not a marker. The question this answers is "where have I been
|
||||
// heard", and the shape of the answer is an area: a wall of squares across the
|
||||
// Atlantic and a bare Pacific says something about an antenna that a scatter of
|
||||
// pins does not. Confirmed and merely worked are two fills of the same hue —
|
||||
// the distinction is a STATE of one thing, not two categories, so it must not
|
||||
// be two unrelated colours.
|
||||
//
|
||||
// 4 characters, never 6: at world zoom a 6-character square is sub-pixel, and
|
||||
// aggregating to the big square is also how the count means "contacts in this
|
||||
// square" rather than "contacts at this exact spot".
|
||||
|
||||
type Square = { grid: string; count: number; confirmed: boolean; band?: string; mode?: string };
|
||||
|
||||
// The SAME basemaps the Main-tab world map offers, imported rather than copied:
|
||||
// two lists of tile servers is how one map ends up on a provider the other has
|
||||
// already been blocked by. This map opened on a hardcoded dark Carto, which is
|
||||
// the least legible of the four under translucent squares.
|
||||
|
||||
// Leaflet geometry is painted onto a CANVAS, and canvas takes a colour, not a
|
||||
// stylesheet: "var(--success)" handed to fillStyle is simply invalid and the
|
||||
// shape is silently not drawn — a map with a correct square count and nothing
|
||||
// on it. So the token is RESOLVED to its literal value here, once per theme,
|
||||
// which is also why every other map in this app passes hex.
|
||||
function cssColour(token: string, fallback: string): string {
|
||||
try {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
|
||||
return v || fallback;
|
||||
} catch { return fallback; }
|
||||
}
|
||||
|
||||
// Mode scope. Two, because the map lives beside a panel that decodes FTx: CW,
|
||||
// phone and "everything" answer a question nobody is asking here, and a row of
|
||||
// choices that are never the right one is just noise to read past.
|
||||
//
|
||||
// Digital is every digital mode; FTx is the FT family alone, which is the
|
||||
// narrower and usually the honest one — a square worked on RTTY in a contest is
|
||||
// not a square worked on FT8.
|
||||
const SCOPES = [
|
||||
{ key: 'DIGI', label: 'gsm.digital' },
|
||||
{ key: 'FTX', label: 'gsm.ftx' },
|
||||
] as const;
|
||||
type ScopeKey = typeof SCOPES[number]['key'];
|
||||
|
||||
const SCOPE_KEY = 'opslog.gridMapScope';
|
||||
|
||||
export function GridSquareMap({ myGrid, className }: { myGrid?: string; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
const [squares, setSquares] = useState<Square[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [scope, setScope] = useState<ScopeKey>(
|
||||
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
||||
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
||||
|
||||
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
|
||||
// Repaint the squares when the THEME changes, not the basemap: the fills come
|
||||
// from theme tokens resolved at draw time, so a theme switch leaves them on
|
||||
// the old palette until something forces a redraw.
|
||||
const [themeTick, setThemeTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const obs = new MutationObserver(() => setThemeTick((n) => n + 1));
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
const load = async (sc: ScopeKey = scope) => {
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const r = (await GridSquares(sc)) as any;
|
||||
setSquares((Array.isArray(r) ? r : []) as Square[]);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
setSquares([]);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);
|
||||
|
||||
// One-time map creation. preferCanvas: a busy digital log is a few thousand
|
||||
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
|
||||
// pan.
|
||||
useEffect(() => {
|
||||
if (!hostRef.current || mapRef.current) return;
|
||||
const m = L.map(hostRef.current, {
|
||||
preferCanvas: true,
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
worldCopyJump: true,
|
||||
minZoom: 1,
|
||||
}).setView([25, 0], 2);
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
return () => { m.remove(); mapRef.current = null; layerRef.current = null; };
|
||||
}, []);
|
||||
|
||||
// The chosen basemap, shared with the Main-tab map so picking one there and
|
||||
// finding another here cannot happen.
|
||||
const baseRef = useRef<L.TileLayer | null>(null);
|
||||
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||
useEffect(() => {
|
||||
const m = mapRef.current;
|
||||
if (!m) return;
|
||||
addBasemap(m, basemap, baseRef, labelsRef);
|
||||
baseRef.current?.bringToBack();
|
||||
}, [basemap]);
|
||||
|
||||
// Redraw the squares.
|
||||
useEffect(() => {
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
// Resolved once for the whole redraw, not per square: getComputedStyle
|
||||
// forces a style flush, and doing that a thousand times is a visible stall.
|
||||
const confirmedColour = cssColour('--success', '#16a34a');
|
||||
const workedColour = cssColour('--chart-1', '#2a78d6');
|
||||
const meColour = cssColour('--warning', '#f59e0b');
|
||||
for (const sq of squares ?? []) {
|
||||
const b = gridSquareBounds(sq.grid);
|
||||
if (!b) continue;
|
||||
// One hue, two states. Confirmed is the solid, saturated one; worked is
|
||||
// the same colour held back — so the eye reads "more" and "less" of the
|
||||
// same thing rather than two unrelated facts.
|
||||
const colour = sq.confirmed ? confirmedColour : workedColour;
|
||||
L.rectangle([[b.south, b.west], [b.north, b.east]], {
|
||||
color: colour,
|
||||
weight: 0.5,
|
||||
opacity: sq.confirmed ? 0.9 : 0.5,
|
||||
fillColor: colour,
|
||||
fillOpacity: sq.confirmed ? 0.55 : 0.22,
|
||||
})
|
||||
.bindTooltip(
|
||||
`${sq.grid} — ${sq.count} QSO${sq.count > 1 ? 's' : ''}` +
|
||||
`${sq.band ? ` · ${sq.band}` : ''}${sq.mode ? ` ${sq.mode}` : ''}` +
|
||||
`${sq.confirmed ? ` · ${t('gsm.confirmed')}` : ''}`,
|
||||
{ sticky: true },
|
||||
)
|
||||
.addTo(layer);
|
||||
}
|
||||
// The operator's own square, so the pattern has an origin to be read from.
|
||||
const me = myGrid ? gridToLatLon(myGrid) : null;
|
||||
if (me) {
|
||||
L.circleMarker([me.lat, me.lon], {
|
||||
radius: 4, color: meColour, weight: 2,
|
||||
fillColor: meColour, fillOpacity: 1,
|
||||
}).bindTooltip(myGrid!.toUpperCase(), { sticky: true }).addTo(layer);
|
||||
}
|
||||
}, [squares, myGrid, t, themeTick]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const list = squares ?? [];
|
||||
return { total: list.length, confirmed: list.filter((s) => s.confirmed).length };
|
||||
}, [squares]);
|
||||
|
||||
return (
|
||||
// isolate is load-bearing, not tidiness. Leaflet puts its panes at z-index
|
||||
// 400 and its zoom control at 1000, in the PAGE's stacking context — and the
|
||||
// modals here are z-50. Without a stacking context of its own the map floats
|
||||
// over Preferences and hides the Save and Close buttons. isolation:isolate
|
||||
// confines every z-index Leaflet sets to this element.
|
||||
<section className={cn('isolate relative flex flex-col min-h-0 rounded-lg border border-border bg-card overflow-hidden', className)}>
|
||||
<header className="flex items-center gap-2 px-2.5 py-1.5 border-b border-border bg-muted/40 shrink-0 flex-wrap">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('gsm.title')}
|
||||
</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{SCOPES.map((s, i) => (
|
||||
<button key={s.key} type="button"
|
||||
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
|
||||
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
|
||||
{t(s.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
|
||||
</span>
|
||||
<select
|
||||
value={basemap}
|
||||
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); writeUiPref('opslog.mapBasemap', v); }}
|
||||
title={t('gsm.basemap')}
|
||||
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||
>
|
||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||
<option key={k} value={k}>{BASEMAPS[k].label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
|
||||
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
</button>
|
||||
</header>
|
||||
{err && <p className="px-2.5 py-1 text-[11px] text-destructive shrink-0">{err}</p>}
|
||||
{/* The map host must have a real height or Leaflet renders nothing at all
|
||||
— flex-1 + min-h-0, never a percentage. */}
|
||||
<div ref={hostRef} className="flex-1 min-h-0" />
|
||||
<footer className="flex items-center gap-3 px-2.5 py-1 border-t border-border bg-muted/30 shrink-0 text-[10px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block size-2.5 rounded-[2px]"
|
||||
style={{ background: 'var(--success)', opacity: 0.75 }} />
|
||||
{t('gsm.confirmed')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block size-2.5 rounded-[2px]"
|
||||
style={{ background: 'var(--chart-1)', opacity: 0.35 }} />
|
||||
{t('gsm.worked')}
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -61,8 +61,8 @@ const ESRI_STREET_ATTR = 'Tiles © Esri — Source: Esri, HERE, Garmin, &cop
|
||||
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
||||
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
||||
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
||||
type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||||
const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
||||
export type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||||
export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
||||
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||
@@ -71,7 +71,7 @@ const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; s
|
||||
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
||||
};
|
||||
function loadBasemap(): BasemapKey {
|
||||
export function loadBasemap(): BasemapKey {
|
||||
const v = localStorage.getItem('opslog.mapBasemap');
|
||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||
}
|
||||
@@ -80,7 +80,7 @@ function loadBasemap(): BasemapKey {
|
||||
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||||
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||||
// layers WebView2 has to hold — is double every other basemap's.
|
||||
function addBasemap(
|
||||
export function addBasemap(
|
||||
m: L.Map,
|
||||
key: BasemapKey,
|
||||
base: React.MutableRefObject<L.TileLayer | null>,
|
||||
|
||||
@@ -111,7 +111,33 @@ export function fmtQslDate(s?: string): string {
|
||||
|
||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
||||
// and download confirmations, while the rest of the app stays usable.
|
||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
// GridActions are the row actions the context menu offers, forwarded straight to
|
||||
// whichever grid this panel is showing.
|
||||
//
|
||||
// The FILTERED exports are deliberately absent. "Export everything the filter
|
||||
// matches" means the Recent-QSOs filter, and the rows here come from a different
|
||||
// query entirely — offering it would export something other than what is on the
|
||||
// screen, which is the one thing an export must never do. Selection-based
|
||||
// exports have no such ambiguity: the ids are the rows you ticked.
|
||||
export type GridActions = {
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
export function QSLManagerPanel({ onEditQSO, actions }: {
|
||||
onEditQSO?: (id: number) => void;
|
||||
actions?: GridActions;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
const [service, setService] = useState('lotw');
|
||||
// The callsign this profile signs/uploads/downloads as for the selected
|
||||
@@ -443,6 +469,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
total={paperRows.length}
|
||||
selectAllSignal={paperSelAllSig}
|
||||
onRowSelected={(ids) => setPaperSel(new Set(ids))}
|
||||
{...actions}
|
||||
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -549,6 +577,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
total={rows.length}
|
||||
selectAllSignal={uploadSelAllSig}
|
||||
onRowSelected={onUploadRowSelected}
|
||||
{...actions}
|
||||
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,8 +7,8 @@ export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||
type Props = {
|
||||
menu: QSOMenuState;
|
||||
onClose: () => void;
|
||||
onUpdateFromCty: (ids: number[]) => void;
|
||||
onUpdateFromQRZ: (ids: number[]) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
// Only passed once the offline US county database has been downloaded —
|
||||
// an entry that can only ever answer "no database" is not worth a line here.
|
||||
@@ -98,20 +98,28 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('qctx.selected', { n })}
|
||||
</div>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||
>
|
||||
<Globe2 className="size-4 text-primary" />
|
||||
<span>{t('qctx.fixCountry')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||
>
|
||||
<RefreshCw className="size-4 text-info" />
|
||||
<span>{t('qctx.updateQrz')}</span>
|
||||
</button>
|
||||
{/* Conditional like every other entry. These two were drawn
|
||||
unconditionally, and the grid passes them through an arrow that calls
|
||||
an optional handler — so in a grid that wires neither (the QSL
|
||||
Manager) both appeared and did precisely nothing when clicked. */}
|
||||
{onUpdateFromCty && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
|
||||
>
|
||||
<Globe2 className="size-4 text-primary" />
|
||||
<span>{t('qctx.fixCountry')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onUpdateFromQRZ && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||
>
|
||||
<RefreshCw className="size-4 text-info" />
|
||||
<span>{t('qctx.updateQrz')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onUpdateFromClublog && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
|
||||
@@ -631,6 +631,20 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
api.setColumnsVisible(ids, false);
|
||||
saveColumnState();
|
||||
}
|
||||
// Whole-dialog show/hide. showAll/hideAll cover the CATALOGUE only; the award
|
||||
// columns live in their own persisted code set, so a global that skipped them
|
||||
// would leave a row of columns behind and look broken.
|
||||
function setEverything(visible: boolean) {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
api.setColumnsVisible(COL_CATALOG.map((c) => c.colId!), visible);
|
||||
if (awardCols && awardCols.length > 0) {
|
||||
persistAwardShown(visible ? new Set(awardCols.map((a) => a.code.toUpperCase())) : new Set());
|
||||
api.setColumnsVisible(awardCols.map((a) => `award_${a.code}`), visible);
|
||||
}
|
||||
saveColumnState();
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
const api = gridRef.current?.api;
|
||||
if (!api) return;
|
||||
@@ -711,11 +725,15 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Every handler is passed STRAIGHT through, never wrapped in an arrow: a
|
||||
wrapper is always truthy, so the menu drew "Fix country" and "Update
|
||||
from the callsign databases" even in a grid that wired neither — and
|
||||
clicking them did nothing at all. */}
|
||||
<QSOContextMenu
|
||||
menu={menu}
|
||||
onClose={() => setMenu(null)}
|
||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||
onUpdateFromCty={onUpdateFromCty}
|
||||
onUpdateFromQRZ={onUpdateFromQRZ}
|
||||
onUpdateFromClublog={onUpdateFromClublog}
|
||||
onUpdateCountyFromULS={onUpdateCountyFromULS}
|
||||
onSendTo={onSendTo}
|
||||
@@ -738,6 +756,16 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
{t('rqg.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Whole-dialog controls. The per-group links alone meant hiding
|
||||
everything was one click PER GROUP, and there are a dozen. */}
|
||||
<div className="flex items-center gap-1 px-5 text-[11px] text-muted-foreground">
|
||||
<span>{t('rqg.pickerAllGroups')}</span>
|
||||
<button type="button" className="text-[11px] text-primary hover:underline px-1"
|
||||
onClick={() => setEverything(true)}>{t('rqg.all')}</button>
|
||||
<span className="opacity-40">·</span>
|
||||
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
|
||||
onClick={() => setEverything(false)}>{t('rqg.none')}</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { loadAutoCall, autoCallKey, type AutoCallSettings, type AutoCallCriteria } from '@/lib/autocall';
|
||||
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||
@@ -202,6 +203,7 @@ type SectionId =
|
||||
| 'uscounties'
|
||||
| 'awards'
|
||||
| 'cat'
|
||||
| 'ftx'
|
||||
| 'rotator'
|
||||
| 'winkeyer'
|
||||
| 'antenna'
|
||||
@@ -306,6 +308,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{ kind: 'item', label: t('sec.modes'), id: 'lists-modes' },
|
||||
]},
|
||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||
{ kind: 'item', label: t('sec.ftx'), id: 'ftx' },
|
||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
|
||||
@@ -325,7 +328,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||
cluster: 'sec.cluster', ftx: 'sec.ftx', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||
adifmon: 'sec.adifmon',
|
||||
foldersync: 'sec.foldersync',
|
||||
webpublish: 'sec.webpublish',
|
||||
@@ -1519,6 +1522,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||
const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0');
|
||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
// Compact rotor widget: the dial and the SP/LP readout only. Off by default —
|
||||
// the full widget is what the operator has today, and a setting that changes
|
||||
// a panel the moment you upgrade is a setting that gets blamed for it.
|
||||
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
|
||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||
@@ -1724,6 +1731,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// has side effects there — adding the RBN nodes, bringing the PSK Reporter
|
||||
// feed up or down — so the write has to go where those live.
|
||||
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
||||
// How a worked square is matched, and what counts as still wanted.
|
||||
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
|
||||
const [gridScope, setGridScope] = useState<any>({ scope: 'mix_digi', hunt: 'new', scopes: [] });
|
||||
useEffect(() => { GetGridScopeSettings().then((g) => setGridScope(g as any)).catch(() => {}); }, []);
|
||||
const saveGridScope = async (next: any) => {
|
||||
setGridScope(next);
|
||||
try { await SaveGridScopeSettings(next); } catch { /* the panel keeps the choice either way */ }
|
||||
};
|
||||
const [chaseGrids, setChaseGrids] = useState(false);
|
||||
const [chaseNew, setChaseNew] = useState(false);
|
||||
const [spotTTL, setSpotTTL] = useState(0);
|
||||
@@ -4180,10 +4195,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How much of the rotor widget to draw. Compact keeps the dial and the
|
||||
SP/LP azimuths and drops the controls — for an operator who turns
|
||||
the antenna from the bearing pill or from PstRotator and wants the
|
||||
dial to take a column, not a panel. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div className="text-sm font-semibold">{t('rot.widget')}</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={rotorCompact}
|
||||
onCheckedChange={(c) => { const v = !!c; setRotorCompact(v); writeUiPref('opslog.rotorCompact', v ? '1' : '0'); }} />
|
||||
{t('rot.compact')}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t('rot.compactHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Quick-turn buttons for the rotor widget. Their azimuths start out
|
||||
computed from the station square, so they are right for THIS QTH
|
||||
rather than copied from someone else's. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
rather than copied from someone else's. Hidden in compact mode —
|
||||
editing buttons that are not drawn is a trap. */}
|
||||
<div className={cn('border-t border-border/60 pt-3 space-y-2', rotorCompact && 'opacity-40')}>
|
||||
<div className="text-sm font-semibold">{t('rot.presets')}</div>
|
||||
<p className="text-xs text-muted-foreground">{t('rot.presetsHint')}</p>
|
||||
<div className="space-y-1.5">
|
||||
@@ -4560,6 +4590,91 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
setEditingServer(next);
|
||||
}
|
||||
|
||||
// FTx decodes: what OpsLog does on its own with the digital stream.
|
||||
//
|
||||
// Auto-call KEYS THE TRANSMITTER without anyone clicking, so the panel is
|
||||
// deliberately explicit about it: off by default, every criterion opt-in, and
|
||||
// a plain statement of what the machine will do once it is on.
|
||||
function FtxPanel() {
|
||||
const set = (patch: Partial<AutoCallSettings>) => {
|
||||
const next = { ...autoCall, ...patch };
|
||||
setAutoCall(next);
|
||||
writeUiPref(autoCallKey, JSON.stringify(next));
|
||||
};
|
||||
const crit = (which: 'criteria' | 'watchCriteria', k: keyof AutoCallCriteria) => (
|
||||
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!autoCall[which][k]}
|
||||
onCheckedChange={(v) => set({ [which]: { ...autoCall[which], [k]: !!v } } as any)}
|
||||
/>
|
||||
{t(`ftx.c_${k}`)}
|
||||
</label>
|
||||
);
|
||||
const KEYS: (keyof AutoCallCriteria)[] = ['dxcc', 'band', 'mode', 'slot', 'grid', 'county', 'pota', 'pfx'];
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={t('sec.ftx')} hint={t('ftx.hint')} />
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border p-3 space-y-3">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={autoCall.enabled} className="mt-0.5"
|
||||
onCheckedChange={(v) => set({ enabled: !!v })} />
|
||||
<span>
|
||||
{t('ftx.enable')}{' '}
|
||||
<span className="text-xs text-muted-foreground">{t('ftx.enableHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{autoCall.enabled && (
|
||||
<div className="pl-6 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-1.5">{t('ftx.callWhen')}</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">{KEYS.map((k) => crit('criteria', k))}</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-1">{t('ftx.watch')}</p>
|
||||
<p className="text-[11px] text-muted-foreground mb-1.5 leading-relaxed">{t('ftx.watchHint')}</p>
|
||||
<Textarea
|
||||
className="h-20 font-mono text-xs"
|
||||
placeholder={"4S7*\nTM0HQ\n*/P"}
|
||||
defaultValue={(autoCall.watch ?? []).join('\n')}
|
||||
key={`w-${(autoCall.watch ?? []).length}`}
|
||||
onBlur={(e) => set({
|
||||
watch: e.target.value.split('\n').map((x) => x.trim().toUpperCase()).filter(Boolean),
|
||||
})}
|
||||
/>
|
||||
{(autoCall.watch ?? []).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-[11px] text-muted-foreground mb-1">{t('ftx.watchOnlyIf')}</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">{KEYS.map((k) => crit('watchCriteria', k))}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{t('ftx.cooldown')}</span>
|
||||
<Input type="number" min={10} max={3600} className="w-24 h-7 text-xs"
|
||||
defaultValue={autoCall.cooldownSec}
|
||||
key={`cd-${autoCall.cooldownSec}`}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v >= 10 && v <= 3600) set({ cooldownSec: v });
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">s</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] rounded border border-warning-border bg-warning-muted text-warning-muted-foreground px-2 py-1.5 leading-relaxed">
|
||||
{t('ftx.warn')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterPanel() {
|
||||
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
return (
|
||||
@@ -4714,6 +4829,36 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* What counts as "I already have this square". There is no single
|
||||
right answer — a VUCC chaser counts a square per band, someone
|
||||
filling a wall map counts it once — so it is a choice, and the
|
||||
same six GridTracker offers, because a square wanted in one and
|
||||
not the other is a bug report every time. */}
|
||||
<div className="pl-6 space-y-1.5 pt-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
|
||||
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(gridScope.scopes ?? []).map((s: any) => (
|
||||
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.hunt')}</span>
|
||||
<Select value={gridScope.hunt} onValueChange={(v) => saveGridScope({ ...gridScope, hunt: v })}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
|
||||
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('gsc.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
||||
@@ -4754,6 +4899,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* How near a RECEIVER has to be. This is the whole premise of
|
||||
the watch — a report is evidence about YOUR path only if it
|
||||
was collected near you — and the right distance differs by
|
||||
band, so it is the operator's call. Committed on blur/Enter,
|
||||
not per keystroke: each save restarts the feed. */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
|
||||
<Input
|
||||
type="number" min={25} max={1000} step={25}
|
||||
className="w-24 h-7 text-xs"
|
||||
defaultValue={bandOpen.near_km ?? 300}
|
||||
key={`nk-${bandOpen.near_km ?? 300}`}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v });
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">km</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('bo.nearKmHint')}</p>
|
||||
|
||||
{/* A live count, because a feed that is connected but silent looks
|
||||
exactly like one that is broken until a number moves. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -6670,6 +6837,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
'lists-bands': BandsPanel,
|
||||
'lists-modes': ModesPanel,
|
||||
cluster: ClusterPanel,
|
||||
ftx: FtxPanel,
|
||||
udp: UDPIntegrationsPanelWrapper,
|
||||
// Module-scope components, wrapped so their props can be passed. The nested
|
||||
// panels below go through PanelHost instead — which is what now lets either
|
||||
|
||||
@@ -24,7 +24,7 @@ type UDPConfig = {
|
||||
direction: 'inbound' | 'outbound';
|
||||
name: string;
|
||||
port: number;
|
||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log' | 'custom';
|
||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log' | 'wsjt_relay' | 'custom';
|
||||
// Custom rows only.
|
||||
trigger?: string;
|
||||
template?: string;
|
||||
@@ -95,6 +95,19 @@ const SERVICE_TYPES: Array<{
|
||||
hint: 'udpp.svcWsjtLogHint',
|
||||
defaults: { port: 2250, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
// Feeds a SECOND application the stream this OpsLog receives. WSJT-X, JTDX
|
||||
// and MSHV each send to ONE address, so without a relay the choice is
|
||||
// between OpsLog and JTAlert/GridTracker rather than both.
|
||||
//
|
||||
// 2238 by default: beside the 2237 the sender uses, and free. Pointing it AT
|
||||
// 2237 would name an OpsLog listener, which the relay refuses.
|
||||
id: 'wsjt_relay',
|
||||
direction: 'outbound',
|
||||
label: 'udpp.svcWsjtRelayLabel',
|
||||
hint: 'udpp.svcWsjtRelayHint',
|
||||
defaults: { port: 2238, destination_ip: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
// The general case: the operator picks the moment and writes the message.
|
||||
// Mainly for antenna switches, which are driven by a URL and want the band
|
||||
|
||||
@@ -298,6 +298,20 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
||||
{t('wbg.pickerDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Whole-dialog controls — the award columns included, which the
|
||||
catalogue-only helpers do not cover. */}
|
||||
<div className="flex items-center gap-1 px-5 text-[11px] text-muted-foreground">
|
||||
<span>{t('wbg.allGroups')}</span>
|
||||
<button type="button" className="text-[11px] text-primary hover:underline px-1"
|
||||
onClick={() => { showAll(); (awardCols ?? []).forEach((a) => setColVisible(`award_${a.code}`, true)); }}>
|
||||
{t('wbg.all')}
|
||||
</button>
|
||||
<span className="opacity-40">·</span>
|
||||
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
|
||||
onClick={() => { hideAll(); (awardCols ?? []).forEach((a) => setColVisible(`award_${a.code}`, false)); }}>
|
||||
{t('wbg.none')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
||||
|
||||
Reference in New Issue
Block a user