chore: release v0.26.1
This commit is contained in:
+192
-31
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
@@ -944,14 +944,55 @@ export default function App() {
|
||||
});
|
||||
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<number>(() => {
|
||||
const n = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
|
||||
return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50;
|
||||
// Main tab column shares, in percent, kept PER COLUMN COUNT.
|
||||
//
|
||||
// One list per count and not a single one that is rescaled: going from three
|
||||
// panes to two and back should give you the three-pane layout you arranged,
|
||||
// not something derived from the two-pane one. The old single 'mainSplit' key
|
||||
// is still read so an existing two-pane arrangement survives the upgrade.
|
||||
//
|
||||
// MIN_SHARE is what keeps a pane recoverable: a column dragged to nothing
|
||||
// takes its divider off screen with it, and there is then no way back.
|
||||
const MIN_SHARE = 10;
|
||||
const evenShares = (n: number) => Array.from({ length: n }, () => 100 / n);
|
||||
const [mainShares, setMainShares] = useState<Record<number, number[]>>(() => {
|
||||
const out: Record<number, number[]> = { 2: evenShares(2), 3: evenShares(3), 4: evenShares(4) };
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem('opslog.mainShares') || '{}');
|
||||
for (const n of [2, 3, 4]) {
|
||||
const a = v?.[n];
|
||||
if (Array.isArray(a) && a.length === n && a.every((x: any) => Number.isFinite(x) && x >= MIN_SHARE)) {
|
||||
out[n] = a.map(Number);
|
||||
}
|
||||
}
|
||||
} catch { /* a corrupt preference must not cost the layout */ }
|
||||
// Legacy: the two-pane percentage from before there were more than two.
|
||||
const old = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
|
||||
if (!Array.isArray(JSON.parse(localStorage.getItem('opslog.mainShares') || 'null')?.[2])
|
||||
&& Number.isFinite(old) && old >= MIN_SHARE && old <= 100 - MIN_SHARE) {
|
||||
out[2] = [old, 100 - old];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]);
|
||||
const saveShares = (next: Record<number, number[]>) => {
|
||||
setMainShares(next);
|
||||
writeUiPref('opslog.mainShares', JSON.stringify(next));
|
||||
};
|
||||
// The quadrant cross: one vertical split and one horizontal split, each
|
||||
// shared by both rows/columns. Two independent row heights would let the
|
||||
// panes stop lining up, which reads as a rendering fault rather than a choice.
|
||||
const [mainQuad, setMainQuad] = useState<{ col: number; row: number }>(() => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem('opslog.mainQuad') || '{}');
|
||||
const ok = (x: any) => Number.isFinite(x) && x >= MIN_SHARE && x <= 100 - MIN_SHARE;
|
||||
if (ok(v?.col) && ok(v?.row)) return { col: Number(v.col), row: Number(v.row) };
|
||||
} catch { /* same */ }
|
||||
return { col: 50, row: 50 };
|
||||
});
|
||||
const saveQuad = (next: { col: number; row: number }) => {
|
||||
setMainQuad(next);
|
||||
writeUiPref('opslog.mainQuad', JSON.stringify(next));
|
||||
};
|
||||
|
||||
// Band-map widths. Two of them, because they are two different things: the
|
||||
// docked map sits beside the tables and competes with them for room, while
|
||||
@@ -996,22 +1037,60 @@ export default function App() {
|
||||
};
|
||||
|
||||
const mainSplitRef = useRef<HTMLDivElement | null>(null);
|
||||
const startMainSplitDrag = (e: React.PointerEvent) => {
|
||||
// Drag the divider between column i and column i+1.
|
||||
//
|
||||
// Only those two columns change, and their SUM is held constant: pushing a
|
||||
// divider must not shuffle the columns further along, which is what makes a
|
||||
// three-way split impossible to arrange one divider at a time.
|
||||
const startMainSplitDrag = (e: React.PointerEvent, i: number, count: number) => {
|
||||
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 start = (mainShares[count] ?? evenShares(count)).slice();
|
||||
const pair = start[i] + start[i + 1];
|
||||
const before = start.slice(0, i).reduce((a, b) => a + b, 0);
|
||||
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 first = Math.min(pair - MIN_SHARE, Math.max(MIN_SHARE, pct - before));
|
||||
const next = start.slice();
|
||||
next[i] = first;
|
||||
next[i + 1] = pair - first;
|
||||
setMainShares((m) => ({ ...m, [count]: next }));
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
// Persisted on release, not on every move: the pointer fires dozens of
|
||||
// events a second and each one would be a database write.
|
||||
setMainShares((m) => { writeUiPref('opslog.mainShares', JSON.stringify(m)); return m; });
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
};
|
||||
// The quadrant cross. Same shape, one axis at a time.
|
||||
const startQuadDrag = (e: React.PointerEvent, axis: 'col' | 'row') => {
|
||||
e.preventDefault();
|
||||
const host = mainSplitRef.current;
|
||||
if (!host) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
const pct = axis === 'col'
|
||||
? ((ev.clientX - r.left) / r.width) * 100
|
||||
: ((ev.clientY - r.top) / r.height) * 100;
|
||||
const v = Math.min(100 - MIN_SHARE, Math.max(MIN_SHARE, pct));
|
||||
setMainQuad((q) => ({ ...q, [axis]: v }));
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
setMainQuad((q) => { writeUiPref('opslog.mainQuad', JSON.stringify(q)); return q; });
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
@@ -1725,18 +1804,32 @@ 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' | 'decodes';
|
||||
// 'none' is only ever stored for the third and fourth panes: the first two are
|
||||
// the Main view, and a layout with no panes at all is not a layout.
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol' | 'decodes' | 'none';
|
||||
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
|
||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||
const [mainPane3, setMainPane3] = useState<MainPaneKind>('none');
|
||||
const [mainPane4, setMainPane4] = useState<MainPaneKind>('none');
|
||||
// With four panes: side by side, or the screen quartered. Both are wanted —
|
||||
// four columns on a 65" desk display, quadrants on an ordinary one, where a
|
||||
// quarter-width map is unreadable.
|
||||
const [mainLayout4, setMainLayout4] = useState<'cols' | 'quad'>('quad');
|
||||
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' || v === 'decodes';
|
||||
const [l, r] = await Promise.all([
|
||||
const [l, r, p3, p4, lay] = await Promise.all([
|
||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||
GetUIPref('mainPaneRight').catch(() => ''),
|
||||
GetUIPref('mainPane3').catch(() => ''),
|
||||
GetUIPref('mainPane4').catch(() => ''),
|
||||
GetUIPref('mainPaneLayout').catch(() => ''),
|
||||
]);
|
||||
setMainPaneLeft(valid(l) ? l : 'map1');
|
||||
setMainPaneRight(valid(r) ? r : 'map2');
|
||||
setMainPane3(valid(p3) ? p3 : 'none');
|
||||
setMainPane4(valid(p4) ? p4 : 'none');
|
||||
setMainLayout4(lay === 'cols' ? 'cols' : 'quad');
|
||||
}, []);
|
||||
useEffect(() => { loadMainPanes(); }, [loadMainPanes]);
|
||||
// Report the current entry-strip band/mode/freq to the backend so the live
|
||||
@@ -1854,15 +1947,17 @@ export default function App() {
|
||||
const spotsVisibleRef = useRef(false);
|
||||
const spotsDirtyRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster'
|
||||
|| mainPaneLeft === 'decodes' || mainPaneRight === 'decodes'
|
||||
// Every pane, not just the first two — a cluster list in the third pane is
|
||||
// just as visible, and missing it left a spot's NEW pill stale.
|
||||
const shown = [mainPaneLeft, mainPaneRight, mainPane3, mainPane4];
|
||||
const vis = shown.includes('cluster') || shown.includes('decodes')
|
||||
|| activeTab === 'cluster' || activeTab === 'bandmap' || activeTab === 'decodes' || showBandMap;
|
||||
if (vis && !spotsVisibleRef.current && spotsDirtyRef.current) {
|
||||
spotsDirtyRef.current = false;
|
||||
void refreshSpotStatuses();
|
||||
}
|
||||
spotsVisibleRef.current = vis;
|
||||
}, [mainPaneLeft, mainPaneRight, activeTab, showBandMap, refreshSpotStatuses]);
|
||||
}, [mainPaneLeft, mainPaneRight, mainPane3, mainPane4, activeTab, showBandMap, refreshSpotStatuses]);
|
||||
useEffect(() => {
|
||||
let t: number | undefined;
|
||||
const off = EventsOn('qso:logged', () => {
|
||||
@@ -7571,21 +7666,79 @@ export default function App() {
|
||||
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. */}
|
||||
<div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
|
||||
style={{ gridTemplateColumns: `${mainSplit}fr 6px ${100 - mainSplit}fr` }}>
|
||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title={t('main.splitTip')}
|
||||
onPointerDown={startMainSplitDrag}
|
||||
onDoubleClick={() => setMainSplit(50)}
|
||||
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
|
||||
>
|
||||
<span className="h-10 w-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
|
||||
</div>
|
||||
{(() => {
|
||||
// The panes actually configured, in order. 'none' is skipped
|
||||
// rather than drawn empty: a blank quarter of the screen is not
|
||||
// a layout choice anyone makes on purpose.
|
||||
const panes = [mainPaneLeft, mainPaneRight, mainPane3, mainPane4]
|
||||
.filter((k) => k !== 'none') as MainPaneKind[];
|
||||
const grip = (vertical: boolean) => (
|
||||
<span className={cn('rounded-full bg-border group-hover:bg-primary transition-colors',
|
||||
vertical ? 'h-10 w-1' : 'w-10 h-1')} />
|
||||
);
|
||||
|
||||
// Four panes, quartered: ONE vertical divider and ONE horizontal
|
||||
// one, each shared by both rows/columns, so the panes always line
|
||||
// up. Independent per-row splits look like a rendering fault.
|
||||
if (panes.length === 4 && mainLayout4 === 'quad') {
|
||||
return (
|
||||
<div ref={mainSplitRef} className="grid gap-2 h-full min-h-0 p-2"
|
||||
style={{
|
||||
gridTemplateColumns: `${mainQuad.col}fr 6px ${100 - mainQuad.col}fr`,
|
||||
gridTemplateRows: `${mainQuad.row}fr 6px ${100 - mainQuad.row}fr`,
|
||||
}}>
|
||||
{panes.map((k, i) => (
|
||||
<div key={i} className="min-h-0 min-w-0 flex"
|
||||
style={{ gridColumn: i % 2 === 0 ? 1 : 3, gridRow: i < 2 ? 1 : 3 }}>
|
||||
{renderMainPane(k)}
|
||||
</div>
|
||||
))}
|
||||
<div role="separator" aria-orientation="vertical" title={t('main.splitTip')}
|
||||
style={{ gridColumn: 2, gridRow: '1 / span 3' }}
|
||||
onPointerDown={(e) => startQuadDrag(e, 'col')}
|
||||
onDoubleClick={() => saveQuad({ ...mainQuad, col: 50 })}
|
||||
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1">
|
||||
{grip(true)}
|
||||
</div>
|
||||
<div role="separator" aria-orientation="horizontal" title={t('main.splitTip')}
|
||||
style={{ gridColumn: '1 / span 3', gridRow: 2 }}
|
||||
onPointerDown={(e) => startQuadDrag(e, 'row')}
|
||||
onDoubleClick={() => saveQuad({ ...mainQuad, row: 50 })}
|
||||
className="group relative cursor-row-resize flex items-center justify-center -my-1 py-1">
|
||||
{grip(false)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One pane fills the tab; two, three or four sit side by side
|
||||
// with a divider between each neighbouring pair.
|
||||
const n = panes.length;
|
||||
const shares = n >= 2 ? (mainShares[n] ?? evenShares(n)) : [100];
|
||||
const cols = shares.map((w) => `${w}fr`).join(' 6px ');
|
||||
return (
|
||||
<div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
|
||||
style={{ gridTemplateColumns: cols }}>
|
||||
{panes.map((k, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title={t('main.splitTip')}
|
||||
onPointerDown={(e) => startMainSplitDrag(e, i - 1, n)}
|
||||
onDoubleClick={() => saveShares({ ...mainShares, [n]: evenShares(n) })}
|
||||
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
|
||||
>
|
||||
{grip(true)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(k)}</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
||||
@@ -8042,7 +8195,15 @@ export default function App() {
|
||||
// spot already on screen showing the answer to the OLD question.
|
||||
setSpotStatus({});
|
||||
}}
|
||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||
onMainPaneChanged={(side, v) => {
|
||||
// Applied from the CHOSEN value, never from a re-read of the DB:
|
||||
// the write is async and the layout must not lag a click behind.
|
||||
if (side === 'left') setMainPaneLeft(v as MainPaneKind);
|
||||
else if (side === 'right') setMainPaneRight(v as MainPaneKind);
|
||||
else if (side === 'p3') setMainPane3(v as MainPaneKind);
|
||||
else if (side === 'p4') setMainPane4(v as MainPaneKind);
|
||||
else if (side === 'layout') setMainLayout4(v === 'cols' ? 'cols' : 'quad');
|
||||
}}
|
||||
flexAvailable={catState.backend === 'flex'}
|
||||
icomAvailable={catState.backend === 'icom'}
|
||||
yaesuAvailable={catState.backend === 'yaesu'}
|
||||
|
||||
Reference in New Issue
Block a user