This commit is contained in:
2026-06-10 20:27:44 +02:00
parent 42b5c6247d
commit 6150498a9e
9 changed files with 223 additions and 120 deletions
+92 -76
View File
@@ -597,15 +597,15 @@ export default function App() {
const [clusterSearch, setClusterSearch] = useState('');
// Hide spots already worked (exact call worked, or this band+mode slot done).
const [clusterHideWorked, setClusterHideWorked] = useState(false);
const [showBandMap, setShowBandMap] = useState(false);
// Which side the band map docks to (persisted). Toggled from its header.
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
);
const toggleBandMapSide = useCallback(() => {
setBandMapSide((s) => {
const next = s === 'right' ? 'left' : 'right';
writeUiPref('bandmap.side', next);
// Bands shown side-by-side in the Band Map tab (portable).
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
catch { return []; }
});
const toggleBandMapBand = useCallback((b: string) => {
setBandMapBands((cur) => {
const next = cur.includes(b) ? cur.filter((x) => x !== b) : [...cur, b];
writeUiPref('opslog.bandMapBands', JSON.stringify(next));
return next;
});
}, []);
@@ -670,6 +670,10 @@ export default function App() {
// tell whether an incoming DX call actually changed anything.
const callsignValRef = useRef('');
useEffect(() => { callsignValRef.current = callsign; }, [callsign]);
// Last callsign broadcast over UDP (WSJT-X/JTDX/MSHV/DXHunter). Lets us tell
// "the field still shows the previous broadcast" (safe to update) from "the
// user has typed a different call" (must not clobber).
const lastUdpCallRef = useRef('');
// When the entered callsign turns out to be worked-before, jump to the
// Worked-before tab so the history is front-and-centre. Only once per call,
@@ -901,6 +905,22 @@ export default function App() {
setRstSent(p?.default_rst_sent || fallback);
setRstRcvd(p?.default_rst_rcvd || fallback);
}
// Clicking a spot (cluster grid or any band map): tune the rig, set the mode,
// fill the call, pre-fill POTA, (re)start the recording. Shared so every spot
// source behaves identically.
function handleSpotClick(s: any) {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
tuneRigCAT(s.freq_hz, m);
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
}
if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call);
applySpotPOTA((s as any).pota_ref);
if (s.dx_call?.trim()) restartRecordingForNewTarget();
}
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
@@ -1030,23 +1050,28 @@ export default function App() {
// We push the broadcast DX call into the entry field and auto-log any
// ADIF record that arrives.
useEffect(() => {
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
const call = String(p?.call ?? '').trim();
if (!call) return;
// Don't clobber what the user is currently typing — only update
// when the entry field is empty or matches a previous broadcast.
const changed = call.toUpperCase() !== callsignValRef.current.trim().toUpperCase();
// Apply a UDP-broadcast callsign, but never clobber what the operator is
// typing: only update when the field is empty, already shows this call, or
// still shows the previous broadcast (i.e. the field content is ours, not
// a different call the user typed). Returns true if it actually changed.
const applyUdpCall = (raw: string): boolean => {
const call = String(raw ?? '').trim();
if (!call) return false;
const upper = call.toUpperCase();
const current = callsignValRef.current.trim().toUpperCase();
const prev = lastUdpCallRef.current;
lastUdpCallRef.current = upper; // remember this broadcast either way
if (current === upper) return false; // already shown → no-op
if (current !== '' && current !== prev) return false; // user typed a different call → leave it
onCallsignInput(call);
// External app jumped to a new station (DXHunter/WSJT/MSHV click): start a
// fresh recording for the new target instead of continuing the old take.
if (changed) restartRecordingForNewTarget();
return true;
};
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
// External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call)) restartRecordingForNewTarget();
});
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
const call = String(raw ?? '').trim();
if (!call) return;
const changed = call.toUpperCase() !== callsignValRef.current.trim().toUpperCase();
onCallsignInput(call);
if (changed) restartRecordingForNewTarget();
if (applyUdpCall(raw)) restartRecordingForNewTarget();
});
const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
@@ -2181,10 +2206,10 @@ export default function App() {
</Button>
)}
<Button
variant={showBandMap ? 'default' : 'outline'}
variant={activeTab === 'bandmap' ? 'default' : 'outline'}
size="sm"
onClick={() => setShowBandMap((v) => !v)}
title="Toggle band map (visible across all tabs)"
onClick={() => setActiveTab('bandmap')}
title="Open the Band Map tab (several bands side by side)"
className="h-8"
>
Band map
@@ -2443,10 +2468,9 @@ export default function App() {
)}
</div>{/* /entry + aside row */}
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
{compact ? null : <>
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[260px_1fr]' : 'grid-cols-[1fr_260px]') : 'grid-cols-[1fr]')}>
<div className="grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)] grid-cols-[1fr]">
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
<TabsList className="px-3 shrink-0">
@@ -2463,6 +2487,7 @@ export default function App() {
)}
</TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
{/* Not a tab — QRZ blocks embedding, so this opens the call's
QRZ.com page in the system browser. Styled like a trigger. */}
<button
@@ -2740,26 +2765,7 @@ export default function App() {
<ClusterGrid
rows={rendered as any}
spotStatus={spotStatus}
onSpotClick={(s) => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
tuneRigCAT(s.freq_hz, m);
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
}
// Set mode + flip the RST default (599↔59) for the new
// target — a plain setMode skipped the RST preset.
if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call);
// A POTA spot carries the park ref — pre-fill the POTA
// award reference (like the State→RAC auto-match) so it's
// logged without re-typing. n-fer refs (comma-separated)
// become one POTA@ entry each.
applySpotPOTA((s as any).pota_ref);
// New target from a clicked spot → fresh recording + reset timer.
if (s.dx_call.trim()) restartRecordingForNewTarget();
}}
onSpotClick={handleSpotClick}
/>
);
})()}
@@ -2952,35 +2958,45 @@ export default function App() {
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel onEditQSO={openEdit} />
</TabsContent>
{/* Band Map: several bands shown side-by-side (panadapter-style
strips). Pick bands with the chips; each strip is clickable to
tune the rig. */}
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
<span className="text-xs text-muted-foreground mr-1">Bands:</span>
{bands.map((b) => {
const on = bandMapBands.includes(b);
return (
<button key={b} type="button" onClick={() => toggleBandMapBand(b)}
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
{b}
</button>
);
})}
</div>
<div className="flex-1 min-h-0 flex gap-2 p-2 overflow-x-auto">
{bandMapBands.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Pick one or more bands above to show their band maps side by side.
</div>
) : bandMapBands.map((b) => (
<div key={b} className="w-[260px] shrink-0 min-h-0 border border-border rounded-lg overflow-hidden flex flex-col">
<BandMap
band={b}
spots={spots.filter((s) => s.band === b)}
spotStatus={spotStatus}
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick}
onClose={() => toggleBandMapBand(b)}
/>
</div>
))}
</div>
</TabsContent>
</Tabs>
</section>
{showBandMap && (
<div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden', bandMapSide === 'left' && 'order-first')}>
<BandMap
side={bandMapSide}
onToggleSide={toggleBandMapSide}
band={band}
spots={spots.filter((s) => s.band === band)}
spotStatus={spotStatus}
currentFreqHz={freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={(s) => {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
tuneRigCAT(s.freq_hz, m);
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
}
if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call);
applySpotPOTA((s as any).pota_ref);
if (s.dx_call.trim()) restartRecordingForNewTarget();
}}
onClose={() => setShowBandMap(false)}
/>
</div>
)}
</div>
</>}