chore: release v0.27.12
This commit is contained in:
@@ -19,6 +19,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { chaseAllows } from '@/lib/spotDisplay';
|
||||
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { decoderName } from '@/lib/decoderName';
|
||||
|
||||
export type Decode = {
|
||||
call: string;
|
||||
@@ -99,6 +100,9 @@ interface Props {
|
||||
// the decoder announces — see the drift warning.
|
||||
rigBand?: string;
|
||||
onCall: (d: Decode) => void;
|
||||
// A single click: take the station without transmitting — fill the entry, and
|
||||
// point the panels at it. Absent, a click falls back to onCall.
|
||||
onSelect?: (d: Decode) => void;
|
||||
myCall?: string;
|
||||
// Drop every decode and transmit message held for this panel. The list is a
|
||||
// live view, not data — clearing it costs nothing but the seconds until the
|
||||
@@ -115,6 +119,12 @@ interface Props {
|
||||
// machine off is not something to go hunting through a settings tree for.
|
||||
autoCallOn?: boolean;
|
||||
onToggleAutoCall?: () => void;
|
||||
// The engine's own account of what it is doing, straight from the backend.
|
||||
autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
|
||||
// The chase list, here as well as in Preferences: naming the station you are
|
||||
// waiting for is done WHILE watching the band, not in a settings tree.
|
||||
autoCallOnly?: string;
|
||||
onSetAutoCallOnly?: (list: string) => void;
|
||||
}
|
||||
|
||||
// The "new" categories, as toggle badges — the same idea and the same colours as
|
||||
@@ -542,12 +552,21 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Column widths, dragged in the header and shared by every row. Persisted
|
||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||
// like every other portable preference.
|
||||
const [colw, setColw] = useState<ColWidths>(loadWidths);
|
||||
// The chase list as TYPED. Re-seeded whenever the stored value changes —
|
||||
// from Preferences, or from another window — but never while the box has the
|
||||
// focus, or a status arriving mid-word would rewrite what is being typed.
|
||||
const [onlyText, setOnlyText] = useState(autoCallOnly ?? '');
|
||||
useEffect(() => {
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
|
||||
setOnlyText(autoCallOnly ?? '');
|
||||
}, [autoCallOnly, t]);
|
||||
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
|
||||
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
|
||||
const setColWidth = (key: ColKey, px: number) => {
|
||||
@@ -710,7 +729,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
}
|
||||
return instances.map((inst) => ({
|
||||
key: inst,
|
||||
label: inst,
|
||||
// What the program is called, not the id it announces — see decoderName.
|
||||
label: decoderName(inst),
|
||||
tx: txStates?.[inst],
|
||||
periods: buildPeriods(
|
||||
filtered.filter((d) => (d.instance ?? '') === inst),
|
||||
@@ -757,7 +777,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
{t('dec.bandDrift', {
|
||||
app: driftInstance || t('dec.bandDriftApp'),
|
||||
app: decoderName(driftInstance) || t('dec.bandDriftApp'),
|
||||
dec: decoderBand.toUpperCase(),
|
||||
rig: (rigBand ?? '').toUpperCase(),
|
||||
})}
|
||||
@@ -889,6 +909,54 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
filter. Halt goes LAST — it is the one that must be findable without
|
||||
reading, and the end of the row is the one position that never moves
|
||||
as filters come and go. */}
|
||||
{/* Auto-call. Deliberately next to Halt: the two belong together, and
|
||||
what it is doing right now — which station, how many calls of how
|
||||
many — is on the button itself, because a thing that keys the
|
||||
transmitter must never be a switch with no readout. */}
|
||||
{onToggleAutoCall && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAutoCall}
|
||||
title={autoCall?.stopped ? t('dec.autoStoppedTip') : t('dec.autoCallTip')}
|
||||
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border font-medium',
|
||||
!autoCallOn
|
||||
? 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
: autoCall?.stopped
|
||||
? 'border-warning bg-warning text-warning-foreground'
|
||||
: 'border-success bg-success text-success-foreground')}
|
||||
>
|
||||
<Bot className="size-3.5" />
|
||||
{t('dec.autoCall')}
|
||||
{autoCallOn && autoCall?.target && (
|
||||
<span className="font-mono text-xs">
|
||||
{autoCall.target} {autoCall.calls}/{autoCall.max}
|
||||
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* The chase list. Raw text while typing, committed on blur or Enter:
|
||||
the stored value is upper-cased and trimmed, and binding the box to
|
||||
that makes the space bar look dead — in a field whose whole purpose
|
||||
is a list separated by spaces. */}
|
||||
{onSetAutoCallOnly && (
|
||||
<input
|
||||
type="text"
|
||||
value={onlyText}
|
||||
onChange={(e) => setOnlyText(e.target.value)}
|
||||
onBlur={() => { if (onlyText.toUpperCase() !== (autoCallOnly ?? '')) onSetAutoCallOnly(onlyText); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
if (e.key === 'Escape') setOnlyText(autoCallOnly ?? '');
|
||||
}}
|
||||
placeholder={t('dec.chasePh')}
|
||||
title={t('dec.chaseTip')}
|
||||
className={cn('h-8 w-44 rounded-lg border px-2 text-sm font-mono uppercase bg-background',
|
||||
(autoCallOnly ?? '').trim()
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-border text-muted-foreground')}
|
||||
/>
|
||||
)}
|
||||
{onHalt && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -955,7 +1023,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
<span className="flex-1" />
|
||||
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
|
||||
{txState.instance && instances.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{decoderName(txState.instance)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1095,7 +1163,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
<button
|
||||
key={`${d.call}-${d.freq_hz}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onCall(d)}
|
||||
// ONE click selects, TWO transmit — the cluster's rule, and
|
||||
// the only safe one here: a single click used to hand the
|
||||
// decode straight to WSJT-X as a Reply, so brushing a row
|
||||
// while reading the band started calling a station.
|
||||
onClick={() => (onSelect ?? onCall)(d)}
|
||||
onDoubleClick={() => onCall(d)}
|
||||
title={t('dec.callTitle', { call: d.call })}
|
||||
style={{ gridTemplateColumns: template, width: tableW }}
|
||||
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead';
|
||||
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -124,8 +124,12 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
|
||||
const age = now - Date.parse(d.at);
|
||||
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||
const colour = bandColour(d.band);
|
||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48);
|
||||
L.polyline(pts as L.LatLngExpression[], {
|
||||
// Cut at the antimeridian: this map shows ONE world, so a path running
|
||||
// past ±180 has to leave one edge and come back at the other. Without it
|
||||
// every arc out of VK or ZL was drawn into the blank space off the side
|
||||
// of the map, its far end sitting alone on the opposite coast.
|
||||
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
||||
L.polyline(pts as L.LatLngExpression[][], {
|
||||
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||
}).addTo(layer);
|
||||
L.circleMarker([to.lat, to.lon], {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -68,8 +68,9 @@ const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
||||
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||||
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||
|
||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||
// the plain SetFrequency command — no band-stacking codes needed.
|
||||
// These frequencies are the FALLBACK: with the band stacking registers switched
|
||||
// on the radio is asked where the operator last was instead, and one of these is
|
||||
// only sent for a band or a model whose register cannot be read.
|
||||
//
|
||||
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||||
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||||
@@ -392,6 +393,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
const [st, setSt] = useState<IcomState>(ZERO);
|
||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||
const [tuning, setTuning] = useState(false);
|
||||
// Band buttons: recall the radio's own band stacking register instead of
|
||||
// sending a frequency picked here. Remembered per operator, not per session —
|
||||
// it is a preference about how a button behaves, and having to set it again
|
||||
// at every launch would make it not worth having.
|
||||
const [bandStack, setBandStack] = useState(() => localStorage.getItem('opslog.icomBandStack') === '1');
|
||||
const toggleBandStack = () => setBandStack((v) => {
|
||||
const n = !v;
|
||||
try { localStorage.setItem('opslog.icomBandStack', n ? '1' : '0'); } catch { /* private mode */ }
|
||||
return n;
|
||||
});
|
||||
// Which register each band was last recalled from, so pressing the same band
|
||||
// again walks 1 → 2 → 3 → 1, exactly as the radio's own band key does.
|
||||
const bandRegRef = useRef<Record<string, number>>({});
|
||||
const txRef = useRef(false);
|
||||
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||
|
||||
@@ -400,6 +414,18 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||||
};
|
||||
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||||
|
||||
// A band button. With the stacking registers on, ask the radio where the
|
||||
// operator last was on that band; pressing the band it is already on steps to
|
||||
// the next register, and the fixed frequency below is the fallback for a band
|
||||
// or a model whose register the backend will not read — never a dead button.
|
||||
const bandClick = (b: Band, here: boolean) => {
|
||||
if (!bandStack) { SetCATFrequency(b.hz).catch(() => {}); return; }
|
||||
const reg = here ? (bandRegRef.current[b.l] ?? 1) % 3 + 1 : 1;
|
||||
IcomRecallBand(b.l, reg)
|
||||
.then(() => { bandRegRef.current[b.l] = reg; load(); })
|
||||
.catch(() => SetCATFrequency(b.hz).catch(() => {}));
|
||||
};
|
||||
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||
const refresh = async () => {
|
||||
@@ -592,11 +618,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* Band buttons + antenna selection. */}
|
||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||
<label className="flex items-center gap-1.5 mb-1.5 text-[11px] text-muted-foreground cursor-pointer select-none"
|
||||
title={t('icmp.bandStackHint')}>
|
||||
<input type="checkbox" checked={bandStack} onChange={toggleBandStack} className="accent-primary" />
|
||||
{t('icmp.bandStack')}
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{bandsFor(st.model).map((b) => {
|
||||
const here = bandOfHz(mainHz) === b.l;
|
||||
return (
|
||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||
<button key={b.l} type="button" onClick={() => bandClick(b, here)}
|
||||
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||
here
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// PSKReporterPanel — can the station I am about to call actually hear me?
|
||||
//
|
||||
// The decodes list to the left says who is transmitting. It cannot say anything
|
||||
// about the other direction, and on FT8 that is the whole question: the DX's
|
||||
// pileup is invisible from here, and a station whose region is not open to
|
||||
// yours will not hear you however many times you call.
|
||||
//
|
||||
// Every number here comes from PSK Reporter — reports uploaded by ordinary
|
||||
// stations saying "I decoded X" — over a five-minute window. Nothing is
|
||||
// inferred and nothing is remembered: when the window empties the panel says it
|
||||
// does not know, which is the honest answer and the reason each block also says
|
||||
// what it is measuring.
|
||||
//
|
||||
// The backend (internal/pskrtgt) does the analysis; this draws it and polls.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { GetPSKAnalysis, SetPSKTarget } from '../../wailsjs/go/main/App';
|
||||
|
||||
export type PSKEntry = {
|
||||
call: string;
|
||||
grid?: string;
|
||||
snr: number;
|
||||
offset_hz: number;
|
||||
age_sec: number;
|
||||
};
|
||||
|
||||
export type PSKAnalysis = {
|
||||
target?: string;
|
||||
mode?: string;
|
||||
enabled: boolean;
|
||||
online: boolean;
|
||||
spots: number;
|
||||
he_me: boolean;
|
||||
he_me_seconds: number;
|
||||
he_me_snr: number;
|
||||
he_me_offset_hz: number;
|
||||
target_uploads: boolean;
|
||||
target_grid?: string;
|
||||
near_him_count: number;
|
||||
near_him_top?: PSKEntry[];
|
||||
from_my_area_count: number;
|
||||
from_my_area_top?: PSKEntry[];
|
||||
path_open: boolean;
|
||||
heard_by_count: number;
|
||||
heard_near_me: number;
|
||||
heard_near_me_top?: PSKEntry[];
|
||||
decoded_by_count: number;
|
||||
decoded_by_top?: PSKEntry[];
|
||||
decoded_by_calls?: string[];
|
||||
pileup_count: number;
|
||||
dial_hz: number;
|
||||
ceiling_hz: number;
|
||||
decodes_in_window: number;
|
||||
bins?: { offset_hz: number; count: number; avg_snr: number }[];
|
||||
suggested_offset: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
// The station to analyse and the mode it was heard on. Set by clicking a
|
||||
// decode, or by whoever the digital application says it is calling.
|
||||
target: string;
|
||||
mode?: string;
|
||||
// The operator's own dial, which is what turns a report's frequency into an
|
||||
// audio offset. Without it the passband block has nothing to say.
|
||||
dialHz?: number;
|
||||
// The local decodes, for "callers you hear": stations WE are decoding that
|
||||
// are calling the same DX. That is the competition measured at this end,
|
||||
// which no amount of PSK Reporter data can show.
|
||||
callers: number;
|
||||
callerCalls?: string[];
|
||||
onCollapse: () => void;
|
||||
}
|
||||
|
||||
// The passband strip: 60 Hz bins, drawn from 200 Hz to 4000 Hz. The bin edges
|
||||
// have to match the backend's alignment exactly — it keys them on multiples of
|
||||
// 60 from zero, so a strip starting at 200 would ask for edges that never
|
||||
// exist and draw an empty histogram over a busy passband.
|
||||
const LO = 200, HI = 4000, STEP = 60;
|
||||
const FIRST_EDGE = Math.floor(LO / STEP) * STEP;
|
||||
const COLS = Math.floor((HI - FIRST_EDGE) / STEP);
|
||||
|
||||
export function PSKReporterPanel({ target, mode, dialHz, callers, callerCalls, onCollapse }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [a, setA] = useState<PSKAnalysis | null>(null);
|
||||
|
||||
// One second, matching the panel's own claim about how fresh it is. The call
|
||||
// is a snapshot of an in-memory window — no query and no network of its own.
|
||||
useEffect(() => {
|
||||
let stop = false;
|
||||
const tick = () => {
|
||||
GetPSKAnalysis().then((r) => { if (!stop) setA(r as unknown as PSKAnalysis); }).catch(() => {});
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => { stop = true; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// The target is re-asserted rather than sent once. The backend treats an
|
||||
// unchanged callsign as a no-op, and this way a broker that dropped while
|
||||
// nobody was looking comes back on its own instead of leaving a panel that
|
||||
// is permanently, silently empty.
|
||||
useEffect(() => {
|
||||
SetPSKTarget(target ?? '', mode ?? '', dialHz ?? 0).catch(() => {});
|
||||
if (!target) return;
|
||||
const id = window.setInterval(() => {
|
||||
SetPSKTarget(target, mode ?? '', dialHz ?? 0).catch(() => {});
|
||||
}, 15000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [target, mode, dialHz]);
|
||||
|
||||
const bins = a?.bins ?? [];
|
||||
const maxCount = useMemo(() => bins.reduce((m, b) => Math.max(m, b.count || 0), 0) || 1, [bins]);
|
||||
const byOffset = useMemo(() => {
|
||||
const m = new Map<number, { count: number; avg_snr: number }>();
|
||||
for (const b of bins) m.set(b.offset_hz, b);
|
||||
return m;
|
||||
}, [bins]);
|
||||
const columns = useMemo(() => {
|
||||
const out: { edge: number; count: number; snr: number | null }[] = [];
|
||||
for (let i = 0; i < COLS; i++) {
|
||||
const edge = FIRST_EDGE + i * STEP;
|
||||
const b = byOffset.get(edge);
|
||||
out.push({ edge, count: b?.count ?? 0, snr: b?.avg_snr ?? null });
|
||||
}
|
||||
return out;
|
||||
}, [byOffset]);
|
||||
|
||||
// Confirmed pileup: a station we hear calling this DX that the DX has also
|
||||
// decoded. Two independent pieces of evidence, so it is the one number here
|
||||
// that is not a proxy for anything.
|
||||
const confirmed = useMemo(() => {
|
||||
const heard = new Set((a?.decoded_by_calls ?? []).map((c) => c.toUpperCase()));
|
||||
return (callerCalls ?? []).filter((c) => heard.has(c.toUpperCase())).length;
|
||||
}, [a?.decoded_by_calls, callerCalls]);
|
||||
|
||||
const snr = (v: number) => `${v > 0 ? '+' : ''}${v}`;
|
||||
|
||||
const Tile = ({ label, value, foot, tone, title }: {
|
||||
label: string; value: number | string; foot: string; tone: string; title?: string;
|
||||
}) => (
|
||||
<div className="px-2 py-1.5 rounded-md bg-muted/40 border border-border/60" title={title}>
|
||||
<div className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className={cn('text-lg font-bold leading-tight', tone)}>{value}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{foot}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-[340px] shrink-0 flex flex-col min-h-0 border-l border-border bg-card">
|
||||
{/* Header: what is being watched, and whether the feed is actually up. A
|
||||
panel full of zeros means one of two very different things. */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border shrink-0">
|
||||
<Activity className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('psk.title')}
|
||||
</span>
|
||||
{target && <span className="text-xs font-mono text-foreground truncate">→ {target}</span>}
|
||||
<span className="ml-auto flex items-center gap-2 text-[10px] shrink-0">
|
||||
{a?.target && a.spots > 0 && (
|
||||
<span className="text-muted-foreground" title={t('psk.spotsTip')}>{t('psk.spots', { n: a.spots })}</span>
|
||||
)}
|
||||
{a?.enabled === false
|
||||
? <span className="text-muted-foreground">{t('psk.off')}</span>
|
||||
: a?.online
|
||||
? <span className="text-success">● {t('psk.online')}</span>
|
||||
: <span className="text-muted-foreground">○ {t('psk.offline')}</span>}
|
||||
<button type="button" onClick={onCollapse} title={t('psk.hide')}
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground">
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2.5 py-2 space-y-3">
|
||||
{a?.enabled === false ? (
|
||||
<p className="text-xs text-muted-foreground italic">{t('psk.enableHint')}</p>
|
||||
) : !target ? (
|
||||
<p className="text-xs text-muted-foreground italic">{t('psk.pickHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* ── The answer ──────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('shrink-0 size-8 rounded-full border flex items-center justify-center text-base',
|
||||
a?.he_me ? 'bg-success/20 border-success/50 text-success'
|
||||
: a?.path_open ? 'bg-warning/20 border-warning/50 text-warning'
|
||||
: 'bg-muted border-border text-muted-foreground')}>
|
||||
{a?.he_me ? '✓' : a?.path_open ? '≈' : '·'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{a?.he_me ? (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-success">{t('psk.heardYou', { s: a.he_me_seconds })}</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground">
|
||||
{snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''}
|
||||
</div>
|
||||
</>
|
||||
) : a?.path_open ? (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-warning">{t('psk.pathOpen')}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t('psk.pathOpenSub', { n: a.from_my_area_count })}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('psk.notYet')}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t('psk.notYetSub')}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Your signal reported next to him. Only when he has not decoded
|
||||
you himself — that is strictly stronger evidence, and two
|
||||
banners saying the same thing differently is noise. */}
|
||||
{!a?.he_me && (a?.near_him_count ?? 0) > 0 && a?.target_grid && (
|
||||
<div className="px-2 py-1.5 rounded-md bg-info/10 border border-info/30">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold text-info">
|
||||
✓ {t('psk.nearHim', { g: a.target_grid })}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t('psk.nRx', { n: a.near_him_count })}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2 font-mono text-[11px]">
|
||||
{(a.near_him_top ?? []).map((h) => (
|
||||
<span key={h.call} className="text-info" title={`${h.call} ${h.grid ?? ''} · ${h.age_sec}s`}>
|
||||
{h.call} <span className="text-muted-foreground">{snr(h.snr)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The four numbers ────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Tile label={t('psk.tFromArea')} value={a?.from_my_area_count ?? 0} foot={t('psk.tFromAreaFoot')}
|
||||
tone="text-success"
|
||||
title={(a?.from_my_area_top ?? []).map((h) => `${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} />
|
||||
<Tile label={t('psk.tPileup')} value={a?.pileup_count ?? 0} foot={t('psk.tPileupFoot')}
|
||||
tone="text-primary" title={t('psk.tPileupTip')} />
|
||||
<Tile label={t('psk.tHeardNear')} value={a?.heard_near_me ?? 0} foot={t('psk.tHeardNearFoot')}
|
||||
tone="text-info"
|
||||
title={t('psk.tHeardNearTip', { n: a?.heard_by_count ?? 0 })} />
|
||||
<Tile label={t('psk.tCallers')} value={confirmed > 0 ? `${callers} (${confirmed})` : callers}
|
||||
foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')}
|
||||
tone="text-warning" title={t('psk.tCallersTip')} />
|
||||
</div>
|
||||
|
||||
{/* The one thing that turns an empty panel from a verdict into a
|
||||
missing measurement. */}
|
||||
{a?.target_uploads ? (
|
||||
<div className="text-[11px] text-success">✓ {t('psk.uploads')}</div>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 rounded-md bg-warning/10 border border-warning/30 text-[11px] text-warning">
|
||||
⚠ {t('psk.noUploads', { c: target })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Who near you he is hearing ──────────────────────────── */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">{t('psk.fromAreaList')}</div>
|
||||
{(a?.from_my_area_top ?? []).length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
||||
{(a?.from_my_area_top ?? []).slice(0, 4).map((h) => (
|
||||
<div key={h.call} className="flex items-baseline gap-2 truncate"
|
||||
title={t('psk.rowTip', { c: h.call, g: h.grid ?? '?', s: h.age_sec, d: snr(h.snr) })}>
|
||||
<span className="font-semibold text-foreground w-20 truncate">{h.call}</span>
|
||||
<span className="text-muted-foreground w-12">({(h.grid ?? '?').slice(0, 4)})</span>
|
||||
<span className="text-success w-14">{snr(h.snr)} dB</span>
|
||||
{h.offset_hz > 0 && h.offset_hz < 10000 && (
|
||||
<span className="ml-auto text-muted-foreground whitespace-nowrap">@ +{h.offset_hz} Hz</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-muted-foreground italic">{t('psk.fromAreaEmpty')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── His passband ────────────────────────────────────────── */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('psk.passband')}</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
{(a?.ceiling_hz ?? 0) > 0
|
||||
? t('psk.ceiling', { hz: a!.ceiling_hz, n: a!.decodes_in_window })
|
||||
: (a?.decodes_in_window ?? 0) > 0 ? t('psk.noDial') : t('psk.noDecodes')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex items-end gap-px h-10 rounded bg-muted/40 px-1 py-0.5 overflow-hidden">
|
||||
{columns.map((c) => {
|
||||
const ratio = c.count / maxCount;
|
||||
return (
|
||||
<div key={c.edge}
|
||||
className={cn('flex-1 min-w-0 rounded-sm',
|
||||
c.count === 0 ? 'bg-border'
|
||||
: ratio > 0.66 ? 'bg-primary'
|
||||
: ratio > 0.33 ? 'bg-primary/70' : 'bg-primary/40')}
|
||||
style={{ height: `${Math.max(2, Math.round(ratio * 36))}px` }}
|
||||
title={`${c.edge}-${c.edge + STEP} Hz · ${c.count}${c.snr !== null ? ` @ ${c.snr.toFixed(0)} dB` : ''}`} />
|
||||
);
|
||||
})}
|
||||
{(a?.suggested_offset ?? 0) > 0 && (
|
||||
<div className="absolute top-0 bottom-0 w-0.5 bg-success pointer-events-none"
|
||||
style={{ left: `${((a!.suggested_offset - LO) / (HI - LO)) * 100}%`, boxShadow: '0 0 4px currentColor' }}
|
||||
title={t('psk.tryOffset', { hz: a!.suggested_offset })} />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-3 mt-0.5 text-[9px] font-mono text-muted-foreground">
|
||||
{[1000, 2000, 3000, 4000].map((hz) => (
|
||||
<span key={hz} className="absolute whitespace-nowrap"
|
||||
style={{ left: `${((hz - LO) / (HI - LO)) * 100}%`, transform: `translateX(${hz === HI ? '-100%' : '-50%'})` }}>
|
||||
{hz}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{(a?.suggested_offset ?? 0) > 0 && (
|
||||
<div className="text-center text-[11px] font-mono text-success mt-0.5">
|
||||
🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -484,6 +484,8 @@ const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: str
|
||||
'dark-indigo': { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' },
|
||||
'dark-teal': { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' },
|
||||
'dark-plum': { bg: '#180f1e', card: '#251830', accent: '#f472b6' },
|
||||
'dxhunter': { bg: '#0f172a', card: '#1e293b', accent: '#3b82f6' },
|
||||
'dxhunter-orange': { bg: '#0f172a', card: '#1e293b', accent: '#f97316' },
|
||||
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
||||
};
|
||||
|
||||
@@ -2086,6 +2088,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
const [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0');
|
||||
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
|
||||
const [chaseNew, setChaseNew] = useState(false);
|
||||
const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' });
|
||||
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2 });
|
||||
// The named contest callsigns. Raw text in state, written on blur: it is a
|
||||
// multi-line list, and normalising it on every keystroke would fight the
|
||||
// Return key — the one key this box is built around.
|
||||
const [contestCalls, setContestCalls] = useState('');
|
||||
const [contestPattern, setContestPattern] = useState('');
|
||||
const saveAC = async (next: any) => {
|
||||
setAc(next);
|
||||
try { await SaveAutoCallSettings(next); } catch { /* the toolbar shows what the engine is doing */ }
|
||||
};
|
||||
const savePSKTgt = async (next: any) => {
|
||||
setPskTgt(next);
|
||||
try { await SavePSKTargetSettings(next); } catch { /* the panel itself reports what the feed is doing */ }
|
||||
};
|
||||
const [spotTTL, setSpotTTL] = useState(0);
|
||||
const [spotTTLText, setSpotTTLText] = useState('0');
|
||||
const [spotMaxText, setSpotMaxText] = useState('1000');
|
||||
@@ -2113,6 +2130,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
writeUiPref('opslog.chaseGrids', g ? '1' : '0');
|
||||
} catch { /* defaults stand */ }
|
||||
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
|
||||
try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ }
|
||||
try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ }
|
||||
try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ }
|
||||
try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
|
||||
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
||||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||
try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ }
|
||||
@@ -5357,6 +5378,135 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
|
||||
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
|
||||
</label>
|
||||
{/* The same radius the band-opening watch uses — one feed, one
|
||||
circle — but reachable from here, because an operator who only
|
||||
wants the chase list would otherwise have to find it inside a
|
||||
watch they never switched on. It is the setting that decides
|
||||
whether this list has anything in it at all: where stations are
|
||||
far apart, 300 km can hold no receivers whatsoever. */}
|
||||
{chaseNew && (
|
||||
<div className="flex items-center gap-2 flex-wrap pl-6">
|
||||
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
|
||||
<Input
|
||||
type="number" min={25} max={3000} step={25}
|
||||
className="w-24 h-7 text-xs"
|
||||
defaultValue={bandOpen.near_km ?? 300}
|
||||
key={`cnk-${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>
|
||||
<span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PSK Reporter analysis of the station being called. Same service as
|
||||
the two options above, opposite question: those ask what is being
|
||||
heard around here, this asks whether ONE station can hear you. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={pskTgt.enabled} className="mt-0.5"
|
||||
onCheckedChange={(c) => savePSKTgt({ ...pskTgt, enabled: !!c })} />
|
||||
<span>{t('psk.setEnable')} <span className="text-xs text-muted-foreground">{t('psk.setEnableHint')}</span></span>
|
||||
</label>
|
||||
{pskTgt.enabled && (
|
||||
<div className="pl-6 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{t('psk.setScope')}</span>
|
||||
<Select value={pskTgt.scope} onValueChange={(v) => savePSKTgt({ ...pskTgt, scope: v })}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="target">{t('psk.setScopeTarget')}</SelectItem>
|
||||
<SelectItem value="band">{t('psk.setScopeBand')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('psk.setScopeHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contest — how a special-event fleet finds its way onto the watch
|
||||
list on its own. Two halves, because a fleet has two kinds of
|
||||
member. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div className="text-sm font-medium">{t('wlc.title')}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{t('wlc.pattern')}</span>
|
||||
<Input className="h-7 w-32 text-xs font-mono uppercase"
|
||||
defaultValue={contestPattern} key={`wlcp-${contestPattern}`}
|
||||
placeholder={t('wlc.patternPh')}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.toUpperCase().trim();
|
||||
if (v !== contestPattern) { setContestPattern(v); void SetWatchlistContestPattern(v); }
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
|
||||
<span className="text-xs text-muted-foreground">{t('wlc.patternHint')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t('wlc.calls')}</span>
|
||||
<textarea
|
||||
className="w-full h-24 rounded-md border border-border bg-background p-2 text-xs font-mono uppercase"
|
||||
value={contestCalls}
|
||||
placeholder={t('wlc.callsPh')}
|
||||
onChange={(e) => setContestCalls(e.target.value)}
|
||||
onBlur={(e) => void SetWatchlistContestCalls(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('wlc.callsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-call. Last in this section, and behind a warning: it is the
|
||||
only setting in OpsLog that transmits without being asked to. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={ac.enabled} className="mt-0.5"
|
||||
onCheckedChange={(c) => saveAC({ ...ac, enabled: !!c })} />
|
||||
<span>{t('ac.enable')} <span className="text-xs text-muted-foreground">{t('ac.enableHint')}</span></span>
|
||||
</label>
|
||||
<p className="text-xs text-warning">{t('ac.warn')}</p>
|
||||
{ac.enabled && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('ac.ladder')}</p>
|
||||
{/* A LIST: several callsigns, spaces or commas. Committed on
|
||||
blur or Enter and kept as raw text while typing — binding the
|
||||
box to the parsed value is what makes the space key look dead,
|
||||
and space is the one key this field needs. */}
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.only')}</span>
|
||||
<Input className="h-7 w-72 text-xs font-mono uppercase"
|
||||
defaultValue={ac.only ?? ''} key={`aco-${ac.only ?? ''}`}
|
||||
placeholder={t('ac.onlyPh')}
|
||||
onBlur={(e) => saveAC({ ...ac, only: e.target.value.toUpperCase() })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
|
||||
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.onlyHint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{([
|
||||
['attempts', t('ac.attempts'), 1, 30],
|
||||
['watched_attempts', t('ac.watchedAttempts'), 1, 60],
|
||||
['misses', t('ac.misses'), 1, 10],
|
||||
['max_rounds', t('ac.rounds'), 1, 10],
|
||||
['rest_min', t('ac.rest'), 1, 60],
|
||||
] as [string, string, number, number][]).map(([k, label, min, max]) => (
|
||||
<span key={k} className="inline-flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<Input type="number" min={min} max={max} className="h-7 w-16 text-xs"
|
||||
defaultValue={(ac as any)[k]} key={`ac-${k}-${(ac as any)[k]}`}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v >= min && v <= max && v !== (ac as any)[k]) saveAC({ ...ac, [k]: v });
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -5601,7 +5751,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<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}
|
||||
type="number" min={25} max={3000} step={25}
|
||||
className="w-24 h-7 text-xs"
|
||||
defaultValue={bandOpen.near_km ?? 300}
|
||||
key={`nk-${bandOpen.near_km ?? 300}`}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||
import {
|
||||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||
GetWsjtHighlight, SetWsjtHighlight, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -160,9 +160,12 @@ type Props = { onError: (msg: string) => void };
|
||||
|
||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
const [highlightOn, setHighlightOn] = useState(false);
|
||||
const [hlWorked, setHlWorked] = useState(false);
|
||||
const [followMode, setFollowMode] = useState(true);
|
||||
useEffect(() => {
|
||||
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
|
||||
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
|
||||
}, []);
|
||||
const { t } = useI18n();
|
||||
@@ -246,6 +249,18 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
{/* Nested under the switch above: the same feature, and meaningless
|
||||
while that one is off. */}
|
||||
{highlightOn && (
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl pl-6">
|
||||
<Checkbox checked={hlWorked}
|
||||
onCheckedChange={(c) => { setHlWorked(!!c); void SetWsjtHighlightWorked(!!c); }} />
|
||||
<span>
|
||||
{t('udpp.hlWorked')}
|
||||
<span className="block text-[11px] text-muted-foreground">{t('udpp.hlWorkedHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||||
<Checkbox checked={followMode}
|
||||
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// The design is DXHunter's — the layout, the badges, the wording — repainted in
|
||||
// the app's theme tokens rather than its hard-coded slate/pink.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search } from 'lucide-react';
|
||||
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search, Check, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -235,8 +235,16 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
}
|
||||
};
|
||||
|
||||
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
|
||||
// 7.0560, 14.0745 rather than 14.074500. DXHunter's own rule, and the one an
|
||||
// operator reads a cluster line with.
|
||||
const fmtMHz = (hz: number) => {
|
||||
const [int, dec] = (hz / 1e6).toFixed(6).split('.');
|
||||
return int + '.' + dec.slice(0, 3) + dec.slice(3).replace(/0+$/, '');
|
||||
};
|
||||
|
||||
const chip = (color: string, text: string, extra?: string) => (
|
||||
<span className={cn('px-1.5 py-0.5 rounded text-[10px] font-bold border', extra)}
|
||||
<span className={cn('px-1.5 py-0.5 rounded text-[11px] font-semibold border', extra)}
|
||||
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
|
||||
{text}
|
||||
</span>
|
||||
@@ -332,14 +340,18 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
|
||||
return (
|
||||
<div key={e.callsign}
|
||||
className={cn('rounded-lg border bg-card/70 p-3 transition-colors hover:bg-accent/20',
|
||||
className={cn('rounded border bg-card/70 p-3 transition-colors hover:bg-accent/20',
|
||||
needed > 0 ? 'border-warning/40' : 'border-border/70',
|
||||
e.isContest && 'border-l-4 border-l-warning')}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-lg font-bold font-mono" style={{ color: '#f472b6' }}>{e.callsign}</span>
|
||||
{/* Proportional, not monospaced: DXHunter sets this one in the
|
||||
interface font and the difference is the first thing an
|
||||
operator notices with the two windows side by side. There is
|
||||
nothing to align here — it is a heading, not a column. */}
|
||||
<span className="text-lg font-bold" style={{ color: '#f472b6' }}>{e.callsign}</span>
|
||||
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
|
||||
{e.isContest && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border"
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border"
|
||||
title={t('wl.contestHint')}>
|
||||
<Trophy className="size-3" /> {t('wl.contest')}
|
||||
</span>
|
||||
@@ -349,16 +361,16 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
|
||||
{e.clubLogLiveStream && (
|
||||
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
|
||||
className="px-1.5 py-0.5 rounded text-[11px] font-semibold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
|
||||
)}
|
||||
{list.length > 0 && (needed > 0
|
||||
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
|
||||
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
|
||||
{e.lastSeenStr && e.lastSeenStr !== 'Never' && (
|
||||
<span className="text-[11px] text-muted-foreground">· {e.lastSeenStr}</span>
|
||||
<span className="text-[11px] text-muted-foreground">• {e.lastSeenStr}</span>
|
||||
)}
|
||||
{e.spotCount > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground/70">· {t('wl.totalSpots', { n: e.spotCount })}</span>
|
||||
<span className="text-[11px] text-muted-foreground/70">• {t('wl.totalSpots', { n: e.spotCount })}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button type="button" title={t('wl.toggleContest')}
|
||||
@@ -390,14 +402,20 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
onDoubleClick={() => onSpotClick?.(s)}
|
||||
title={t('wl.spotTip')}
|
||||
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
|
||||
!done && 'border-l-[3px] border-warning')}>
|
||||
{done && <span className='font-bold shrink-0 text-success'>✓</span>}
|
||||
!done && 'border-l-2 border-warning')}>
|
||||
{/* Worked or wanted, said with a symbol at the head of
|
||||
the line as well as with the stripe down its side —
|
||||
the same two marks DXHunter uses, and the one an eye
|
||||
finds first when a card holds ten rows. */}
|
||||
{done
|
||||
? <Check className="size-4 shrink-0 text-success" />
|
||||
: <AlertTriangle className="size-4 shrink-0 text-warning" />}
|
||||
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
|
||||
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||||
<span className="font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||||
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
|
||||
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
|
||||
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
|
||||
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{(s.freq_hz / 1e6).toFixed(4)}</span>
|
||||
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{fmtMHz(s.freq_hz)}</span>
|
||||
{badge && chip(badge.color, badge.label)}
|
||||
<div className="flex-1" />
|
||||
{done
|
||||
|
||||
Reference in New Issue
Block a user