feat(decodes): offer the panel as a Main-view pane

It joins the left/right dropdowns in Settings -> General alongside the maps,
the cluster and the rest, so decodes can sit beside the entry strip instead
of only behind a tab. Per-profile like the other pane choices.

The panel itself is now built in ONE place and rendered from both: two
copies of that call would be two sets of props to keep in step, and the
click handler in particular is not something to duplicate.

The status refresh follows. Its "is anything showing a spot status?" test
decides whether a logged QSO refreshes the NEW badges now or only marks
them dirty, and a panel that had become a pane would have gone on wearing
stale badges whenever it was shown that way rather than as a tab.
This commit is contained in:
2026-08-18 10:01:30 +02:00
parent 6da30f91c4
commit 0881c72c0f
3 changed files with 43 additions and 28 deletions
+40 -25
View File
@@ -1656,12 +1656,12 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol';
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol' | 'decodes';
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol';
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol' || v === 'decodes';
const [l, r] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''),
@@ -1786,6 +1786,7 @@ export default function App() {
const spotsDirtyRef = useRef(false);
useEffect(() => {
const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster'
|| mainPaneLeft === 'decodes' || mainPaneRight === 'decodes'
|| activeTab === 'cluster' || activeTab === 'bandmap' || activeTab === 'decodes' || showBandMap;
if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) {
spotsDirtyRef.current = false;
@@ -5446,6 +5447,34 @@ export default function App() {
</button>
);
// The FT decodes panel, built in ONE place: it is offered both as a tab and as
// a Main-view pane, and two copies of this call would be two sets of props to
// keep in step.
const renderDecodesPanel = () => (
<DecodesPanel
decodes={decodes}
txMsgs={txMsgs}
txState={txState}
spotStatus={spotStatus as any}
myCall={station.callsign}
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
// a Reply, which is the same thing as double-clicking the line in their
// own window.
//
// Deliberately NOT a rig tune, unlike a cluster spot. On FT8 the whole
// band is inside one passband, so moving the dial changes nothing about
// who gets answered — and it would only fight the digital application
// for the VFO. The entry is still filled, so the QSO can be logged here.
onCall={(d) => {
onCallsignInput(d.call, { force: true });
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
d.audio_hz ?? 0, d.mode ?? '', d.msg ?? '', !!d.low_conf,
).catch((e: any) => setError(String(e?.message ?? e)));
}}
/>
);
// Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from
// the same four choices, configured per-profile in Settings → Main view.
const renderMainPane = (kind: MainPaneKind) => {
@@ -5464,6 +5493,14 @@ export default function App() {
);
case 'map2':
return <LocatorMap toGrid={grid} toLabel={callsign} />;
case 'decodes':
// Same panel as the tab, in a pane. It brings its own filter bar and
// column header, so it needs no frame of its own here.
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
{renderDecodesPanel()}
</div>
);
case 'cluster':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
@@ -7205,29 +7242,7 @@ export default function App() {
{decodesTabOpen && (
<TabsContent value="decodes" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<DecodesPanel
decodes={decodes}
txMsgs={txMsgs}
txState={txState}
spotStatus={spotStatus as any}
myCall={station.callsign}
// A click ANSWERS the station: it hands the decode back to
// WSJT-X/MSHV as a Reply, which is the same thing as
// double-clicking the line in their own window.
//
// Deliberately NOT a rig tune, unlike a cluster spot. On FT8
// the whole band is inside one passband, so moving the dial
// changes nothing about who gets answered — and it would only
// fight the digital application for the VFO. The entry is
// still filled, so the QSO can be logged here.
onCall={(d) => {
onCallsignInput(d.call, { force: true });
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
d.audio_hz ?? 0, d.mode ?? '', d.msg ?? '', !!d.low_conf,
).catch((e: any) => setError(String(e?.message ?? e)));
}}
/>
{renderDecodesPanel()}
</TabsContent>
)}
+1 -1
View File
@@ -1029,7 +1029,7 @@ function RelayAutoPanel() {
// panes show, independently: the great-circle map, the locator street map, the
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes'];
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
const { t } = useI18n();
const [left, setLeft] = useState('map1');
+2 -2
View File
@@ -105,7 +105,7 @@ const en: Dict = {
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', 'settings.pane.decodes': 'FT decodes',
'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode; click again to switch sideband (U/L)', 'yaesu.splitUpHint': 'Transmit this far above the receive frequency, and turn split on', 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': 'Break-in: the rig switches to receive between characters', 'yaesu.zinHint': 'Zero-in: retune so the station you hear lands on your CW pitch',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
'theme.light-sage': 'Sage light', 'theme.light-nordic': 'Nordic light', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
@@ -579,7 +579,7 @@ const fr: Dict = {
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés',
'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net',
'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net', 'settings.pane.decodes': 'Decodes FT',
'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'yaesu.sidebandHint': 'Cliquer pour choisir ce mode ; recliquer pour changer de bande latérale (U/L)', 'yaesu.splitUpHint': "Émettre à cette distance au-dessus de la fréquence de réception, et activer le split", 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': "Break-in : la radio repasse en réception entre les caractères", 'yaesu.zinHint': "Zéro-in : réaccorde pour que la station entendue tombe sur votre note CW",
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
'theme.light-sage': 'Clair sauge', 'theme.light-nordic': 'Clair nordique', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',