From a83acb0f9a6c265f55e9f9ba57394441e7d9b7e7 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 1 Aug 2026 11:11:32 +0200 Subject: [PATCH] feat(main): draggable divider between the two panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Main tab was a fixed 50/50 grid, but a map and a cluster list want very different widths and which one deserves the room changes with what the operator is doing. The share is clamped to 15..85: a pane can be made small but never dragged out of existence, because recovering from that would mean grabbing a divider no longer on screen. Double-click restores even. The drag uses pointer capture on the divider — without it Leaflet swallows the moves as soon as the cursor crosses the map. Persisted through writeUiPref, so it travels with data/ like the other UI prefs. --- changelog.json | 6 +++-- frontend/src/App.tsx | 47 +++++++++++++++++++++++++++++++++++++- frontend/src/lib/i18n.tsx | 4 ++-- frontend/src/lib/uiPref.ts | 1 + 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/changelog.json b/changelog.json index 358dcc1..360055b 100644 --- a/changelog.json +++ b/changelog.json @@ -12,7 +12,8 @@ "Info (F2) panel: the fields are sized for what they hold — a wider county, narrower prefix and zones, and the address takes the space left by the bearing and distances.", "Clicking in the lower half no longer draws an orange rule under the tab strip.", "A green NEW badge appears on the County field in Info (F2) when the contacted US county has never been worked.", - "DARC DOK award updated to v2 in the catalogue: one reference per QSO." + "DARC DOK award updated to v2 in the catalogue: one reference per QSO.", + "Main tab: drag the divider between the two panes to resize them. The setting is remembered and travels with the data folder; double-click the divider to even them out." ], "fr": [ "Le CAT série ne met plus la radio en émission par le seul fait de se connecter : DTR et RTS sont abaissés sur Xiegu, Yaesu et Kenwood comme ils l'étaient déjà sur Icom — un Xiegu G90 derrière un DE-19 partait en émission et y restait.", @@ -24,7 +25,8 @@ "Panneau Info (F2) : les champs sont dimensionnés selon leur contenu — comté plus large, préfixe et zones plus étroits, et l’adresse récupère la place laissée par l’azimut et les distances.", "Un clic dans la moitié basse ne trace plus de trait orange sous la barre d’onglets.", "Un badge vert NOUV apparaît sur le champ Comté dans Info (F2) quand le comté américain contacté n’a jamais été travaillé.", - "Diplôme DARC DOK mis à jour en v2 dans le catalogue : une seule référence par QSO." + "Diplôme DARC DOK mis à jour en v2 dans le catalogue : une seule référence par QSO.", + "Onglet Principal : faites glisser le séparateur entre les deux volets pour les redimensionner. Le réglage est mémorisé et suit le dossier de données ; double-clic pour les égaliser." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f2b677a..b1ccdf3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -871,6 +871,36 @@ export default function App() { return ALWAYS_TABS.includes(saved) ? saved : 'recent'; }); useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]); + + // Main tab: how much width the LEFT pane gets, in percent. Clamped to 15..85 + // so a pane can be made small but never dragged out of existence — recovering + // from that means finding a divider that is no longer on screen. + const [mainSplit, setMainSplit] = useState(() => { + const n = parseFloat(localStorage.getItem('opslog.mainSplit') || ''); + return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50; + }); + useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]); + const mainSplitRef = useRef(null); + const startMainSplitDrag = (e: React.PointerEvent) => { + e.preventDefault(); + const host = mainSplitRef.current; + if (!host) return; + // Pointer capture on the divider, so dragging over a map keeps working — + // Leaflet would otherwise swallow the moves the moment the cursor entered it. + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + const onMove = (ev: PointerEvent) => { + const r = host.getBoundingClientRect(); + if (r.width <= 0) return; + const pct = ((ev.clientX - r.left) / r.width) * 100; + setMainSplit(Math.min(85, Math.max(15, pct))); + }; + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }; // QSL Manager is a closable tab opened on demand from Tools → QSL Manager. const [qslTabOpen, setQslTabOpen] = useState(false); const [qslDesignerOpen, setQslDesignerOpen] = useState(false); @@ -6018,8 +6048,23 @@ export default function App() { {/* Two configurable panes (per-profile, Settings → Main view). Each side shows one of: great-circle map, locator map, cluster or worked-before. */} -
+ {/* The divider is draggable: a map and a cluster list want very + different widths, and which one deserves the room changes with + what the operator is doing. The share is persisted (portable), + and a double-click puts it back to even. */} +
{renderMainPane(mainPaneLeft)}
+
setMainSplit(50)} + className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1" + > + +
{renderMainPane(mainPaneRight)}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index c9ed078..53f6557 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -51,7 +51,7 @@ const en: Dict = { 'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name', 'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.', // Tabs - 'tab.main': 'Main', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before', + 'tab.main': 'Main', 'main.splitTip': 'Drag to resize the panes — double-click to even them out', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before', 'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net', // Entry form 'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid', @@ -464,7 +464,7 @@ const fr: Dict = { 'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer', 'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom', 'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.", - 'tab.main': 'Principal', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté', + 'tab.main': 'Principal', 'main.splitTip': 'Faites glisser pour redimensionner les volets — double-clic pour les égaliser', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté', 'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET', 'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator', 'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire', diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts index cac63d6..6e83bd6 100644 --- a/frontend/src/lib/uiPref.ts +++ b/frontend/src/lib/uiPref.ts @@ -36,6 +36,7 @@ const PORTABLE_KEYS = [ 'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter', 'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked', 'opslog.activeTab', // last selected tab + 'opslog.mainSplit', // Main tab: width share of the left pane (percent) // NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here. // They are handled by lib/gridPrefs, which scopes the localStorage cache PER // PROFILE and mirrors to the DB (already per-profile) itself — mirroring them