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'}
|
||||
|
||||
@@ -179,6 +179,31 @@ export function AppearancePanel() {
|
||||
<span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span>
|
||||
</label>
|
||||
|
||||
{/* Banding is its own thing, above the QSL rules and outside them: it says
|
||||
nothing about a contact, it only helps the eye keep its line across a
|
||||
wide table. Turning the status colours off must not take it away. */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={(cfg as any).recent_zebra !== 'off'} className="mt-0.5"
|
||||
onCheckedChange={(c) => save({ ...cfg, recent_zebra: c ? '' : 'off' } as any)} />
|
||||
<span>{t('appr.zebra')} <span className="text-xs text-muted-foreground">{t('appr.zebraHint')}</span></span>
|
||||
</label>
|
||||
{(cfg as any).recent_zebra !== 'off' && (
|
||||
<div className="flex items-center gap-2 pl-6">
|
||||
<span className="text-xs text-muted-foreground">{t('appr.zebraColor')}</span>
|
||||
<input type="color" className="size-7 rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test((cfg as any).recent_zebra_color ?? '') ? (cfg as any).recent_zebra_color : '#808080'}
|
||||
onChange={(e) => save({ ...cfg, recent_zebra_color: e.target.value } as any)} />
|
||||
{/* Empty means "follow the theme", and there has to be a way back to
|
||||
it — a colour chosen under one theme is wrong under the other. */}
|
||||
{(cfg as any).recent_zebra_color && (
|
||||
<button type="button" className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => save({ ...cfg, recent_zebra_color: '' } as any)}>{t('appr.zebraAuto')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.enabled} className="mt-0.5"
|
||||
onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
||||
|
||||
@@ -55,6 +55,19 @@ const SCOPES = [
|
||||
type ScopeKey = typeof SCOPES[number]['key'];
|
||||
|
||||
const SCOPE_KEY = 'opslog.gridMapScope';
|
||||
// Chosen fill colours. Empty means "follow the theme", which is the default and
|
||||
// stays the default: the tokens already track the four themes, and freezing a
|
||||
// hex at first run would leave a dark-theme map painted in the light palette.
|
||||
const COL_CONFIRMED_KEY = 'opslog.gridMapColorConfirmed';
|
||||
const COL_WORKED_KEY = 'opslog.gridMapColorWorked';
|
||||
|
||||
// A colour input only accepts #rrggbb. The theme tokens ARE plain hex today, so
|
||||
// this normally just passes them through — but a token that ever becomes oklch()
|
||||
// or a named colour would silently drive the swatch to black, and a fallback is
|
||||
// cheaper than that debugging session.
|
||||
function asHex(v: string, fallback: string): string {
|
||||
return /^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : fallback;
|
||||
}
|
||||
|
||||
export function GridSquareMap({ myGrid, className }: { myGrid?: string; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
@@ -69,6 +82,8 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
||||
|
||||
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
|
||||
const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
|
||||
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
|
||||
// Repaint the squares when the THEME changes, not the basemap: the fills come
|
||||
// from theme tokens resolved at draw time, so a theme switch leaves them on
|
||||
// the old palette until something forces a redraw.
|
||||
@@ -105,7 +120,15 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
}).setView([25, 0], 2);
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
return () => { m.remove(); mapRef.current = null; layerRef.current = null; };
|
||||
// Leaflet measures its container ONCE, when the map is created, and never
|
||||
// looks again. Here that measurement happens while the panel is still
|
||||
// laying out — so the map kept the height it had at that instant and the
|
||||
// rest of the panel stayed blank underneath it, whatever the window size.
|
||||
// Watching the host is the only fix that also survives a window resize, a
|
||||
// split-pane drag and the tab being shown for the first time.
|
||||
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
||||
ro.observe(hostRef.current);
|
||||
return () => { ro.disconnect(); m.remove(); mapRef.current = null; layerRef.current = null; };
|
||||
}, []);
|
||||
|
||||
// The chosen basemap, shared with the Main-tab map so picking one there and
|
||||
@@ -126,8 +149,8 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
layer.clearLayers();
|
||||
// Resolved once for the whole redraw, not per square: getComputedStyle
|
||||
// forces a style flush, and doing that a thousand times is a visible stall.
|
||||
const confirmedColour = cssColour('--success', '#16a34a');
|
||||
const workedColour = cssColour('--chart-1', '#2a78d6');
|
||||
const confirmedColour = confColour || cssColour('--success', '#16a34a');
|
||||
const workedFill = workedColour || cssColour('--chart-1', '#2a78d6');
|
||||
const meColour = cssColour('--warning', '#f59e0b');
|
||||
for (const sq of squares ?? []) {
|
||||
const b = gridSquareBounds(sq.grid);
|
||||
@@ -135,7 +158,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
// One hue, two states. Confirmed is the solid, saturated one; worked is
|
||||
// the same colour held back — so the eye reads "more" and "less" of the
|
||||
// same thing rather than two unrelated facts.
|
||||
const colour = sq.confirmed ? confirmedColour : workedColour;
|
||||
const colour = sq.confirmed ? confirmedColour : workedFill;
|
||||
L.rectangle([[b.south, b.west], [b.north, b.east]], {
|
||||
color: colour,
|
||||
weight: 0.5,
|
||||
@@ -159,7 +182,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
fillColor: meColour, fillOpacity: 1,
|
||||
}).bindTooltip(myGrid!.toUpperCase(), { sticky: true }).addTo(layer);
|
||||
}
|
||||
}, [squares, myGrid, t, themeTick]);
|
||||
}, [squares, myGrid, t, themeTick, confColour, workedColour]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const list = squares ?? [];
|
||||
@@ -200,6 +223,28 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
<option key={k} value={k}>{BASEMAPS[k].label}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Beside the basemap, because they answer the same question: what this
|
||||
map looks like. Written straight through — there is no Save here. */}
|
||||
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colConfirmed')}>
|
||||
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||
value={asHex(confColour || cssColour('--success', '#16a34a'), '#16a34a')}
|
||||
onChange={(e) => { setConfColour(e.target.value); writeUiPref(COL_CONFIRMED_KEY, e.target.value); }} />
|
||||
{t('gsm.confirmed')}
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colWorked')}>
|
||||
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||
value={asHex(workedColour || cssColour('--chart-1', '#2a78d6'), '#2a78d6')}
|
||||
onChange={(e) => { setWorkedColour(e.target.value); writeUiPref(COL_WORKED_KEY, e.target.value); }} />
|
||||
{t('gsm.worked')}
|
||||
</label>
|
||||
{(confColour || workedColour) && (
|
||||
<button type="button" title={t('gsm.colReset')}
|
||||
onClick={() => {
|
||||
setConfColour(''); setWorkedColour('');
|
||||
writeUiPref(COL_CONFIRMED_KEY, ''); writeUiPref(COL_WORKED_KEY, '');
|
||||
}}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
|
||||
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
||||
@@ -213,12 +258,12 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
<footer className="flex items-center gap-3 px-2.5 py-1 border-t border-border bg-muted/30 shrink-0 text-[10px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block size-2.5 rounded-[2px]"
|
||||
style={{ background: 'var(--success)', opacity: 0.75 }} />
|
||||
style={{ background: confColour || 'var(--success)', opacity: 0.75 }} />
|
||||
{t('gsm.confirmed')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block size-2.5 rounded-[2px]"
|
||||
style={{ background: 'var(--chart-1)', opacity: 0.35 }} />
|
||||
style={{ background: workedColour || 'var(--chart-1)', opacity: 0.35 }} />
|
||||
{t('gsm.worked')}
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
@@ -720,7 +720,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null)}
|
||||
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null, p.node.rowIndex ?? undefined)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,7 +168,7 @@ interface Props {
|
||||
initialSection?: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
|
||||
onMainPaneChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; // live Main-view layout update
|
||||
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
|
||||
icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
|
||||
yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane
|
||||
@@ -1029,15 +1029,16 @@ function RelayAutoPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||
// panes show, independently: the great-circle map, the locator street map, the
|
||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||
// MainViewPanes lets the operator choose what the Main tab shows, in up to four
|
||||
// panes. The first two are the view; the third and fourth can be left empty, and
|
||||
// an empty one is not drawn at all. Per-profile (stored via SetUIPref, which is
|
||||
// profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes'];
|
||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
|
||||
const PANE_NONE = 'none';
|
||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [left, setLeft] = useState('map1');
|
||||
const [right, setRight] = useState('map2');
|
||||
const [panes, setPanes] = useState<Record<string, string>>({ left: 'map1', right: 'map2', p3: PANE_NONE, p4: PANE_NONE });
|
||||
const [layout, setLayout] = useState('quad');
|
||||
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
||||
const options = [
|
||||
...MAIN_PANE_VALUES,
|
||||
@@ -1046,42 +1047,77 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable
|
||||
...(yaesuAvailable ? ['yaesu'] : []),
|
||||
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const KEYS: Record<string, string> = { left: 'mainPaneLeft', right: 'mainPaneRight', p3: 'mainPane3', p4: 'mainPane4' };
|
||||
useEffect(() => {
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || MAIN_PANE_VALUES.includes(v);
|
||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||
Promise.all([
|
||||
...Object.values(KEYS).map((k) => GetUIPref(k).catch(() => '')),
|
||||
GetUIPref('mainPaneLayout').catch(() => ''),
|
||||
]).then(([l, r, p3, p4, lay]) => {
|
||||
setPanes({
|
||||
left: valid(l) ? l : 'map1',
|
||||
right: valid(r) ? r : 'map2',
|
||||
p3: valid(p3) ? p3 : PANE_NONE,
|
||||
p4: valid(p4) ? p4 : PANE_NONE,
|
||||
});
|
||||
if (lay === 'cols' || lay === 'quad') setLayout(lay);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const pick = (side: 'left' | 'right', v: string) => {
|
||||
if (side === 'left') setLeft(v); else setRight(v);
|
||||
const pick = (side: string, v: string) => {
|
||||
setPanes((p) => ({ ...p, [side]: v }));
|
||||
// Persist (per-profile) AND tell the parent the new value directly, so the
|
||||
// Main view updates from the chosen value — never a stale DB re-read.
|
||||
SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {});
|
||||
onChanged?.(side, v);
|
||||
SetUIPref(KEYS[side], v).catch(() => {});
|
||||
onChanged?.(side as any, v);
|
||||
};
|
||||
const pickLayout = (v: string) => {
|
||||
setLayout(v);
|
||||
SetUIPref('mainPaneLayout', v).catch(() => {});
|
||||
onChanged?.('layout', v);
|
||||
};
|
||||
const filled = ['left', 'right', 'p3', 'p4'].filter((k) => panes[k] !== PANE_NONE).length;
|
||||
const SIDES = [
|
||||
{ key: 'left', label: t('settings.leftPane'), canBeEmpty: false },
|
||||
{ key: 'right', label: t('settings.rightPane'), canBeEmpty: false },
|
||||
{ key: 'p3', label: t('settings.thirdPane'), canBeEmpty: true },
|
||||
{ key: 'p4', label: t('settings.fourthPane'), canBeEmpty: true },
|
||||
];
|
||||
return (
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
|
||||
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
|
||||
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
|
||||
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
{SIDES.map((sd) => (
|
||||
<label key={sd.key} className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">{sd.label}</span>
|
||||
<Select value={panes[sd.key]} onValueChange={(v) => pick(sd.key, v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Only the third and fourth may be empty: the first two ARE the
|
||||
Main view, and a tab with nothing in it is not a layout. */}
|
||||
{sd.canBeEmpty && <SelectItem value={PANE_NONE}>{t('settings.paneNone')}</SelectItem>}
|
||||
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{/* Only asked when it can matter. Three panes are columns and nothing
|
||||
else; four are the only case with two honest answers. */}
|
||||
{filled === 4 && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="text-xs text-muted-foreground">{t('settings.paneLayout')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||
{[{ v: 'quad', l: t('settings.paneQuad') }, { v: 'cols', l: t('settings.paneCols') }].map((o) => (
|
||||
<button key={o.v} type="button" onClick={() => pickLayout(o.v)}
|
||||
className={cn('px-2.5 py-1', layout === o.v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { QSO_BOX_FIELDS, QSO_FIELD_LABELS } from './qslTypes';
|
||||
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
|
||||
import type { CardSelection } from './CardPreview';
|
||||
import { StylePresetPicker, NumberField } from './StylePresetPicker';
|
||||
@@ -199,6 +200,26 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
|
||||
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
|
||||
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Columns</Label>
|
||||
{/* Rebuilt from the canonical order every time rather than
|
||||
appended to: a box whose columns come out in the order they
|
||||
were ticked reads as a bug, and the widths are tuned for this
|
||||
order. */}
|
||||
<div className="grid grid-cols-2 gap-x-2">
|
||||
{QSO_BOX_FIELDS.map((f) => (
|
||||
<label key={f} className="flex items-center gap-1.5 text-xs">
|
||||
<Checkbox
|
||||
checked={box.fields.includes(f)}
|
||||
onCheckedChange={(v) => onPatchBox({
|
||||
fields: QSO_BOX_FIELDS.filter((k) => (k === f ? v === true : box.fields.includes(k))),
|
||||
})}
|
||||
/>
|
||||
{QSO_FIELD_LABELS[f] ?? f}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
||||
|
||||
@@ -165,6 +165,12 @@ export interface QSLPresetInfo {
|
||||
}
|
||||
|
||||
// Human labels for the QSO box fields the renderer knows.
|
||||
// QSO_BOX_FIELDS is the canonical COLUMN ORDER of the confirmation box, and
|
||||
// the whole list the designer offers. The renderer's column widths are tuned
|
||||
// for it, so a box is always rebuilt from this order rather than from the
|
||||
// order the operator happened to tick the boxes in.
|
||||
export const QSO_BOX_FIELDS = ['qso_date', 'time_on', 'band', 'freq', 'mode', 'submode', 'rst_sent'] as const;
|
||||
|
||||
export const QSO_FIELD_LABELS: Record<string, string> = {
|
||||
qso_date: 'Date',
|
||||
time_on: 'UTC',
|
||||
|
||||
@@ -104,8 +104,8 @@ const en: Dict = {
|
||||
'settings.telemetry': 'Send anonymous usage statistics',
|
||||
'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
|
||||
'settings.mainView': 'Main view',
|
||||
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
|
||||
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
|
||||
'settings.mainViewHint': 'Choose what the Main tab shows, in up to four panes (per profile). Leave the third and fourth empty and they are not drawn; every divider is draggable, and a double-click evens them out.',
|
||||
'settings.leftPane': 'Left pane', 'settings.thirdPane': 'Third pane', 'settings.fourthPane': 'Fourth pane', 'settings.paneNone': '— none —', 'settings.paneLayout': 'With four panes:', 'settings.paneQuad': 'Quarters (2 × 2)', 'settings.paneCols': 'Four columns', 'settings.rightPane': 'Right pane',
|
||||
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
|
||||
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
|
||||
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', 'settings.pane.decodes': 'FT decodes',
|
||||
@@ -117,7 +117,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.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', '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
|
||||
@@ -312,7 +312,7 @@ const en: Dict = {
|
||||
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
|
||||
'rq.searchPh': 'Search callsign… 4S · *4S · *4S*', 'rq.searchTip': 'A plain word matches the START of a callsign: 4S finds 4S7AB. * is any run of characters and ? is exactly one, so *4S ends with 4S, *4S* contains it anywhere, and F?BPO matches F4BPO.',
|
||||
'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.',
|
||||
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed',
|
||||
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed',
|
||||
'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region; 100 km suits 2 m, where a duct is narrow.',
|
||||
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotTtlHint': 'minutes — spots older than this are removed from the list and the band maps. 0 keeps them.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot',
|
||||
'clu.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.',
|
||||
@@ -592,8 +592,8 @@ const fr: Dict = {
|
||||
'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
|
||||
'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
|
||||
'settings.mainView': 'Vue principale',
|
||||
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
|
||||
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
|
||||
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche, jusqu’à quatre volets (par profil). Laisse le troisième et le quatrième vides et ils ne sont pas dessinés ; chaque séparateur se glisse, et un double-clic les remet à égalité.",
|
||||
'settings.leftPane': 'Volet gauche', 'settings.thirdPane': 'Troisième volet', 'settings.fourthPane': 'Quatrième volet', 'settings.paneNone': '— aucun —', 'settings.paneLayout': 'Avec quatre volets :', 'settings.paneQuad': 'Quarts (2 × 2)', 'settings.paneCols': 'Quatre colonnes', 'settings.rightPane': 'Volet droit',
|
||||
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
|
||||
'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés',
|
||||
'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net', 'settings.pane.decodes': 'Decodes FT',
|
||||
@@ -604,7 +604,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.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', '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
|
||||
@@ -793,7 +793,7 @@ const fr: Dict = {
|
||||
'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)",
|
||||
'rq.searchPh': 'Chercher un indicatif… 4S · *4S · *4S*', 'rq.searchTip': 'Un mot simple correspond au DÉBUT de l’indicatif : 4S trouve 4S7AB. * remplace n’importe quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient n’importe où, et F?BPO correspond à F4BPO.',
|
||||
'gsc.scope': 'Carré déjà fait selon', 'gsc.hunt': 'Chasser', 'gsc.huntNew': 'Nouveau — jamais contacté', 'gsc.huntUnconf': 'Nouveau et non confirmé', 'gsc.scope_band_digi': 'Cette bande + tout mode numérique', 'gsc.scope_band_mode': 'Cette bande + ce mode exact', 'gsc.scope_band_ftx': 'Cette bande + tout mode FT (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Toutes bandes + tout mode numérique', 'gsc.scope_mix_mode': 'Toutes bandes + ce mode exact', 'gsc.scope_mix_ftx': 'Toutes bandes + tout mode FT (FT8/FT4/FT2)', 'gsc.hint': 'Détermine quand un carré cesse d’être NEW. Plus c’est étroit, plus il y a de carrés à chasser : par bande et par mode exact est le plus exigeant, toutes bandes et tout numérique le moins. Chasser aussi les non confirmés garde un carré recherché jusqu’à une confirmation QSL, LoTW ou eQSL — il manque toujours au diplôme d’ici là.',
|
||||
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés',
|
||||
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés',
|
||||
'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que s’il a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille n’a plus rien à observer. 300 km emprunte les oreilles de toute une région ; 100 km convient au 2 m, où un conduit est étroit.',
|
||||
'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
|
||||
'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton n’est pas affiché.',
|
||||
|
||||
@@ -16,9 +16,16 @@ export type RowColorSettings = {
|
||||
style?: 'bar' | 'tint' | 'both';
|
||||
intensity?: number;
|
||||
bandmap_lotw?: boolean;
|
||||
recent_zebra?: string; // '' = alternate (default), 'off' = every row alike
|
||||
recent_zebra_color?: string; // '' = follow the theme
|
||||
rules: RowColorRule[];
|
||||
};
|
||||
|
||||
// The banding the grid theme applies on its own. Repeated here because turning
|
||||
// the option OFF means painting every row explicitly — the theme would
|
||||
// otherwise keep striping underneath whatever we do per row.
|
||||
const ZEBRA_DEFAULT = 'color-mix(in srgb, var(--muted) 40%, var(--card))';
|
||||
|
||||
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||
|
||||
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
|
||||
@@ -76,21 +83,43 @@ export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule
|
||||
// A log where nearly every contact has SOME QSL state ends up with every row
|
||||
// painted, and colour that is always present stops being information. The
|
||||
// default is a stripe down the left edge; a tint is offered at a chosen strength.
|
||||
export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined {
|
||||
if (!cfg?.enabled) return undefined;
|
||||
// rowIndex is optional: only the Recent QSOs grid bands its rows, and the other
|
||||
// callers have nothing to say about odd and even.
|
||||
export function rowStyleFor(q: any, cfg: RowColorSettings | null, rowIndex?: number): Record<string, string> | undefined {
|
||||
const out: Record<string, string> = {};
|
||||
// Banding first, and INDEPENDENT of cfg.enabled: it is legibility, not QSL
|
||||
// status, and an operator who wants plain rows should not have to turn the
|
||||
// status colours off to get them.
|
||||
if (typeof rowIndex === 'number') {
|
||||
if (cfg?.recent_zebra === 'off') {
|
||||
out.backgroundColor = 'var(--card)';
|
||||
} else if (rowIndex % 2 === 1) {
|
||||
out.backgroundColor = cfg?.recent_zebra_color || ZEBRA_DEFAULT;
|
||||
}
|
||||
}
|
||||
const empty = () => (Object.keys(out).length ? out : undefined);
|
||||
if (!cfg?.enabled) return empty();
|
||||
const rule = matchRowRule(q, cfg);
|
||||
if (!rule?.color) return undefined;
|
||||
if (!rule?.color) return empty();
|
||||
|
||||
const style = cfg.style ?? 'bar';
|
||||
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
|
||||
const out: Record<string, string> = {};
|
||||
if (style === 'tint' || style === 'both') {
|
||||
// Overrides the banding on purpose: the QSL colour is information, the
|
||||
// banding is only there to help the eye keep its line.
|
||||
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
|
||||
}
|
||||
if (style === 'bar' || style === 'both') {
|
||||
// inset shadow rather than a border: a border would shift the cells three
|
||||
// pixels on coloured rows only, and the columns would stop lining up.
|
||||
out.boxShadow = `inset 3px 0 0 ${rule.color}`;
|
||||
// A background gradient, not a border and not a shadow.
|
||||
//
|
||||
// A border would shift the cells three pixels on coloured rows only, and
|
||||
// the columns would stop lining up. An inset box-shadow was the first fix
|
||||
// for that and drew the stripe correctly — but on the grid's transformed,
|
||||
// absolutely-positioned rows it also bled a hairline of the same colour
|
||||
// along the row edges, so every coloured row got a horizontal rule it was
|
||||
// never meant to have. A gradient paints inside the box and nothing else:
|
||||
// three pixels of colour, then transparent, and no edge to bleed from.
|
||||
out.backgroundImage = `linear-gradient(to right, ${rule.color} 0, ${rule.color} 3px, transparent 3px)`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ const PORTABLE_KEYS = [
|
||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||
'opslog.activeTab', // last selected tab
|
||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
||||
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
||||
'opslog.mainQuad', // Main tab: the 2x2 cross, as {col,row} percentages
|
||||
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
|
||||
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||
@@ -51,6 +53,7 @@ const PORTABLE_KEYS = [
|
||||
'opslog.clusterMacros', // cluster console: the twelve named command buttons
|
||||
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
|
||||
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
|
||||
'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme)
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.26.0';
|
||||
export const APP_VERSION = '0.26.1';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
@@ -3301,6 +3301,8 @@ export namespace main {
|
||||
style: string;
|
||||
intensity: number;
|
||||
bandmap_lotw: boolean;
|
||||
recent_zebra?: string;
|
||||
recent_zebra_color?: string;
|
||||
configured: boolean;
|
||||
rules: RowColorRule[];
|
||||
|
||||
@@ -3314,6 +3316,8 @@ export namespace main {
|
||||
this.style = source["style"];
|
||||
this.intensity = source["intensity"];
|
||||
this.bandmap_lotw = source["bandmap_lotw"];
|
||||
this.recent_zebra = source["recent_zebra"];
|
||||
this.recent_zebra_color = source["recent_zebra_color"];
|
||||
this.configured = source["configured"];
|
||||
this.rules = this.convertValues(source["rules"], RowColorRule);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user