feat(layout): drag the widgets into the order you want

A list in Appearance, in the row's own order, dragged to rearrange —
with QSO entry and the F1-F5 panel at its head, locked. They are not in
that row at all, and letting an operator push the thing they type into
behind a rotator dial is not a preference, it is a trap.

Implemented with flexbox ORDER rather than by moving the JSX: in a
component this size, reordering the tree would have moved every
condition, ref and hook with it. Each slot keeps its place in the source
and receives an order property, so a widget switched off still holds its
rank and returns where the operator left it.

An unknown key from a later version joins the end rather than the front,
and a key that no longer exists is dropped — an old preference can
neither reorder a widget it has never heard of nor hide one. Opens
0.27.10.
This commit is contained in:
2026-09-03 09:46:47 +02:00
parent ab68e4a84e
commit 85061ab673
4 changed files with 146 additions and 15 deletions
+39 -12
View File
@@ -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<string[]>(() => {
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' && (
<div className="w-[248px] shrink-0 min-h-0 relative">
<div className="w-[248px] shrink-0 min-h-0 relative" style={{ order: wOrder('livestations') }}>
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
<Radio className="size-3.5 text-primary" />
@@ -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.
<div className="w-[280px] shrink-0 min-h-0 relative">
<div className="w-[280px] shrink-0 min-h-0 relative" style={{ order: wOrder('chat') }}>
<div className="absolute inset-0 flex flex-col min-h-0">
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
onSend={chatSend} onClose={() => 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) && (
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')}>
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}>
<RotorCompass
presets={rotorCompact ? undefined : rotorPresets}
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
@@ -7516,7 +7543,7 @@ export default function App() {
</div>
)}
{showMotorAnt && ubStatus.enabled && (
<div className="w-[230px] shrink-0 min-h-0">
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('motorant') }}>
<MotorAntennaWidget
ant={ubStatus}
refetch={pokeUbStatus}
@@ -7527,7 +7554,7 @@ export default function App() {
</div>
)}
{showAntGenius && agEnabled && (
<div className="w-[230px] shrink-0 min-h-0">
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('antgenius') }}>
<AntGeniusPanel
status={agStatus}
onActivate={agActivate}
@@ -7540,7 +7567,7 @@ export default function App() {
// One column per amplifier shown, so two amps stand side by side
// rather than making the widget twice as tall as the dock row.
<div className="shrink-0 min-h-0"
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}>
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px`, order: wOrder('amp') }}>
<AmpWidget
amps={ampSts}
flex={flexAmp}
@@ -7550,7 +7577,7 @@ export default function App() {
</div>
)}
{showTuner && tgEnabled && (
<div className="w-[230px] shrink-0 min-h-0">
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('tuner') }}>
<TunerGeniusPanel
status={tgStatus}
onTune={tgTune}
@@ -7562,7 +7589,7 @@ export default function App() {
</div>
)}
{showScp && scpEnabled && (
<div className="w-[240px] shrink-0 min-h-0">
<div className="w-[240px] shrink-0 min-h-0" style={{ order: wOrder('scp') }}>
<ScpPanel
result={scpResult}
currentCall={callsign}
@@ -7573,7 +7600,7 @@ export default function App() {
</div>
)}
{chaseNewOn && showChaseNew && (
<div className="w-[420px] shrink-0 min-h-0">
<div className="w-[420px] shrink-0 min-h-0" style={{ order: wOrder('chasenew') }}>
{/* Same reflex as clicking a cluster spot: the callsign into the
entry, and the rig onto the frequency it was decoded on. */}
<ChaseNewPanel onPick={(sp) => {
@@ -7586,7 +7613,7 @@ export default function App() {
</div>
)}
{dvkEnabled && (
<div className="w-[320px] shrink-0 min-h-0">
<div className="w-[320px] shrink-0 min-h-0" style={{ order: wOrder('dvk') }}>
<DvkPanel
messages={dvkMsgs}
status={dvkStat}
@@ -7602,7 +7629,7 @@ export default function App() {
</div>
)}
{wkEnabled && (
<div className="w-[380px] shrink-0 min-h-0">
<div className="w-[380px] shrink-0 min-h-0" style={{ order: wOrder('winkeyer') }}>
<WinkeyerPanel
// A rig keyer has no serial status of its own: it is connected
// exactly when its CAT backend is. Yaesu was missing from this
@@ -7646,7 +7673,7 @@ export default function App() {
{/* QRZ photo: when the keyer is open it sits to its right at natural
(capped) width, shrinking the keyer panel rather than hiding it. */}
{lookupResult?.image_url && (
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')}>
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')} style={{ order: wOrder('photo') }}>
<button
type="button"
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}