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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user