up
This commit is contained in:
+193
-138
@@ -16,6 +16,7 @@ import {
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||
RefreshCtyDat,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||
OpenExternalURL,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
@@ -306,6 +307,7 @@ export default function App() {
|
||||
// CAT — receives live rig state via Wails events.
|
||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
||||
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||
// in Preferences > Hardware > CAT interface.
|
||||
@@ -579,6 +581,8 @@ export default function App() {
|
||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
|
||||
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'>(
|
||||
@@ -747,6 +751,17 @@ export default function App() {
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// Poll the Ultrabeam antenna for its connection + pattern direction.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 3000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// RX band auto-follows the TX band (only differs for cross-band work).
|
||||
useEffect(() => { setBandRx(band); }, [band]);
|
||||
|
||||
@@ -2438,142 +2453,7 @@ export default function App() {
|
||||
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
|
||||
</div>
|
||||
|
||||
{/* Row 2: filters */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border/60 flex-wrap text-xs">
|
||||
<Input
|
||||
className="w-32 h-7 text-xs font-mono uppercase"
|
||||
placeholder="Search call…"
|
||||
value={clusterSearch}
|
||||
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
|
||||
/>
|
||||
<span className="text-muted-foreground">Bands:</span>
|
||||
{bands.map((b) => {
|
||||
const on = clusterBands.has(b);
|
||||
return (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
onClick={() => setClusterBands((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(b)) n.delete(b); else n.add(b);
|
||||
return n;
|
||||
})}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors',
|
||||
on
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{clusterBands.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClusterBands(new Set())}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground underline"
|
||||
title="Clear band filter"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClusterLockBand((v) => !v)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
|
||||
clusterLockBand
|
||||
? 'bg-amber-100 text-amber-800 border-amber-300'
|
||||
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
|
||||
)}
|
||||
title="Only show spots on the entry strip's current band"
|
||||
>
|
||||
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
|
||||
Lock band ({band})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClusterLockMode((v) => !v)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
|
||||
clusterLockMode
|
||||
? 'bg-amber-100 text-amber-800 border-amber-300'
|
||||
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
|
||||
)}
|
||||
title="Only show spots whose mode matches the entry strip"
|
||||
>
|
||||
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
|
||||
Lock mode ({mode})
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
{([
|
||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
|
||||
]).map((s) => {
|
||||
const on = clusterStatusFilter.has(s.k);
|
||||
return (
|
||||
<button
|
||||
key={s.k}
|
||||
type="button"
|
||||
onClick={() => setClusterStatusFilter((cur) => {
|
||||
const n = new Set(cur);
|
||||
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
|
||||
return n;
|
||||
})}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
|
||||
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<span className="text-muted-foreground">Mode:</span>
|
||||
{([
|
||||
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
|
||||
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
|
||||
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
|
||||
]).map((s) => {
|
||||
const on = clusterModeFilter.has(s.k);
|
||||
return (
|
||||
<button
|
||||
key={s.k}
|
||||
type="button"
|
||||
onClick={() => setClusterModeFilter((cur) => {
|
||||
const n = new Set(cur);
|
||||
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
|
||||
return n;
|
||||
})}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
|
||||
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
|
||||
Group
|
||||
</label>
|
||||
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-32 h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">All sources</SelectItem>
|
||||
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Filters moved to the right-side panel (see below). */}
|
||||
|
||||
{(() => {
|
||||
// Apply every filter. `bandsActive` is the band set the
|
||||
@@ -2601,6 +2481,18 @@ export default function App() {
|
||||
const st = spotStatus[k]?.status || '';
|
||||
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
|
||||
}
|
||||
// Hide worked: drop spots whose exact call is already worked,
|
||||
// or whose entity+band+mode slot is already in the log. The
|
||||
// status is resolved asynchronously, so we also hide spots
|
||||
// whose status isn't known yet — otherwise a worked spot would
|
||||
// flash in (no status) then vanish once it resolves. A new
|
||||
// spot waits for its status, then appears only if not worked.
|
||||
if (clusterHideWorked) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const e = spotStatus[k];
|
||||
if (!e) return false;
|
||||
if (e.worked_call || e.status === 'worked') return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
||||
@@ -2678,8 +2570,135 @@ export default function App() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>{/* /left column */}
|
||||
{/* BandMap moved to a global side panel below — toggle is
|
||||
now in the topbar, visible on every tab. */}
|
||||
|
||||
{/* Right-side filter panel (Log4OM style) */}
|
||||
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
|
||||
<div className="px-2.5 py-2 border-b border-border/60 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</div>
|
||||
<div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
|
||||
{/* Callsign search */}
|
||||
<Input
|
||||
className="h-7 text-xs font-mono uppercase"
|
||||
placeholder="Search call…"
|
||||
value={clusterSearch}
|
||||
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
|
||||
/>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
|
||||
Hide worked
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
|
||||
Group duplicates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Band filter — multi-select listbox */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClusterLockBand((v) => !v)}
|
||||
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
|
||||
clusterLockBand ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||
title="Lock to the entry strip's current band"
|
||||
>
|
||||
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
|
||||
</button>
|
||||
{clusterBands.size > 0 && (
|
||||
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
|
||||
{bands.map((b) => {
|
||||
const on = clusterBands.has(b);
|
||||
return (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
onClick={() => setClusterBands((s) => { const n = new Set(s); if (n.has(b)) n.delete(b); else n.add(b); return n; })}
|
||||
className={cn('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
|
||||
on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode lock */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClusterLockMode((v) => !v)}
|
||||
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
|
||||
clusterLockMode ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||
title="Only show spots whose mode matches the entry strip"
|
||||
>
|
||||
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
|
||||
</button>
|
||||
|
||||
{/* Status filter */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
|
||||
]).map((s) => {
|
||||
const on = clusterStatusFilter.has(s.k);
|
||||
return (
|
||||
<button key={s.k} type="button"
|
||||
onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
|
||||
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode filter */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{([
|
||||
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
|
||||
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
|
||||
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
|
||||
]).map((s) => {
|
||||
const on = clusterModeFilter.has(s.k);
|
||||
return (
|
||||
<button key={s.k} type="button"
|
||||
onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
|
||||
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
|
||||
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">All sources</SelectItem>
|
||||
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
@@ -2779,6 +2798,42 @@ 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>
|
||||
);
|
||||
|
||||
@@ -125,17 +125,18 @@ const COL_CATALOG: ColEntry[] = [
|
||||
const isNew = status?.status === 'new';
|
||||
const workedCall = !!status?.worked_call;
|
||||
const style: any = {
|
||||
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
|
||||
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
|
||||
};
|
||||
if (isNew) {
|
||||
// New DXCC entity — soft rose pill, no clashing border.
|
||||
style.backgroundColor = '#ffe4e6';
|
||||
style.color = '#9f1239';
|
||||
style.border = '1px solid #fda4af';
|
||||
style.color = '#be123c';
|
||||
style.padding = '1px 7px';
|
||||
style.borderRadius = 4;
|
||||
} else if (workedCall) {
|
||||
style.color = '#0369a1';
|
||||
style.color = '#0369a1'; // already worked this exact call
|
||||
} else {
|
||||
style.color = '#b8410c';
|
||||
style.color = '#b8410c'; // new call in a worked entity
|
||||
}
|
||||
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
|
||||
},
|
||||
@@ -164,15 +165,12 @@ const COL_CATALOG: ColEntry[] = [
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const newBand = status?.status === 'new-band';
|
||||
const bg = newBand ? '#fde68a' : '#f0d9a8';
|
||||
const fg = newBand ? '#92400e' : '#7a4a14';
|
||||
return p.value
|
||||
? <span
|
||||
style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
||||
border: newBand ? '1px solid #f59e0b' : undefined,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 12,
|
||||
fontWeight: newBand ? 700 : 400,
|
||||
...(newBand ? { backgroundColor: '#fde68a', color: '#92400e', padding: '1px 7px', borderRadius: 4 } : {}),
|
||||
}}
|
||||
title={newBand ? 'NEW BAND for this entity' : undefined}
|
||||
>{p.value}</span>
|
||||
@@ -190,15 +188,12 @@ const COL_CATALOG: ColEntry[] = [
|
||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||
];
|
||||
const newSlot = status?.status === 'new-slot';
|
||||
const bg = newSlot ? '#fef08a' : '#d1fae5';
|
||||
const fg = newSlot ? '#854d0e' : '#047857';
|
||||
return p.value
|
||||
? <span
|
||||
style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
||||
border: newSlot ? '1px solid #eab308' : undefined,
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 12,
|
||||
fontWeight: newSlot ? 700 : 400,
|
||||
...(newSlot ? { backgroundColor: '#fef08a', color: '#854d0e', padding: '1px 7px', borderRadius: 4 } : {}),
|
||||
}}
|
||||
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
|
||||
>{p.value}</span>
|
||||
|
||||
@@ -67,7 +67,39 @@ interface Props {
|
||||
|
||||
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
|
||||
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
|
||||
const PROP_MODES: { value: string; label: string }[] = [
|
||||
{ value: 'NONE', label: '—' },
|
||||
{ value: 'AS', label: 'Aircraft Scatter' },
|
||||
{ value: 'AUR', label: 'Aurora' },
|
||||
{ value: 'AUE', label: 'Aurora-E' },
|
||||
{ value: 'BS', label: 'Back Scatter' },
|
||||
{ value: 'ECH', label: 'EchoLink' },
|
||||
{ value: 'EME', label: 'Earth-Moon-Earth' },
|
||||
{ value: 'ES', label: 'Sporadic E' },
|
||||
{ value: 'FAI', label: 'Field Aligned Irregularities' },
|
||||
{ value: 'F2', label: 'F2 Reflection' },
|
||||
{ value: 'GWAVE', label: 'Ground Wave' },
|
||||
{ value: 'INTERNET', label: 'Internet-assisted' },
|
||||
{ value: 'ION', label: 'Ionoscatter' },
|
||||
{ value: 'IRL', label: 'IRLP' },
|
||||
{ value: 'LOS', label: 'Line of Sight' },
|
||||
{ value: 'MS', label: 'Meteor Scatter' },
|
||||
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
|
||||
{ value: 'RS', label: 'Rain Scatter' },
|
||||
{ value: 'SAT', label: 'Satellite' },
|
||||
{ value: 'TEP', label: 'Trans-Equatorial' },
|
||||
{ value: 'TR', label: 'Tropospheric Ducting' },
|
||||
];
|
||||
|
||||
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
||||
const ANT_PATHS: { value: string; label: string }[] = [
|
||||
{ value: 'NONE', label: '—' },
|
||||
{ value: 'S', label: 'Short Path' },
|
||||
{ value: 'L', label: 'Long Path' },
|
||||
{ value: 'G', label: 'Grayline' },
|
||||
{ value: 'O', label: 'Other' },
|
||||
];
|
||||
|
||||
function numOrUndef(v: string): number | undefined {
|
||||
if (v === '') return undefined;
|
||||
@@ -76,9 +108,9 @@ function numOrUndef(v: string): number | undefined {
|
||||
}
|
||||
|
||||
// Compact field helper to keep the JSX dense.
|
||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
|
||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
|
||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
||||
<Label className="mb-1">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
@@ -217,26 +249,31 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
||||
<Field label="Elevation (°)">
|
||||
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Ant. path">
|
||||
<Input value={details.ant_path} placeholder="S / L / G" onChange={(e) => onChange({ ant_path: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Propagation">
|
||||
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === 'NONE' ? '—' : p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="TX power (W)">
|
||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||
</Field>
|
||||
<div className="flex items-end pb-1.5">
|
||||
<div className="col-span-3 flex items-end pb-1.5">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||
Satellite mode
|
||||
</label>
|
||||
</div>
|
||||
<Field label="Ant. path" span={2}>
|
||||
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Propagation" span={4}>
|
||||
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Rig" span={3}>
|
||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
</Field>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GetCATSettings, SaveCATSettings,
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||
@@ -193,7 +194,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
||||
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna' },
|
||||
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
|
||||
],
|
||||
},
|
||||
@@ -368,6 +369,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
// Ultrabeam antenna (TCP) settings.
|
||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number }>({
|
||||
enabled: false, host: '', port: 23,
|
||||
});
|
||||
const [ubTesting, setUbTesting] = useState(false);
|
||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
// WinKeyer CW keyer settings + macro editor.
|
||||
type WKMac = { label: string; text: string };
|
||||
type WKSettings = {
|
||||
@@ -583,6 +591,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
await reloadClusterServers();
|
||||
setCatCfg(c);
|
||||
setRotator(r);
|
||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||
setBackupCfg(b as any);
|
||||
setQslDefaults(qd as any);
|
||||
setExtSvc(es as any);
|
||||
@@ -751,6 +760,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
await SaveLookupSettings(lookup as any);
|
||||
await SaveCATSettings(catCfg as any);
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveUltrabeamSettings(ultrabeam as any);
|
||||
await SaveWinkeyerSettings(wk as any);
|
||||
await SaveAudioSettings(audioCfg as any);
|
||||
await SaveEmailSettings(emailCfg as any);
|
||||
@@ -1499,6 +1509,74 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function testUltrabeam() {
|
||||
setUbTesting(true);
|
||||
setUbTest(null);
|
||||
try {
|
||||
await TestUltrabeam(ultrabeam as any);
|
||||
setUbTest({ ok: true, msg: 'Connected — the Ultrabeam responded with a status frame.' });
|
||||
} catch (e: any) {
|
||||
setUbTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
setUbTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function UltrabeamPanel() {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Antenna (Ultrabeam)"
|
||||
hint="OpsLog talks to the Ultrabeam controller over TCP — typically via an RS232↔Ethernet adapter. Enter its IP address and port; the pattern direction (Normal / 180° / Bidirectional) is then controlled from the status bar."
|
||||
/>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
||||
Enable Ultrabeam control
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host / IP</Label>
|
||||
<Input
|
||||
value={ultrabeam.host ?? ''}
|
||||
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
|
||||
placeholder="192.168.1.50"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>TCP port</Label>
|
||||
<Input
|
||||
type="number" min={1} max={65535}
|
||||
value={ultrabeam.port}
|
||||
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !ultrabeam.host.trim()}>
|
||||
{ubTesting ? 'Connecting…' : 'Test connection'}
|
||||
</Button>
|
||||
</div>
|
||||
{ubTest && (
|
||||
<div className={cn(
|
||||
'text-xs rounded-md p-2.5 border',
|
||||
ubTest.ok
|
||||
? 'bg-emerald-50 text-emerald-800 border-emerald-200'
|
||||
: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||
)}>
|
||||
{ubTest.msg}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Once enabled, the antenna's direction control (Normal / 180° / Bidirectional) appears in the bottom status bar, next to CAT and Rotator. Changing the direction re-tunes the elements at the current frequency.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RotatorPanel() {
|
||||
return (
|
||||
<>
|
||||
@@ -2715,7 +2793,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{audioCfg.ptt_method !== 'none' && (
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT().catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
||||
Test PTT
|
||||
</Button>
|
||||
)}
|
||||
@@ -2926,7 +3004,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
cat: CATPanel,
|
||||
rotator: RotatorPanel,
|
||||
winkeyer: WinkeyerPanel,
|
||||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||||
antenna: UltrabeamPanel,
|
||||
audio: AudioPanel,
|
||||
};
|
||||
|
||||
|
||||
Vendored
+13
-1
@@ -169,6 +169,10 @@ export function GetStationSettings():Promise<main.StationSettings>;
|
||||
|
||||
export function GetUIPref(arg1:string):Promise<string>;
|
||||
|
||||
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||
|
||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||
|
||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
@@ -293,6 +297,8 @@ export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||
|
||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
|
||||
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||
|
||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||
@@ -317,6 +323,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
@@ -329,12 +337,16 @@ export function TestLoTWUpload():Promise<string>;
|
||||
|
||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||
|
||||
export function TestPTT():Promise<void>;
|
||||
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function TestQRZUpload():Promise<string>;
|
||||
|
||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function UltrabeamRetract():Promise<void>;
|
||||
|
||||
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
|
||||
|
||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
@@ -310,6 +310,14 @@ export function GetUIPref(arg1) {
|
||||
return window['go']['main']['App']['GetUIPref'](arg1);
|
||||
}
|
||||
|
||||
export function GetUltrabeamSettings() {
|
||||
return window['go']['main']['App']['GetUltrabeamSettings']();
|
||||
}
|
||||
|
||||
export function GetUltrabeamStatus() {
|
||||
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||
}
|
||||
|
||||
export function GetWinkeyerSettings() {
|
||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||
}
|
||||
@@ -558,6 +566,10 @@ export function SaveUDPIntegration(arg1) {
|
||||
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
||||
}
|
||||
|
||||
export function SaveUltrabeamSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveUltrabeamSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveWinkeyerSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
||||
}
|
||||
@@ -606,6 +618,10 @@ export function SetUIPref(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetUltrabeamDirection(arg1) {
|
||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||
}
|
||||
|
||||
export function SwitchCATRig(arg1) {
|
||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||
}
|
||||
@@ -630,8 +646,8 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function TestPTT() {
|
||||
return window['go']['main']['App']['TestPTT']();
|
||||
export function TestPTT(arg1) {
|
||||
return window['go']['main']['App']['TestPTT'](arg1);
|
||||
}
|
||||
|
||||
export function TestQRZUpload() {
|
||||
@@ -642,6 +658,14 @@ export function TestRotator(arg1) {
|
||||
return window['go']['main']['App']['TestRotator'](arg1);
|
||||
}
|
||||
|
||||
export function TestUltrabeam(arg1) {
|
||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||
}
|
||||
|
||||
export function UltrabeamRetract() {
|
||||
return window['go']['main']['App']['UltrabeamRetract']();
|
||||
}
|
||||
|
||||
export function UpdateAwardReferenceList(arg1) {
|
||||
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
|
||||
}
|
||||
|
||||
@@ -1282,6 +1282,44 @@ export namespace main {
|
||||
this.my_pota_ref = source["my_pota_ref"];
|
||||
}
|
||||
}
|
||||
export class UltrabeamSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UltrabeamSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
}
|
||||
}
|
||||
export class UltrabeamStatusInfo {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
direction: number;
|
||||
frequency: number;
|
||||
band: number;
|
||||
moving: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UltrabeamStatusInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.connected = source["connected"];
|
||||
this.direction = source["direction"];
|
||||
this.frequency = source["frequency"];
|
||||
this.band = source["band"];
|
||||
this.moving = source["moving"];
|
||||
}
|
||||
}
|
||||
export class WKMacro {
|
||||
label: string;
|
||||
text: string;
|
||||
|
||||
Reference in New Issue
Block a user