up
This commit is contained in:
+146
-97
@@ -53,6 +53,8 @@ import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -416,8 +418,6 @@ export default function App() {
|
||||
return () => { cancelled = true; };
|
||||
}, [band]);
|
||||
const prefix = useMemo(() => computePrefix(callsign), [callsign]);
|
||||
// Bearing/distance from operator's home grid to the remote station —
|
||||
// shown live in the entry strip (SP azimuth) and Info tab (LP + dist).
|
||||
|
||||
// === Logbook list ===
|
||||
const [qsos, setQsos] = useState<QSO[]>([]);
|
||||
@@ -444,13 +444,21 @@ export default function App() {
|
||||
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
|
||||
// second while a recording is in progress.
|
||||
const [recSeconds, setRecSeconds] = useState(0);
|
||||
// Bumped on every (re)start so the timer resets even when `recording` was
|
||||
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
|
||||
const [recTick, setRecTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!recording) { setRecSeconds(0); return; }
|
||||
const start = Date.now();
|
||||
setRecSeconds(0);
|
||||
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [recording]);
|
||||
}, [recording, recTick]);
|
||||
// restartRecordingForNewTarget (re)starts the take for a new programmatic
|
||||
// target (clicked spot / external app via UDP) and resets the elapsed timer.
|
||||
const restartRecordingForNewTarget = () => {
|
||||
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||
};
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [filterCallsign, setFilterCallsign] = useState('');
|
||||
// Advanced filter builder (replaces the old band/mode dropdowns).
|
||||
@@ -473,7 +481,7 @@ export default function App() {
|
||||
const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500');
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 500;
|
||||
});
|
||||
useEffect(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
|
||||
useEffect(() => { writeUiPref('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
|
||||
|
||||
// === DX Cluster live state ===
|
||||
type ClusterSpot = {
|
||||
@@ -591,7 +599,7 @@ export default function App() {
|
||||
const toggleBandMapSide = useCallback(() => {
|
||||
setBandMapSide((s) => {
|
||||
const next = s === 'right' ? 'left' : 'right';
|
||||
localStorage.setItem('bandmap.side', next);
|
||||
writeUiPref('bandmap.side', next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -607,6 +615,8 @@ export default function App() {
|
||||
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
// Optional deep-link: which Preferences section to open. Cleared on
|
||||
// close so the next plain "Preferences" launch reverts to default.
|
||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||
@@ -654,11 +664,6 @@ export default function App() {
|
||||
// tell whether an incoming DX call actually changed anything.
|
||||
const callsignValRef = useRef('');
|
||||
useEffect(() => { callsignValRef.current = callsign; }, [callsign]);
|
||||
// True while the operator is typing in the Call field. A call change that
|
||||
// arrives while it's NOT focused is programmatic (clicked spot / external app
|
||||
// via UDP) → we (re)start the recording immediately; typed changes wait for
|
||||
// blur so we don't restart on every keystroke.
|
||||
const callFocusedRef = useRef(false);
|
||||
|
||||
// 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,
|
||||
@@ -681,6 +686,36 @@ export default function App() {
|
||||
});
|
||||
myCallRef.current = (station.callsign || '').toUpperCase();
|
||||
|
||||
// Bearing/distance from operator's grid to the DX — used by the entry-strip
|
||||
// azimuth, the Info tab, and the rotor compass. Grid-to-grid when both known;
|
||||
// else fall back to the DX lat/lon (cty.dat-only entities carry no grid).
|
||||
const dxPath = useMemo(() => {
|
||||
const byGrid = pathBetween(station.my_grid, grid);
|
||||
if (byGrid) return byGrid;
|
||||
const myLL = gridToLatLon(station.my_grid);
|
||||
if (myLL && details.lat != null && details.lon != null) {
|
||||
return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon });
|
||||
}
|
||||
return null;
|
||||
}, [station.my_grid, grid, details.lat, details.lon]);
|
||||
|
||||
// Effective antenna heading(s): the rotor azimuth, transformed by the
|
||||
// Ultrabeam pattern when one is active — reversed (180°) points opposite,
|
||||
// bidirectional radiates both ways, normal is the heading itself.
|
||||
const beamHeadings = useMemo<number[]>(() => {
|
||||
if (!(rotatorHeading.enabled && rotatorHeading.ok)) return [];
|
||||
const base = ((rotatorHeading.azimuth % 360) + 360) % 360;
|
||||
if (ubStatus.enabled && ubStatus.connected) {
|
||||
if (ubStatus.direction === 1) return [(base + 180) % 360];
|
||||
if (ubStatus.direction === 2) return [base, (base + 180) % 360];
|
||||
}
|
||||
return [base];
|
||||
}, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||
|
||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
|
||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||
// picked award references to the QSO field/extras each award actually reads.
|
||||
const awardFieldRef = useRef<Record<string, string>>({});
|
||||
@@ -966,10 +1001,18 @@ export default function App() {
|
||||
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();
|
||||
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();
|
||||
});
|
||||
const unsubRC = EventsOn('udp:remote_call', (call: string) => {
|
||||
if (call) onCallsignInput(String(call).trim());
|
||||
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();
|
||||
});
|
||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||
@@ -1118,8 +1161,12 @@ export default function App() {
|
||||
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
|
||||
const rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined;
|
||||
const now = new Date();
|
||||
const start = qsoStartedAt ?? now;
|
||||
const end = (locks.end && qsoEndedAt) ? qsoEndedAt : now;
|
||||
// Option: log TIME_ON = TIME_OFF (the moment the QSO completes). Useful
|
||||
// when you call a station for a long time — otherwise TIME_ON is frozen at
|
||||
// when you first entered the call (minutes early) and won't match LoTW.
|
||||
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
|
||||
const start = (startEqualsEnd && !locks.start) ? end : (qsoStartedAt ?? now);
|
||||
const payload: any = {
|
||||
callsign: callsign.trim().toUpperCase(),
|
||||
qso_date: start.toISOString(),
|
||||
@@ -1406,19 +1453,17 @@ export default function App() {
|
||||
// start is locked: the user is back-entering a past QSO and set a
|
||||
// specific time manually.
|
||||
setQsoStartedAt(new Date());
|
||||
// Begin the recording here too: a fast CW workflow (type the call then
|
||||
// hit Enter to log, exchanging with the WinKeyer) never blurs the call
|
||||
// field, so the blur-based start was missed. No-op if the recorder is off
|
||||
// or already running; the pre-roll covers the lead-in.
|
||||
QSOAudioBegin().then(setRecording).catch(() => {});
|
||||
} else if (isEmpty && !locks.start) {
|
||||
// Callsign wiped → user abandoned this QSO; reset the timer.
|
||||
setQsoStartedAt(null);
|
||||
}
|
||||
setCallsign(v);
|
||||
scheduleLookup(v);
|
||||
// Programmatic call change (clicked spot, or external app via UDP) for a new
|
||||
// non-empty target → (re)start the recording now, even if one was already
|
||||
// running for the previous contact. Typed changes (field focused) wait for
|
||||
// blur so we don't restart per keystroke.
|
||||
if (v.trim() !== '' && !callFocusedRef.current) {
|
||||
QSOAudioRestart().then(setRecording).catch(() => {});
|
||||
}
|
||||
}
|
||||
function markEdited(field: string) { userEditedRef.current.add(field); }
|
||||
|
||||
@@ -1640,11 +1685,10 @@ export default function App() {
|
||||
ref={callsignRef}
|
||||
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
|
||||
value={callsign}
|
||||
onFocus={() => { callFocusedRef.current = true; }}
|
||||
onChange={(e) => onCallsignInput(e.target.value)}
|
||||
// Start the QSO recording when leaving the callsign field (the pre-roll
|
||||
// covers the seconds before). No-op when the recorder is off.
|
||||
onBlur={() => { callFocusedRef.current = false; if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
|
||||
onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1703,13 +1747,13 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
const qthBlock = (
|
||||
<div className="flex flex-col flex-1 min-w-[90px]"><Label className="mb-1 h-3.5">QTH</Label>
|
||||
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">QTH</Label>
|
||||
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
|
||||
</div>
|
||||
);
|
||||
const gridBlock = (
|
||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">Grid</Label>
|
||||
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">Grid</Label>
|
||||
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
</div>
|
||||
);
|
||||
// Compact-strip Country (stacked label) + a narrow Comment.
|
||||
@@ -1899,9 +1943,28 @@ export default function App() {
|
||||
|
||||
<Menubar menus={menus} onAction={handleMenu} />
|
||||
|
||||
<div className="flex items-center justify-center gap-2 font-mono">
|
||||
<div className="relative flex items-center justify-center gap-2 font-mono">
|
||||
{/* Transient toast / error, in the empty band between the menu and
|
||||
the frequency (left of centre). Single-line + truncated. */}
|
||||
{(error || toast) && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20 flex items-center max-w-[min(42vw,560px)] font-sans">
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/10 text-destructive px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span className="truncate" title={error}>{error}</span>
|
||||
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
||||
<Satellite className="size-3.5 shrink-0" />
|
||||
<span className="truncate" title={toast}>{toast}</span>
|
||||
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3" /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-end leading-none">
|
||||
<span className="text-[22px] font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
||||
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
||||
{catState.split && rxFreqMhz && (
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5">
|
||||
<span className="text-rose-600 font-semibold mr-1">RX</span>
|
||||
@@ -1931,14 +1994,7 @@ export default function App() {
|
||||
both directly clickable, plus an always-visible Stop. The
|
||||
old Shift/Ctrl shortcuts were not discoverable enough. */}
|
||||
{(() => {
|
||||
// Prefer grid-to-grid; fall back to lat/lon when the DX has no
|
||||
// grid but a known location (e.g. cty.dat-only entities like
|
||||
// Svalbard → no QRZ grid, but cty.dat gives coordinates).
|
||||
const myLL = gridToLatLon(station.my_grid);
|
||||
const p = pathBetween(station.my_grid, grid)
|
||||
?? (myLL && details.lat != null && details.lon != null
|
||||
? pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon })
|
||||
: null);
|
||||
const p = dxPath;
|
||||
const disabled = !p;
|
||||
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
|
||||
return (
|
||||
@@ -1986,6 +2042,25 @@ export default function App() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
|
||||
{ubStatus.enabled && (
|
||||
<div className="inline-flex items-center rounded-full border border-emerald-300 bg-emerald-50 overflow-hidden text-[10px] font-semibold ml-1"
|
||||
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
|
||||
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
||||
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-muted-foreground/40')} />
|
||||
</button>
|
||||
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
|
||||
<button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
|
||||
onClick={() => { SetUltrabeamDirection(o.d).then(() => setUbStatus((s) => ({ ...s, direction: o.d }))).catch((e: any) => setError(String(e?.message ?? e))); }}
|
||||
className={cn('px-1.5 py-0.5 transition-colors',
|
||||
ubStatus.direction === o.d ? 'bg-emerald-600 text-white' : 'text-emerald-800 hover:bg-emerald-100',
|
||||
!ubStatus.connected && 'opacity-40 cursor-default')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voice keyer (DVK) + CW keyer (WinKeyer) quick status/access. */}
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<button
|
||||
@@ -2009,13 +2084,25 @@ export default function App() {
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
wkStatus.busy ? 'border-amber-300 bg-amber-100 text-amber-800'
|
||||
: wkEnabled && wkStatus.connected ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||
: wkEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Zap className="size-4" />
|
||||
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-amber-500 animate-pulse" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||
title={showRotor ? 'Rotor compass — shown · click to hide' : 'Rotor compass · click to show'}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
showRotor ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Compass className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||
@@ -2100,24 +2187,6 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || toast) && (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 max-w-md">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 text-destructive px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||
<pre className="flex-1 font-sans whitespace-pre-wrap m-0 leading-snug">{error}</pre>
|
||||
<button className="ml-1 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
)}
|
||||
{toast && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<Satellite className="size-4 shrink-0" />
|
||||
<span>{toast}</span>
|
||||
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* "You have been spotted" banner — shows when our own callsign appears
|
||||
in a cluster spot (Log4OM-style). Floated as a bottom-center overlay
|
||||
@@ -2148,7 +2217,7 @@ export default function App() {
|
||||
className={cn('bg-card shadow-sm border-border',
|
||||
compact
|
||||
? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
|
||||
: 'flex flex-col gap-2 px-3 py-2 flex-1 min-w-[560px] max-w-[760px] border rounded-lg')}
|
||||
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[760px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
||||
e.preventDefault();
|
||||
@@ -2243,10 +2312,25 @@ export default function App() {
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url) && (
|
||||
{!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
||||
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
||||
rotator is configured or a DX bearing exists. */}
|
||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||
<div className="w-[186px] shrink-0 min-h-0">
|
||||
<RotorCompass
|
||||
bearing={dxPath?.bearingShort ?? null}
|
||||
headings={beamHeadings}
|
||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
||||
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
|
||||
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{dvkEnabled && (
|
||||
<div className="flex-1 min-w-0 min-h-0">
|
||||
<div className="w-[264px] shrink-0 min-h-0">
|
||||
<DvkPanel
|
||||
messages={dvkMsgs}
|
||||
status={dvkStat}
|
||||
@@ -2257,7 +2341,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{wkEnabled && (
|
||||
<div className="flex-1 min-w-0 min-h-0">
|
||||
<div className="w-[500px] shrink-0 min-h-0">
|
||||
<WinkeyerPanel
|
||||
status={wkStatus}
|
||||
ports={wkPorts}
|
||||
@@ -2621,8 +2705,8 @@ export default function App() {
|
||||
// logged without re-typing. n-fer refs (comma-separated)
|
||||
// become one POTA@ entry each.
|
||||
applySpotPOTA((s as any).pota_ref);
|
||||
// (recording (re)starts inside onCallsignInput — the call
|
||||
// changed programmatically with the field unfocused.)
|
||||
// New target from a clicked spot → fresh recording + reset timer.
|
||||
if (s.dx_call.trim()) restartRecordingForNewTarget();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -2809,6 +2893,7 @@ export default function App() {
|
||||
toGrid={grid}
|
||||
fromLabel={station.callsign}
|
||||
toLabel={callsign}
|
||||
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -2839,7 +2924,7 @@ export default function App() {
|
||||
if (m) applyModeFromSpot(m);
|
||||
onCallsignInput(s.dx_call);
|
||||
applySpotPOTA((s as any).pota_ref);
|
||||
// (recording (re)starts inside onCallsignInput — programmatic call change)
|
||||
if (s.dx_call.trim()) restartRecordingForNewTarget();
|
||||
}}
|
||||
onClose={() => setShowBandMap(false)}
|
||||
/>
|
||||
@@ -2888,42 +2973,6 @@ export default function App() {
|
||||
disabled={!rotatorHeading.enabled}
|
||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||
/>
|
||||
{ubStatus.enabled && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1 px-2 h-5 rounded border border-border text-[11px]"
|
||||
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam connected') : 'Ultrabeam: connecting…'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 cursor-pointer hover:text-foreground text-muted-foreground"
|
||||
onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }}
|
||||
title="Antenna settings"
|
||||
>
|
||||
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-muted-foreground/40')} />
|
||||
Ant
|
||||
</button>
|
||||
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: '180°' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
|
||||
<button
|
||||
key={o.d}
|
||||
type="button"
|
||||
disabled={!ubStatus.connected}
|
||||
title={o.t}
|
||||
onClick={() => {
|
||||
SetUltrabeamDirection(o.d)
|
||||
.then(() => setUbStatus((s) => ({ ...s, direction: o.d })))
|
||||
.catch((e: any) => setError(String(e?.message ?? e)));
|
||||
}}
|
||||
className={cn(
|
||||
'px-1 rounded text-[10px] font-medium transition-colors',
|
||||
ubStatus.direction === o.d ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted',
|
||||
!ubStatus.connected && 'opacity-40 cursor-default',
|
||||
)}
|
||||
>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
</footer>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user