chore: release v0.26.1

This commit is contained in:
2026-08-21 01:43:14 +02:00
parent e3b7a35e2c
commit e6889caae9
17 changed files with 472 additions and 95 deletions
+192 -31
View File
@@ -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'}