update
This commit is contained in:
+542
-6
@@ -6,7 +6,7 @@ import {
|
||||
|
||||
import {
|
||||
AddQSO, ListQSO, CountQSO,
|
||||
OpenADIFFile, ImportADIF,
|
||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus,
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
RefreshCtyDat,
|
||||
RotatorGoTo, RotatorStop,
|
||||
OpenExternalURL,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
ListClusterServers, ClusterSpotStatuses,
|
||||
GetCATSettings,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
|
||||
@@ -27,6 +29,8 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
import { CallHistoryPanel } from '@/components/CallHistoryPanel';
|
||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
|
||||
@@ -81,6 +85,9 @@ function fmtFreq(hz?: number): string {
|
||||
if (!hz) return '';
|
||||
return (hz / 1_000_000).toFixed(4);
|
||||
}
|
||||
// cleanSpotter / inferSpotMode / spotStatusKey live in lib/spot.ts so
|
||||
// the BandMap component reads from the same canonical source — keeps
|
||||
// "CW spot looks like CW everywhere" honest.
|
||||
function fmtHMSUTC(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
|
||||
@@ -334,6 +341,59 @@ export default function App() {
|
||||
const [filterMode, setFilterMode] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('recent');
|
||||
|
||||
// === DX Cluster live state ===
|
||||
type ClusterSpot = {
|
||||
source_id: number;
|
||||
source_name: string;
|
||||
spotter: string;
|
||||
dx_call: string;
|
||||
freq_khz: number;
|
||||
freq_hz: number;
|
||||
band?: string;
|
||||
comment?: string;
|
||||
locator?: string;
|
||||
time_utc?: string;
|
||||
received_at: string;
|
||||
raw: string;
|
||||
};
|
||||
type ServerStatus = {
|
||||
server_id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
||||
login?: string;
|
||||
error?: string;
|
||||
spots_count?: number;
|
||||
retries?: number;
|
||||
};
|
||||
const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
|
||||
const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]);
|
||||
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
||||
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
||||
const SPOTS_CAP = 1000;
|
||||
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
||||
const [clusterGroup, setClusterGroup] = useState(true);
|
||||
const [clusterCmd, setClusterCmd] = useState('');
|
||||
// Multi-band filter: empty set = all bands. The user toggles chips.
|
||||
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
|
||||
// Lock-to-entry: when on, the band filter follows the entry's current
|
||||
// band and the mode filter follows the entry's current mode.
|
||||
const [clusterLockBand, setClusterLockBand] = useState(false);
|
||||
const [clusterLockMode, setClusterLockMode] = useState(false);
|
||||
// Status filter chips. Empty set = show every status (including
|
||||
// already-worked). Otherwise only matching spots pass.
|
||||
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
|
||||
const [clusterSearch, setClusterSearch] = useState('');
|
||||
const [showBandMap, setShowBandMap] = useState(false);
|
||||
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
|
||||
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
|
||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||
// different slots don't share the same colour.
|
||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string }>>({});
|
||||
|
||||
// === Modals ===
|
||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
|
||||
@@ -348,6 +408,7 @@ export default function App() {
|
||||
|
||||
// === ADIF ===
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
||||
const [importDupsOpen, setImportDupsOpen] = useState(false);
|
||||
@@ -495,6 +556,64 @@ export default function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Cluster live wiring: hydrate per-server status + saved server list,
|
||||
// then subscribe to push events.
|
||||
async function reloadClusterMeta() {
|
||||
try {
|
||||
const [st, list] = await Promise.all([GetClusterStatus(), ListClusterServers()]);
|
||||
setClusterServerStatuses((st ?? []) as ServerStatus[]);
|
||||
setClusterServers(((list ?? []) as any[]).map((s) => ({
|
||||
id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
|
||||
})));
|
||||
} catch {}
|
||||
}
|
||||
useEffect(() => {
|
||||
reloadClusterMeta();
|
||||
EventsOn('cluster:state', (sts: ServerStatus[]) => setClusterServerStatuses(sts ?? []));
|
||||
EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
setSpots((arr) => {
|
||||
const next = [sp, ...arr];
|
||||
return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
|
||||
});
|
||||
});
|
||||
return () => { EventsOff('cluster:state'); EventsOff('cluster:spot'); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Resolve slot status for any spot we haven't seen yet — debounced so we
|
||||
// don't hammer the backend at firehose rate. The mode passed to the
|
||||
// backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the
|
||||
// band-plan fallback, NOT just digital watering-hole detection — that's
|
||||
// how CW spots get correctly classified instead of being labelled
|
||||
// "new-slot" because the lookup key carried mode="".
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(async () => {
|
||||
const unknown: { call: string; band: string; mode: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of spots) {
|
||||
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
if (seen.has(k) || spotStatus[k]) continue;
|
||||
seen.add(k);
|
||||
unknown.push({ call: s.dx_call, band: s.band ?? '', mode });
|
||||
}
|
||||
if (unknown.length === 0) return;
|
||||
try {
|
||||
const res = await ClusterSpotStatuses(unknown as any);
|
||||
setSpotStatus((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const r of res) {
|
||||
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
|
||||
next[k] = { status: r.status ?? '', country: r.country };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
}, 400);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [spots]);
|
||||
|
||||
async function save() {
|
||||
if (!callsign.trim()) { setError('Callsign required'); return; }
|
||||
setSaving(true); setError('');
|
||||
@@ -692,6 +811,25 @@ export default function App() {
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function exportAdif() {
|
||||
if (exporting) return;
|
||||
setError('');
|
||||
try {
|
||||
const path = await SaveADIFFile();
|
||||
if (!path) return;
|
||||
setExporting(true);
|
||||
const res = await ExportADIF(path);
|
||||
// Reuse the error banner area for a brief success note (4s auto-dismiss).
|
||||
const msg = `ADIF exported: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`;
|
||||
setError(msg);
|
||||
setTimeout(() => setError((e) => e === msg ? '' : e), 4000);
|
||||
} catch (e: any) {
|
||||
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
const path = pendingImportPath;
|
||||
if (!path || importing) return;
|
||||
@@ -714,7 +852,7 @@ export default function App() {
|
||||
const menus: Menu[] = useMemo(() => [
|
||||
{ name: 'file', label: 'File', items: [
|
||||
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
|
||||
{ type: 'item', label: 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: true },
|
||||
{ type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
|
||||
{ type: 'separator' },
|
||||
@@ -743,11 +881,12 @@ export default function App() {
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About HamLog', action: 'help.about', disabled: true },
|
||||
]},
|
||||
], [total, selectedId, ctyRefreshing]);
|
||||
], [total, selectedId, ctyRefreshing, exporting]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
case 'file.import': importAdif(); break;
|
||||
case 'file.export': exportAdif(); break;
|
||||
case 'file.deleteall': setShowDeleteAll(true); break;
|
||||
case 'view.refresh': refresh(); break;
|
||||
case 'view.clearfilters': setFilterCallsign(''); setFilterBand(''); setFilterMode(''); break;
|
||||
@@ -973,6 +1112,15 @@ export default function App() {
|
||||
<div className="font-bold text-[15px] leading-none">{total.toLocaleString('en-US')}</div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">Total QSOs</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={showBandMap ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowBandMap((v) => !v)}
|
||||
title="Toggle band map (visible across all tabs)"
|
||||
className="h-8"
|
||||
>
|
||||
Band map
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -1263,8 +1411,8 @@ export default function App() {
|
||||
onChange={updateDetails}
|
||||
/>
|
||||
|
||||
{/* ===== LOWER: tabs+table | call history ===== */}
|
||||
<div className="grid grid-cols-[1fr_360px] gap-2.5 p-2.5 flex-1 min-h-0">
|
||||
{/* ===== LOWER: tabs+table | call history | (optional) band map ===== */}
|
||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0', showBandMap ? 'grid-cols-[1fr_360px_260px]' : 'grid-cols-[1fr_360px]')}>
|
||||
<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">
|
||||
<TabsList className="px-3 shrink-0">
|
||||
@@ -1412,7 +1560,372 @@ export default function App() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{(['main','cluster','awards','propagation'] as const).map((t) => (
|
||||
<TabsContent value="cluster" className="mt-0 flex flex-row min-h-0 flex-1">
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
{/* Row 1: actions + per-server pills */}
|
||||
<div className="flex items-center gap-2 px-2.5 pt-2.5 flex-wrap">
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={async () => { await ConnectAllClusters().catch((e) => setError(String(e?.message ?? e))); await reloadClusterMeta(); }}
|
||||
>
|
||||
Connect all
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterMeta(); }}
|
||||
>
|
||||
Disconnect all
|
||||
</Button>
|
||||
{clusterServerStatuses.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
No active sessions — configure clusters in Settings → DX Cluster.
|
||||
</span>
|
||||
)}
|
||||
{clusterServerStatuses.map((s) => {
|
||||
const isMaster = clusterServers
|
||||
.filter((x) => x.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
|
||||
return (
|
||||
<span
|
||||
key={s.server_id}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
|
||||
s.state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
|
||||
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
|
||||
s.state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
|
||||
'bg-muted text-muted-foreground border-border',
|
||||
)}
|
||||
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
|
||||
>
|
||||
{isMaster && <span className="text-amber-600" title="Master (commands go here)">★</span>}
|
||||
{s.name}
|
||||
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1" />
|
||||
<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="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>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{(() => {
|
||||
// Apply every filter. `bandsActive` is the band set the
|
||||
// user clicked, OR the entry's locked band when Lock band
|
||||
// is on. Mode lock compares the spot's inferred mode to
|
||||
// the entry's current one.
|
||||
const bandsActive = clusterLockBand
|
||||
? new Set([band])
|
||||
: clusterBands;
|
||||
const search = clusterSearch.trim().toUpperCase();
|
||||
let list = spots.filter((s) => {
|
||||
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
|
||||
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
|
||||
if (search && !s.dx_call.includes(search)) return false;
|
||||
if (clusterLockMode) {
|
||||
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
// Treat empty inferred mode as wildcard so we don't
|
||||
// hide perfectly good spots just because the comment
|
||||
// was ambiguous.
|
||||
if (spotMode && mode && spotMode !== mode) return false;
|
||||
}
|
||||
if (clusterStatusFilter.size > 0) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const st = spotStatus[k]?.status || '';
|
||||
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
||||
if (clusterGroup) {
|
||||
const seen = new Map<string, ClusterSpot & { repeats: number }>();
|
||||
for (const s of list) {
|
||||
const e = seen.get(s.dx_call);
|
||||
if (e) { e.repeats++; }
|
||||
else seen.set(s.dx_call, { ...s, repeats: 1 });
|
||||
}
|
||||
rendered = Array.from(seen.values());
|
||||
}
|
||||
// Apply sort. Time defaults to descending (newest first).
|
||||
const dir = clusterSort.dir === 'asc' ? 1 : -1;
|
||||
const cmp = (a: any, b: any) => (a < b ? -dir : a > b ? dir : 0);
|
||||
rendered = [...rendered].sort((a, b) => {
|
||||
switch (clusterSort.key) {
|
||||
case 'time': return cmp(a.received_at, b.received_at);
|
||||
case 'call': return cmp(a.dx_call, b.dx_call);
|
||||
case 'freq': return cmp(a.freq_khz, b.freq_khz);
|
||||
case 'band': return cmp(a.band ?? '', b.band ?? '');
|
||||
case 'mode': return cmp(inferSpotMode(a.comment ?? '', a.freq_hz), inferSpotMode(b.comment ?? '', b.freq_hz));
|
||||
case 'spotter': return cmp(cleanSpotter(a.spotter), cleanSpotter(b.spotter));
|
||||
case 'source': return cmp(a.source_name, b.source_name);
|
||||
}
|
||||
});
|
||||
if (rendered.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||
<Hash className="size-10 opacity-30" />
|
||||
<div className="text-sm font-semibold text-foreground/70">
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{clusterServerStatuses.some((s) => s.state === 'connected')
|
||||
? 'Spots will appear as the cluster sends them.'
|
||||
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const headers: { key: SortKey | null; label: string; align?: 'right' }[] = [
|
||||
{ key: 'time', label: 'Time' },
|
||||
{ key: 'call', label: 'Call' },
|
||||
{ key: 'freq', label: 'Freq', align: 'right' },
|
||||
{ key: 'band', label: 'Band' },
|
||||
{ key: 'mode', label: 'Mode' },
|
||||
{ key: 'spotter', label: 'Spotter' },
|
||||
{ key: 'source', label: 'Source' },
|
||||
{ key: null, label: 'Loc' },
|
||||
{ key: null, label: 'Comment' },
|
||||
];
|
||||
const toggleSort = (k: SortKey) => setClusterSort((s) =>
|
||||
s.key === k
|
||||
? { key: k, dir: s.dir === 'asc' ? 'desc' : 'asc' }
|
||||
: { key: k, dir: k === 'time' || k === 'freq' ? 'desc' : 'asc' });
|
||||
const rowColor = (s: ClusterSpot): string => {
|
||||
// The cache key includes the inferred mode (from
|
||||
// comment / band-plan) so CW vs FT8 on the same
|
||||
// band get distinct statuses.
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const st = spotStatus[k];
|
||||
if (!st) return '';
|
||||
switch (st.status) {
|
||||
case 'new': return 'bg-rose-50 hover:bg-rose-100';
|
||||
case 'new-band': return 'bg-amber-50 hover:bg-amber-100';
|
||||
case 'new-slot': return 'bg-yellow-50 hover:bg-yellow-100';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
return (
|
||||
<table className="w-full border-collapse text-[12.5px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => {
|
||||
const sortable = h.key !== null;
|
||||
const active = sortable && clusterSort.key === h.key;
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
onClick={sortable ? () => toggleSort(h.key as SortKey) : undefined}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 font-semibold text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border sticky top-0',
|
||||
h.align === 'right' ? 'text-right' : 'text-left',
|
||||
sortable && 'cursor-pointer select-none hover:text-foreground',
|
||||
active && 'text-primary',
|
||||
)}
|
||||
>
|
||||
{h.label}
|
||||
{active && (
|
||||
<span className="ml-1 inline-block text-[9px]">
|
||||
{clusterSort.dir === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rendered.map((s, i) => (
|
||||
<tr
|
||||
key={`${s.received_at}-${s.dx_call}-${i}`}
|
||||
className={cn('cursor-pointer', rowColor(s) || 'hover:bg-accent/30')}
|
||||
onClick={() => {
|
||||
// Mode comes from the spot itself (comment text
|
||||
// first, band plan fallback). Sending it to CAT
|
||||
// matters because skipping it leaves the rig
|
||||
// on whatever it had — typically DIGU after a
|
||||
// previous FT8 contact, which breaks a SSB click.
|
||||
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
if (catState.connected) {
|
||||
SetCATFrequency(s.freq_hz).catch(() => {});
|
||||
if (m) SetCATMode(m).catch(() => {});
|
||||
} else {
|
||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||
if (s.band) setBand(s.band);
|
||||
if (m) setMode(m);
|
||||
}
|
||||
onCallsignInput(s.dx_call);
|
||||
}}
|
||||
title={s.raw}
|
||||
>
|
||||
<td
|
||||
className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40"
|
||||
title={s.repeats && s.repeats > 1 ? `Seen ${s.repeats}× across active clusters` : undefined}
|
||||
>{s.time_utc || ''}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono font-bold text-primary whitespace-nowrap border-b border-border/40">{s.dx_call}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{s.freq_khz.toFixed(1)}</td>
|
||||
<td className="px-2.5 py-1.5 border-b border-border/40"><Badge variant="accent" className="font-mono text-[10px] py-0">{s.band || '—'}</Badge></td>
|
||||
<td className="px-2.5 py-1.5 border-b border-border/40">{(() => {
|
||||
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
if (!m) return <span className="text-muted-foreground text-[10px]">—</span>;
|
||||
return <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono text-[10px] py-0" variant="outline">{m}</Badge>;
|
||||
})()}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{cleanSpotter(s.spotter)}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground/60 text-[10px] whitespace-nowrap border-b border-border/40">{s.source_name}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-muted-foreground whitespace-nowrap border-b border-border/40">{s.locator || ''}</td>
|
||||
<td className="px-2.5 py-1.5 text-muted-foreground border-b border-border/40">{s.comment}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Command input — sends to the master server. */}
|
||||
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
|
||||
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap">→ master</span>
|
||||
<Input
|
||||
className="font-mono text-xs h-8"
|
||||
placeholder='sh/dx 30, set/needsdxcc, …'
|
||||
value={clusterCmd}
|
||||
onChange={(e) => setClusterCmd(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && clusterCmd.trim()) {
|
||||
SendClusterCommand(clusterCmd.trim())
|
||||
.then(() => setClusterCmd(''))
|
||||
.catch((err) => setError(String(err?.message ?? err)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => {
|
||||
if (!clusterCmd.trim()) return;
|
||||
SendClusterCommand(clusterCmd.trim())
|
||||
.then(() => setClusterCmd(''))
|
||||
.catch((err) => setError(String(err?.message ?? err)));
|
||||
}}
|
||||
disabled={!clusterCmd.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>{/* /left column */}
|
||||
{/* BandMap moved to a global side panel below — toggle is
|
||||
now in the topbar, visible on every tab. */}
|
||||
</TabsContent>
|
||||
|
||||
{(['main','awards','propagation'] as const).map((t) => (
|
||||
<TabsContent key={t} value={t} className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||
<Hash className="size-10 opacity-30" />
|
||||
<div className="text-base font-semibold text-foreground/70">{t[0].toUpperCase() + t.slice(1)}</div>
|
||||
@@ -1423,6 +1936,29 @@ export default function App() {
|
||||
</section>
|
||||
|
||||
<CallHistoryPanel wb={wb} busy={wbBusy} currentCall={callsign} />
|
||||
{showBandMap && (
|
||||
<div className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
||||
<BandMap
|
||||
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) {
|
||||
SetCATFrequency(s.freq_hz).catch(() => {});
|
||||
if (m) SetCATMode(m).catch(() => {});
|
||||
} else {
|
||||
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
|
||||
if (s.band) setBand(s.band);
|
||||
if (m) setMode(m);
|
||||
}
|
||||
onCallsignInput(s.dx_call);
|
||||
}}
|
||||
onClose={() => setShowBandMap(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user