diff --git a/changelog.json b/changelog.json index 5ea3cdc..0731a44 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,14 @@ [ + { + "version": "0.27.10", + "date": "", + "en": [ + "Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag." + ], + "fr": [ + "Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous l’aviez laissé, et la vue principale suit pendant que vous glissez." + ] + }, { "version": "0.27.9", "date": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2d8bb5..eb9b61c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,7 @@ import { Combobox } from '@/components/ui/combobox'; import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs'; import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App'; import { formatDistance } from '@/lib/units'; +import { WIDGET_KEYS } from '@/components/AppearancePanel'; import { bandForMHz } from '@/lib/bandplan'; import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; @@ -761,6 +762,32 @@ export default function App() { // Several amps side by side make a wide widget, so an operator running two // picks the one he watches while transmitting. Declared here because the poll // below runs faster while the widget is open. + // Widget order (Settings → Appearance). The row is a flex container, so the + // ORDER property moves a widget without touching the tree: every condition, + // every ref and every hook stays where it was, and a widget that is switched + // off simply is not there to be ordered. + // + // QSO entry and the F1-F5 panel are not in this list on purpose — they are + // not in this row at all, and an operator cannot be allowed to push the thing + // they type into behind a rotator dial. + const [widgetOrder, setWidgetOrder] = useState(() => { + try { + const raw = localStorage.getItem('opslog.widgetOrder'); + const arr = raw ? JSON.parse(raw) : null; + if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr; + } catch { /* corrupt pref → the default order */ } + return [...WIDGET_KEYS]; + }); + useEffect(() => EventsOn('widgets:order', (keys: any) => { + if (Array.isArray(keys)) setWidgetOrder(keys.filter((k: any) => typeof k === 'string')); + }), []); + // A key the saved order has never heard of (a widget added since) goes to the + // end rather than to the front, where it would jump the queue on every + // upgrade. + const wOrder = (k: string) => { + const i = widgetOrder.indexOf(k); + return i < 0 ? WIDGET_KEYS.length + (WIDGET_KEYS as readonly string[]).indexOf(k) : i; + }; const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0'); const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all'); // Poll fast only while the amplifier widget is open: its meters must track TX @@ -7440,7 +7467,7 @@ export default function App() { {/* Multi-op "who's on air" widget: every operator on the shared logbook, their freq/mode (colour-coded) and OpsLog version. */} {showLiveStations && dbConn?.backend === 'mysql' && ( -
+
@@ -7484,7 +7511,7 @@ export default function App() { // relative + absolute inner: the chat takes the row height (set by the // entry strip) WITHOUT its message list growing the row, like the // Stats panel. The list scrolls inside this fixed height. -
+
setChatOpen(false)} /> @@ -7496,7 +7523,7 @@ export default function App() { controls column, so the widget is just the dial and needs only its width. */} {showRotor && (rotatorHeading.enabled || dxPath) && ( -
+
{ RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }} @@ -7516,7 +7543,7 @@ export default function App() {
)} {showMotorAnt && ubStatus.enabled && ( -
+
)} {showAntGenius && agEnabled && ( -
+
+ style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px`, order: wOrder('amp') }}> )} {showTuner && tgEnabled && ( -
+
)} {showScp && scpEnabled && ( -
+
)} {chaseNewOn && showChaseNew && ( -
+
{/* Same reflex as clicking a cluster spot: the callsign into the entry, and the rig onto the frequency it was decoded on. */} { @@ -7586,7 +7613,7 @@ export default function App() {
)} {dvkEnabled && ( -
+
)} {wkEnabled && ( -
+
+
+ ); +} + +// The row of widgets to the right of the entry, in the order they appear. +// +// Flexbox does the moving in the main view — this list only decides the order +// property each one gets. That is why a widget switched OFF still holds its +// place here: it comes back where the operator left it rather than at the end. +export const WIDGET_KEYS = [ + 'livestations', 'chat', 'rotor', 'motorant', 'antgenius', + 'amp', 'tuner', 'scp', 'chasenew', 'dvk', 'winkeyer', 'photo', +] as const; + +const WIDGET_LABELS: Record = { + livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor', + motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp', + tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', + dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo', +}; + +function readWidgetOrder(): string[] { + try { + const raw = localStorage.getItem('opslog.widgetOrder'); + const arr = raw ? JSON.parse(raw) : null; + if (Array.isArray(arr)) { + // A key from an older build that no longer exists is dropped; a widget + // added since joins the end. An old preference can never hide a new one. + const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k)); + return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))]; + } + } catch { /* corrupt pref → the default order */ } + return [...WIDGET_KEYS]; +} + +function WidgetOrderSection() { + const { t } = useI18n(); + const [order, setOrder] = useState(readWidgetOrder); + const dragKey = useRef(null); + const [dragging, setDragging] = useState(null); + + const commit = (keys: string[]) => { + setOrder(keys); + try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ } + // The main view listens: an order is meant to be watched as it is dragged, + // not discovered after closing Preferences. + EventsEmit('widgets:order', keys); + }; + const moveTo = (from: string, to: string) => { + if (from === to) return; + const next = order.filter((k) => k !== from); + const at = next.indexOf(to); + next.splice(at < 0 ? next.length : at, 0, from); + commit(next); + }; + + return ( +
+

{t('wo.title')}

+

{t('wo.hint')}

+
+ {/* The two that cannot move, shown so the order reads as the whole row + rather than as a list that mysteriously starts at the third item. */} + {['wo.entry', 'wo.details'].map((k) => ( +
+ + {t(k)} +
+ ))} + {order.map((k) => ( +
{ if (dragKey.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} + onDrop={(e) => { if (dragKey.current) { e.preventDefault(); moveTo(dragKey.current, k); } }} + className={cn('flex items-center gap-2 rounded-md border border-border bg-card px-2 py-1.5 text-sm', + dragging === k && 'opacity-50')}> + { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }} + onDragEnd={() => { dragKey.current = null; setDragging(null); }} + title={t('wo.drag')} + className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-foreground"> + + + {t(WIDGET_LABELS[k] ?? k)} +
+ ))} +
+
); } diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index d5b7106..b64b953 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -131,7 +131,7 @@ const en: Dict = { 'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists', 'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions', 'sec.confirmations': 'Confirmations', 'sec.external': 'External services', - 'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)', + 'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'wo.title': 'Widget order', 'wo.hint': 'The row to the right of the entry, in the order it appears. Drag to rearrange. A widget you have switched off keeps its place and comes back where you left it.', 'wo.drag': 'Drag to move', 'wo.reset': 'Reset to the default order', 'wo.entry': 'QSO entry', 'wo.details': 'Extra information (F1-F5)', 'wo.livestations': 'Who is on air', 'wo.chat': 'Chat', 'wo.rotor': 'Rotator compass', 'wo.motorant': 'Motorised antenna', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplifier', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'CW keyer', 'wo.photo': 'Operator photo', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)', 'appr.matrixEnable': 'Choose the band/mode matrix colours', 'appr.matrixHint': '(the PH/CW/DIG grid in Stats — off, each theme uses its own)', 'appr.matrixSample': 'Sample', 'appr.matrixReset': 'Back to the theme’s colours', // Matrix legend + colour names. One set of labels for the grid's legend, its @@ -651,7 +651,7 @@ const fr: Dict = { 'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes', 'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération", 'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes', - 'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)', + 'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'wo.title': 'Ordre des widgets', 'wo.hint': 'La rangée à droite de la saisie, dans son ordre d’affichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous l’aviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir l’ordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à l’air', 'wo.chat': 'Chat', 'wo.rotor': 'Boussole rotor', 'wo.motorant': 'Antenne motorisée', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplificateur', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'Manipulateur CW', 'wo.photo': 'Photo de l’opérateur', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)', 'appr.matrixEnable': 'Choisir les couleurs de la matrice bandes/modes', 'appr.matrixHint': '(la grille PH/CW/DIG des Stats — décoché, chaque thème garde les siennes)', 'appr.matrixSample': 'Aperçu', 'appr.matrixReset': 'Revenir aux couleurs du thème', // Légende de la matrice + noms des couleurs. Un seul jeu de libellés pour la