From 0881c72c0f8534c2b75219afed61c5fa7fa8c192 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 18 Aug 2026 10:01:30 +0200 Subject: [PATCH] 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. --- frontend/src/App.tsx | 65 ++++++++++++++--------- frontend/src/components/SettingsModal.tsx | 2 +- frontend/src/lib/i18n.tsx | 4 +- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6a2f0c2..658980e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('map1'); const [mainPaneRight, setMainPaneRight] = useState('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() { ); + // 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 = () => ( + { + 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 ; + 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 ( +
+ {renderDecodesPanel()} +
+ ); case 'cluster': return (
@@ -7205,29 +7242,7 @@ export default function App() { {decodesTabOpen && ( - { - 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()} )} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 5944a06..c414b7c 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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'); diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 206b890..8211766 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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',