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:
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { EventsEmit } from '../../wailsjs/runtime/runtime';
|
||||
import { GripVertical, Lock } from 'lucide-react';
|
||||
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -290,6 +292,98 @@ export function AppearancePanel() {
|
||||
)}
|
||||
|
||||
<MatrixColorsSection />
|
||||
<WidgetOrderSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<string, string> = {
|
||||
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<string[]>(readWidgetOrder);
|
||||
const dragKey = useRef<string | null>(null);
|
||||
const [dragging, setDragging] = useState<string | null>(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 (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
|
||||
<div className="space-y-1 max-w-md">
|
||||
{/* 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) => (
|
||||
<div key={k}
|
||||
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
|
||||
<Lock className="size-3.5 shrink-0 opacity-60" />
|
||||
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
|
||||
</div>
|
||||
))}
|
||||
{order.map((k) => (
|
||||
<div key={k}
|
||||
onDragOver={(e) => { 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')}>
|
||||
<span draggable
|
||||
onDragStart={(e) => { 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">
|
||||
<GripVertical className="size-4" />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline">
|
||||
{t('wo.reset')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user