|
|
|
@ -1,7 +1,11 @@
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* PSKReporter Panel
|
|
|
|
* PSKReporter + WSJT-X Panel
|
|
|
|
* Shows where your digital mode signals are being received
|
|
|
|
* Digital mode spots — toggle between internet (PSKReporter) and local (WSJT-X UDP)
|
|
|
|
* Toggles between PSKReporter (internet) and WSJT-X (local UDP) views
|
|
|
|
*
|
|
|
|
|
|
|
|
* Layout:
|
|
|
|
|
|
|
|
* Row 1: Segmented mode toggle | map + filter controls
|
|
|
|
|
|
|
|
* Row 2: Sub-tabs (Being Heard / Hearing or Decodes / QSOs)
|
|
|
|
|
|
|
|
* Content: Scrolling spot/decode list
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
import React, { useState, useMemo } from 'react';
|
|
|
|
import React, { useState, useMemo } from 'react';
|
|
|
|
import { usePSKReporter } from '../hooks/usePSKReporter.js';
|
|
|
|
import { usePSKReporter } from '../hooks/usePSKReporter.js';
|
|
|
|
@ -25,36 +29,28 @@ const PSKReporterPanel = ({
|
|
|
|
showWSJTXOnMap,
|
|
|
|
showWSJTXOnMap,
|
|
|
|
onToggleWSJTXMap
|
|
|
|
onToggleWSJTXMap
|
|
|
|
}) => {
|
|
|
|
}) => {
|
|
|
|
const [panelMode, setPanelMode] = useState('psk'); // 'psk' | 'wsjtx'
|
|
|
|
const [panelMode, setPanelMode] = useState('psk');
|
|
|
|
const [activeTab, setActiveTab] = useState('tx'); // PSK: tx | rx
|
|
|
|
const [activeTab, setActiveTab] = useState('tx');
|
|
|
|
const [wsjtxTab, setWsjtxTab] = useState('decodes'); // WSJT-X: decodes | qsos
|
|
|
|
const [wsjtxTab, setWsjtxTab] = useState('decodes');
|
|
|
|
const [bandFilter, setBandFilter] = useState('all');
|
|
|
|
const [wsjtxFilter, setWsjtxFilter] = useState('all'); // 'all' | 'cq' | band name
|
|
|
|
const [showCQ, setShowCQ] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// PSKReporter hook
|
|
|
|
const {
|
|
|
|
const {
|
|
|
|
txReports,
|
|
|
|
txReports, txCount, rxReports, rxCount,
|
|
|
|
txCount,
|
|
|
|
loading, error, connected, source, refresh
|
|
|
|
rxReports,
|
|
|
|
|
|
|
|
rxCount,
|
|
|
|
|
|
|
|
loading,
|
|
|
|
|
|
|
|
error,
|
|
|
|
|
|
|
|
connected,
|
|
|
|
|
|
|
|
source,
|
|
|
|
|
|
|
|
refresh
|
|
|
|
|
|
|
|
} = usePSKReporter(callsign, {
|
|
|
|
} = usePSKReporter(callsign, {
|
|
|
|
minutes: 15,
|
|
|
|
minutes: 15,
|
|
|
|
enabled: callsign && callsign !== 'N0CALL'
|
|
|
|
enabled: callsign && callsign !== 'N0CALL'
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// PSK filter logic
|
|
|
|
// ── PSK filtering ──
|
|
|
|
const filterReports = (reports) => {
|
|
|
|
const filterReports = (reports) => {
|
|
|
|
return reports.filter(r => {
|
|
|
|
return reports.filter(r => {
|
|
|
|
if (filters?.bands?.length && !filters.bands.includes(r.band)) return false;
|
|
|
|
if (filters?.bands?.length && !filters.bands.includes(r.band)) return false;
|
|
|
|
if (filters?.grids?.length) {
|
|
|
|
if (filters?.grids?.length) {
|
|
|
|
const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid;
|
|
|
|
const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid;
|
|
|
|
if (!grid) return false;
|
|
|
|
if (!grid) return false;
|
|
|
|
const gridPrefix = grid.substring(0, 2).toUpperCase();
|
|
|
|
if (!filters.grids.includes(grid.substring(0, 2).toUpperCase())) return false;
|
|
|
|
if (!filters.grids.includes(gridPrefix)) return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false;
|
|
|
|
if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false;
|
|
|
|
return true;
|
|
|
|
return true;
|
|
|
|
@ -64,401 +60,378 @@ const PSKReporterPanel = ({
|
|
|
|
const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]);
|
|
|
|
const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]);
|
|
|
|
const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]);
|
|
|
|
const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]);
|
|
|
|
const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx;
|
|
|
|
const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx;
|
|
|
|
|
|
|
|
const pskFilterCount = [filters?.bands?.length, filters?.grids?.length, filters?.modes?.length].filter(Boolean).length;
|
|
|
|
|
|
|
|
|
|
|
|
const getActiveFilterCount = () => {
|
|
|
|
const getFreqColor = (freqMHz) => !freqMHz ? 'var(--text-muted)' : getBandColor(parseFloat(freqMHz));
|
|
|
|
let count = 0;
|
|
|
|
const formatAge = (m) => m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m/60)}h`;
|
|
|
|
if (filters?.bands?.length) count++;
|
|
|
|
|
|
|
|
if (filters?.grids?.length) count++;
|
|
|
|
|
|
|
|
if (filters?.modes?.length) count++;
|
|
|
|
|
|
|
|
return count;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
const filterCount = getActiveFilterCount();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const getFreqColor = (freqMHz) => {
|
|
|
|
|
|
|
|
if (!freqMHz) return 'var(--text-muted)';
|
|
|
|
|
|
|
|
return getBandColor(parseFloat(freqMHz));
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const formatAge = (minutes) => {
|
|
|
|
|
|
|
|
if (minutes < 1) return 'now';
|
|
|
|
|
|
|
|
if (minutes < 60) return `${minutes}m`;
|
|
|
|
|
|
|
|
return `${Math.floor(minutes/60)}h`;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const getStatusIndicator = () => {
|
|
|
|
|
|
|
|
if (connected) return <span style={{ color: '#4ade80', fontSize: '10px' }}>● LIVE</span>;
|
|
|
|
|
|
|
|
if (source === 'connecting' || source === 'reconnecting') return <span style={{ color: '#fbbf24', fontSize: '10px' }}>◐ {source}</span>;
|
|
|
|
|
|
|
|
if (error) return <span style={{ color: '#ef4444', fontSize: '10px' }}>● offline</span>;
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// WSJT-X helpers
|
|
|
|
// ── WSJT-X helpers ──
|
|
|
|
const activeClients = Object.entries(wsjtxClients);
|
|
|
|
const activeClients = Object.entries(wsjtxClients);
|
|
|
|
const primaryClient = activeClients.length > 0 ? activeClients[0][1] : null;
|
|
|
|
const primaryClient = activeClients[0]?.[1] || null;
|
|
|
|
|
|
|
|
|
|
|
|
const wsjtxBands = useMemo(() => {
|
|
|
|
// Build unified filter options: All, CQ Only, then each available band
|
|
|
|
const bands = new Set(wsjtxDecodes.map(d => d.band).filter(Boolean));
|
|
|
|
const wsjtxFilterOptions = useMemo(() => {
|
|
|
|
return ['all', ...Array.from(bands).sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999))];
|
|
|
|
const bands = [...new Set(wsjtxDecodes.map(d => d.band).filter(Boolean))]
|
|
|
|
|
|
|
|
.sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999));
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
|
|
{ value: 'all', label: 'All decodes' },
|
|
|
|
|
|
|
|
{ value: 'cq', label: 'CQ only' },
|
|
|
|
|
|
|
|
...bands.map(b => ({ value: b, label: b }))
|
|
|
|
|
|
|
|
];
|
|
|
|
}, [wsjtxDecodes]);
|
|
|
|
}, [wsjtxDecodes]);
|
|
|
|
|
|
|
|
|
|
|
|
const filteredDecodes = useMemo(() => {
|
|
|
|
const filteredDecodes = useMemo(() => {
|
|
|
|
let filtered = [...wsjtxDecodes];
|
|
|
|
let filtered = [...wsjtxDecodes];
|
|
|
|
if (bandFilter !== 'all') filtered = filtered.filter(d => d.band === bandFilter);
|
|
|
|
if (wsjtxFilter === 'cq') {
|
|
|
|
if (showCQ) filtered = filtered.filter(d => d.type === 'CQ');
|
|
|
|
filtered = filtered.filter(d => d.type === 'CQ');
|
|
|
|
|
|
|
|
} else if (wsjtxFilter !== 'all') {
|
|
|
|
|
|
|
|
// Band filter
|
|
|
|
|
|
|
|
filtered = filtered.filter(d => d.band === wsjtxFilter);
|
|
|
|
|
|
|
|
}
|
|
|
|
return filtered.reverse();
|
|
|
|
return filtered.reverse();
|
|
|
|
}, [wsjtxDecodes, bandFilter, showCQ]);
|
|
|
|
}, [wsjtxDecodes, wsjtxFilter]);
|
|
|
|
|
|
|
|
|
|
|
|
const getSnrColor = (snr) => {
|
|
|
|
const getSnrColor = (snr) => {
|
|
|
|
if (snr === null || snr === undefined) return 'var(--text-muted)';
|
|
|
|
if (snr == null) return 'var(--text-muted)';
|
|
|
|
if (snr >= 0) return '#4ade80';
|
|
|
|
if (snr >= 0) return '#4ade80';
|
|
|
|
if (snr >= -10) return '#fbbf24';
|
|
|
|
if (snr >= -10) return '#fbbf24';
|
|
|
|
if (snr >= -18) return '#fb923c';
|
|
|
|
if (snr >= -18) return '#fb923c';
|
|
|
|
return '#ef4444';
|
|
|
|
return '#ef4444';
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getMsgColor = (decode) => {
|
|
|
|
const getMsgColor = (d) => {
|
|
|
|
if (decode.type === 'CQ') return '#60a5fa';
|
|
|
|
if (d.type === 'CQ') return '#60a5fa';
|
|
|
|
if (decode.exchange === 'RR73' || decode.exchange === '73' || decode.exchange === 'RRR') return '#4ade80';
|
|
|
|
if (['RR73', '73', 'RRR'].includes(d.exchange)) return '#4ade80';
|
|
|
|
if (decode.exchange?.startsWith('R')) return '#fbbf24';
|
|
|
|
if (d.exchange?.startsWith('R')) return '#fbbf24';
|
|
|
|
return 'var(--text-primary)';
|
|
|
|
return 'var(--text-primary)';
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Mode switch button style
|
|
|
|
// Active map toggle for current mode
|
|
|
|
const modeBtn = (mode, color) => ({
|
|
|
|
const isMapOn = panelMode === 'psk' ? showOnMap : showWSJTXOnMap;
|
|
|
|
padding: '2px 8px',
|
|
|
|
const handleMapToggle = panelMode === 'psk' ? onToggleMap : onToggleWSJTXMap;
|
|
|
|
background: panelMode === mode ? `${color}22` : 'transparent',
|
|
|
|
|
|
|
|
border: `1px solid ${panelMode === mode ? color : 'var(--border-color)'}`,
|
|
|
|
// Compact status dot
|
|
|
|
color: panelMode === mode ? color : 'var(--text-muted)',
|
|
|
|
const statusDot = connected
|
|
|
|
|
|
|
|
? { color: '#4ade80', char: '●' }
|
|
|
|
|
|
|
|
: (source === 'connecting' || source === 'reconnecting')
|
|
|
|
|
|
|
|
? { color: '#fbbf24', char: '◐' }
|
|
|
|
|
|
|
|
: error ? { color: '#ef4444', char: '●' } : null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── Shared styles ──
|
|
|
|
|
|
|
|
const segBtn = (active, color) => ({
|
|
|
|
|
|
|
|
padding: '3px 10px',
|
|
|
|
|
|
|
|
background: active ? `${color}18` : 'transparent',
|
|
|
|
|
|
|
|
color: active ? color : 'var(--text-muted)',
|
|
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
|
|
borderBottom: active ? `2px solid ${color}` : '2px solid transparent',
|
|
|
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
|
|
|
fontWeight: active ? '700' : '400',
|
|
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
|
|
letterSpacing: '0.02em',
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const subTabBtn = (active, color) => ({
|
|
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
|
|
padding: '3px 4px',
|
|
|
|
|
|
|
|
background: active ? `${color}20` : 'transparent',
|
|
|
|
|
|
|
|
border: `1px solid ${active ? color + '66' : 'var(--border-color)'}`,
|
|
|
|
|
|
|
|
borderRadius: '3px',
|
|
|
|
|
|
|
|
color: active ? color : 'var(--text-muted)',
|
|
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
|
|
fontSize: '10px',
|
|
|
|
|
|
|
|
fontWeight: active ? '600' : '400',
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const iconBtn = (active, activeColor = '#4488ff') => ({
|
|
|
|
|
|
|
|
background: active ? `${activeColor}30` : 'rgba(100,100,100,0.3)',
|
|
|
|
|
|
|
|
border: `1px solid ${active ? activeColor : '#555'}`,
|
|
|
|
|
|
|
|
color: active ? activeColor : '#777',
|
|
|
|
|
|
|
|
padding: '2px 6px',
|
|
|
|
borderRadius: '3px',
|
|
|
|
borderRadius: '3px',
|
|
|
|
fontSize: '10px',
|
|
|
|
fontSize: '10px',
|
|
|
|
cursor: 'pointer',
|
|
|
|
cursor: 'pointer',
|
|
|
|
fontWeight: panelMode === mode ? '700' : '400',
|
|
|
|
lineHeight: 1,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div className="panel" style={{
|
|
|
|
<div className="panel" style={{
|
|
|
|
padding: '10px',
|
|
|
|
padding: '8px 10px',
|
|
|
|
display: 'flex',
|
|
|
|
display: 'flex',
|
|
|
|
flexDirection: 'column',
|
|
|
|
flexDirection: 'column',
|
|
|
|
height: '100%',
|
|
|
|
height: '100%',
|
|
|
|
overflow: 'hidden'
|
|
|
|
overflow: 'hidden'
|
|
|
|
}}>
|
|
|
|
}}>
|
|
|
|
{/* Mode switcher header */}
|
|
|
|
{/* ── Row 1: Mode toggle + controls ── */}
|
|
|
|
<div style={{
|
|
|
|
<div style={{
|
|
|
|
display: 'flex',
|
|
|
|
display: 'flex',
|
|
|
|
alignItems: 'center',
|
|
|
|
alignItems: 'center',
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
marginBottom: '4px',
|
|
|
|
marginBottom: '5px',
|
|
|
|
flexShrink: 0
|
|
|
|
flexShrink: 0,
|
|
|
|
}}>
|
|
|
|
}}>
|
|
|
|
<div style={{ display: 'flex', gap: '3px' }}>
|
|
|
|
{/* Mode toggle */}
|
|
|
|
<button onClick={() => setPanelMode('psk')} style={modeBtn('psk', 'var(--accent-primary)')}>
|
|
|
|
<div style={{ display: 'flex' }}>
|
|
|
|
📡 PSKReporter
|
|
|
|
<button onClick={() => setPanelMode('psk')} style={segBtn(panelMode === 'psk', 'var(--accent-primary)')}>
|
|
|
|
|
|
|
|
PSKReporter
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
<button onClick={() => setPanelMode('wsjtx')} style={modeBtn('wsjtx', '#a78bfa')}>
|
|
|
|
<button onClick={() => setPanelMode('wsjtx')} style={segBtn(panelMode === 'wsjtx', '#a78bfa')}>
|
|
|
|
🔊 WSJT-X
|
|
|
|
WSJT-X
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Controls row - differs per mode */}
|
|
|
|
{/* Right controls */}
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
|
|
|
|
|
|
{/* PSK: status dot + filter + refresh */}
|
|
|
|
{panelMode === 'psk' && (
|
|
|
|
{panelMode === 'psk' && (
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
|
|
|
|
{statusDot && (
|
|
|
|
{filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount}
|
|
|
|
<span style={{ color: statusDot.color, fontSize: '10px', lineHeight: 1 }}>{statusDot.char}</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
{getStatusIndicator()}
|
|
|
|
<button onClick={onOpenFilters} style={iconBtn(pskFilterCount > 0, '#ffaa00')}>
|
|
|
|
<button onClick={onOpenFilters} style={{
|
|
|
|
{pskFilterCount > 0 ? `🔍${pskFilterCount}` : '🔍'}
|
|
|
|
background: filterCount > 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
|
|
|
|
</button>
|
|
|
|
border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
|
|
|
|
|
|
|
|
color: filterCount > 0 ? '#ffaa00' : '#888',
|
|
|
|
|
|
|
|
padding: '2px 6px', borderRadius: '4px', fontSize: '10px', cursor: 'pointer'
|
|
|
|
|
|
|
|
}}>🔍</button>
|
|
|
|
|
|
|
|
<button onClick={refresh} disabled={loading} style={{
|
|
|
|
<button onClick={refresh} disabled={loading} style={{
|
|
|
|
background: 'rgba(100, 100, 100, 0.3)', border: '1px solid #666',
|
|
|
|
...iconBtn(false),
|
|
|
|
color: '#888', padding: '2px 6px', borderRadius: '4px', fontSize: '10px',
|
|
|
|
opacity: loading ? 0.4 : 1,
|
|
|
|
cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.5 : 1
|
|
|
|
cursor: loading ? 'not-allowed' : 'pointer',
|
|
|
|
}}>🔄</button>
|
|
|
|
}}>🔄</button>
|
|
|
|
{onToggleMap && (
|
|
|
|
|
|
|
|
<button onClick={onToggleMap} style={{
|
|
|
|
|
|
|
|
background: showOnMap ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
|
|
|
|
|
|
|
|
border: `1px solid ${showOnMap ? '#4488ff' : '#666'}`,
|
|
|
|
|
|
|
|
color: showOnMap ? '#4488ff' : '#888',
|
|
|
|
|
|
|
|
padding: '2px 6px', borderRadius: '4px', fontSize: '10px', cursor: 'pointer'
|
|
|
|
|
|
|
|
}}>🗺️ {showOnMap ? 'ON' : 'OFF'}</button>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* WSJT-X: mode/band info + unified filter */}
|
|
|
|
{panelMode === 'wsjtx' && (
|
|
|
|
{panelMode === 'wsjtx' && (
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
{primaryClient && (
|
|
|
|
{primaryClient && (
|
|
|
|
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
|
|
|
|
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
|
|
|
|
{primaryClient.mode || ''} {primaryClient.band || ''}
|
|
|
|
{primaryClient.mode} {primaryClient.band}
|
|
|
|
{primaryClient.transmitting && <span style={{ color: '#ef4444', marginLeft: '3px' }}>TX</span>}
|
|
|
|
{primaryClient.transmitting && <span style={{ color: '#ef4444', marginLeft: '2px' }}>TX</span>}
|
|
|
|
{primaryClient.decoding && <span style={{ color: '#4ade80', marginLeft: '3px' }}>RX</span>}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
<select value={bandFilter} onChange={(e) => setBandFilter(e.target.value)} style={{
|
|
|
|
<select
|
|
|
|
background: 'var(--bg-tertiary)', color: 'var(--text-primary)',
|
|
|
|
value={wsjtxFilter}
|
|
|
|
border: '1px solid var(--border-color)', borderRadius: '3px',
|
|
|
|
onChange={(e) => setWsjtxFilter(e.target.value)}
|
|
|
|
fontSize: '10px', padding: '1px 2px', cursor: 'pointer'
|
|
|
|
style={{
|
|
|
|
}}>
|
|
|
|
background: 'var(--bg-tertiary)',
|
|
|
|
{wsjtxBands.map(b => <option key={b} value={b}>{b === 'all' ? 'All' : b}</option>)}
|
|
|
|
color: wsjtxFilter !== 'all' ? '#a78bfa' : 'var(--text-primary)',
|
|
|
|
|
|
|
|
border: `1px solid ${wsjtxFilter !== 'all' ? '#a78bfa55' : 'var(--border-color)'}`,
|
|
|
|
|
|
|
|
borderRadius: '3px',
|
|
|
|
|
|
|
|
fontSize: '10px',
|
|
|
|
|
|
|
|
padding: '1px 4px',
|
|
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
|
|
maxWidth: '90px',
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
{wsjtxFilterOptions.map(o => (
|
|
|
|
|
|
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
|
|
|
|
|
|
))}
|
|
|
|
</select>
|
|
|
|
</select>
|
|
|
|
<button onClick={() => setShowCQ(!showCQ)} style={{
|
|
|
|
|
|
|
|
background: showCQ ? '#60a5fa33' : 'transparent',
|
|
|
|
|
|
|
|
color: showCQ ? '#60a5fa' : 'var(--text-muted)',
|
|
|
|
|
|
|
|
border: `1px solid ${showCQ ? '#60a5fa55' : 'var(--border-color)'}`,
|
|
|
|
|
|
|
|
borderRadius: '3px', fontSize: '10px', padding: '1px 4px', cursor: 'pointer'
|
|
|
|
|
|
|
|
}}>CQ</button>
|
|
|
|
|
|
|
|
{onToggleWSJTXMap && (
|
|
|
|
|
|
|
|
<button onClick={onToggleWSJTXMap} style={{
|
|
|
|
|
|
|
|
background: showWSJTXOnMap ? 'rgba(167, 139, 250, 0.3)' : 'rgba(100, 100, 100, 0.3)',
|
|
|
|
|
|
|
|
border: `1px solid ${showWSJTXOnMap ? '#a78bfa' : '#666'}`,
|
|
|
|
|
|
|
|
color: showWSJTXOnMap ? '#a78bfa' : '#888',
|
|
|
|
|
|
|
|
padding: '2px 6px', borderRadius: '4px', fontSize: '10px', cursor: 'pointer'
|
|
|
|
|
|
|
|
}}>🗺️ {showWSJTXOnMap ? 'ON' : 'OFF'}</button>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Map toggle (always visible) */}
|
|
|
|
|
|
|
|
{handleMapToggle && (
|
|
|
|
|
|
|
|
<button onClick={handleMapToggle} style={iconBtn(isMapOn, panelMode === 'psk' ? '#4488ff' : '#a78bfa')}>
|
|
|
|
|
|
|
|
🗺️
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* === PSKReporter View === */}
|
|
|
|
{/* ── Row 2: Sub-tabs ── */}
|
|
|
|
{panelMode === 'psk' && (
|
|
|
|
<div style={{ display: 'flex', gap: '4px', marginBottom: '5px', flexShrink: 0 }}>
|
|
|
|
<>
|
|
|
|
{panelMode === 'psk' ? (
|
|
|
|
{(!callsign || callsign === 'N0CALL') ? (
|
|
|
|
<>
|
|
|
|
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '10px', fontSize: '11px' }}>
|
|
|
|
<button onClick={() => setActiveTab('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')}>
|
|
|
|
Set callsign in Settings
|
|
|
|
▲ Heard ({pskFilterCount > 0 ? filteredTx.length : txCount})
|
|
|
|
</div>
|
|
|
|
</button>
|
|
|
|
) : (
|
|
|
|
<button onClick={() => setActiveTab('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')}>
|
|
|
|
<>
|
|
|
|
▼ Hearing ({pskFilterCount > 0 ? filteredRx.length : rxCount})
|
|
|
|
{/* PSK Tabs */}
|
|
|
|
</button>
|
|
|
|
<div style={{ display: 'flex', gap: '4px', marginBottom: '6px', flexShrink: 0 }}>
|
|
|
|
</>
|
|
|
|
<button onClick={() => setActiveTab('tx')} style={{
|
|
|
|
) : (
|
|
|
|
flex: 1, padding: '4px 6px',
|
|
|
|
<>
|
|
|
|
background: activeTab === 'tx' ? 'rgba(74, 222, 128, 0.2)' : 'rgba(100, 100, 100, 0.2)',
|
|
|
|
<button onClick={() => setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')}>
|
|
|
|
border: `1px solid ${activeTab === 'tx' ? '#4ade80' : '#555'}`,
|
|
|
|
Decodes ({filteredDecodes.length})
|
|
|
|
borderRadius: '3px', color: activeTab === 'tx' ? '#4ade80' : '#888',
|
|
|
|
</button>
|
|
|
|
cursor: 'pointer', fontSize: '10px', fontFamily: 'JetBrains Mono'
|
|
|
|
<button onClick={() => setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')}>
|
|
|
|
}}>
|
|
|
|
QSOs ({wsjtxQsos.length})
|
|
|
|
📤 Being Heard ({filterCount > 0 ? filteredTx.length : txCount})
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
</>
|
|
|
|
<button onClick={() => setActiveTab('rx')} style={{
|
|
|
|
)}
|
|
|
|
flex: 1, padding: '4px 6px',
|
|
|
|
</div>
|
|
|
|
background: activeTab === 'rx' ? 'rgba(96, 165, 250, 0.2)' : 'rgba(100, 100, 100, 0.2)',
|
|
|
|
|
|
|
|
border: `1px solid ${activeTab === 'rx' ? '#60a5fa' : '#555'}`,
|
|
|
|
|
|
|
|
borderRadius: '3px', color: activeTab === 'rx' ? '#60a5fa' : '#888',
|
|
|
|
|
|
|
|
cursor: 'pointer', fontSize: '10px', fontFamily: 'JetBrains Mono'
|
|
|
|
|
|
|
|
}}>
|
|
|
|
|
|
|
|
📥 Hearing ({filterCount > 0 ? filteredRx.length : rxCount})
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* PSK Reports list */}
|
|
|
|
{/* ── Content area ── */}
|
|
|
|
{error && !connected ? (
|
|
|
|
<div style={{ flex: 1, overflow: 'auto', fontSize: '11px', fontFamily: "'JetBrains Mono', monospace" }}>
|
|
|
|
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
|
|
|
|
|
|
|
|
⚠️ Connection failed - click 🔄 to retry
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
) : loading && filteredReports.length === 0 && filterCount === 0 ? (
|
|
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '15px', color: 'var(--text-muted)', fontSize: '11px' }}>
|
|
|
|
|
|
|
|
<div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
|
|
|
|
|
|
|
|
Connecting to MQTT...
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
) : !connected && filteredReports.length === 0 && filterCount === 0 ? (
|
|
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
|
|
|
|
|
|
|
|
Waiting for connection...
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
) : filteredReports.length === 0 ? (
|
|
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
|
|
|
|
|
|
|
|
{filterCount > 0
|
|
|
|
|
|
|
|
? 'No spots match filters'
|
|
|
|
|
|
|
|
: activeTab === 'tx'
|
|
|
|
|
|
|
|
? 'Waiting for spots... (TX to see reports)'
|
|
|
|
|
|
|
|
: 'No stations heard yet'}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
) : (
|
|
|
|
|
|
|
|
<div style={{ flex: 1, overflow: 'auto', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace' }}>
|
|
|
|
|
|
|
|
{filteredReports.slice(0, 20).map((report, i) => {
|
|
|
|
|
|
|
|
const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?');
|
|
|
|
|
|
|
|
const color = getFreqColor(freqMHz);
|
|
|
|
|
|
|
|
const displayCall = activeTab === 'tx' ? report.receiver : report.sender;
|
|
|
|
|
|
|
|
const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
{/* === PSKReporter content === */}
|
|
|
|
<div
|
|
|
|
{panelMode === 'psk' && (
|
|
|
|
key={`${displayCall}-${report.freq}-${i}`}
|
|
|
|
<>
|
|
|
|
onClick={() => onShowOnMap && report.lat && report.lon && onShowOnMap(report)}
|
|
|
|
{(!callsign || callsign === 'N0CALL') ? (
|
|
|
|
style={{
|
|
|
|
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
|
|
|
|
display: 'grid', gridTemplateColumns: '55px 1fr auto',
|
|
|
|
Set your callsign in Settings to see reports
|
|
|
|
gap: '6px', padding: '4px 6px', borderRadius: '3px', marginBottom: '2px',
|
|
|
|
</div>
|
|
|
|
background: i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent',
|
|
|
|
) : error && !connected ? (
|
|
|
|
cursor: report.lat && report.lon ? 'pointer' : 'default',
|
|
|
|
<div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}>
|
|
|
|
transition: 'background 0.15s', borderLeft: '2px solid transparent'
|
|
|
|
Connection failed — tap 🔄
|
|
|
|
}}
|
|
|
|
</div>
|
|
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68, 136, 255, 0.15)'}
|
|
|
|
) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? (
|
|
|
|
onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'}
|
|
|
|
<div style={{ textAlign: 'center', padding: '16px', color: 'var(--text-muted)', fontSize: '11px' }}>
|
|
|
|
>
|
|
|
|
<div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
|
|
|
|
<div style={{ color, fontWeight: '600', fontSize: '11px' }}>{freqMHz}</div>
|
|
|
|
Connecting...
|
|
|
|
<div style={{ color: 'var(--text-primary)', fontWeight: '600', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '11px' }}>
|
|
|
|
</div>
|
|
|
|
{displayCall}
|
|
|
|
) : filteredReports.length === 0 ? (
|
|
|
|
{grid && <span style={{ color: 'var(--text-muted)', fontWeight: '400', marginLeft: '4px', fontSize: '9px' }}>{grid}</span>}
|
|
|
|
<div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}>
|
|
|
|
</div>
|
|
|
|
{pskFilterCount > 0
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '10px' }}>
|
|
|
|
? 'No spots match filters'
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>{report.mode}</span>
|
|
|
|
: activeTab === 'tx'
|
|
|
|
{report.snr !== null && report.snr !== undefined && (
|
|
|
|
? 'Waiting for spots... (TX to see reports)'
|
|
|
|
<span style={{ color: report.snr >= 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}>
|
|
|
|
: 'No stations heard yet'}
|
|
|
|
{report.snr > 0 ? '+' : ''}{report.snr}
|
|
|
|
</div>
|
|
|
|
</span>
|
|
|
|
) : (
|
|
|
|
)}
|
|
|
|
filteredReports.slice(0, 25).map((report, i) => {
|
|
|
|
<span style={{ color: 'var(--text-muted)', fontSize: '9px' }}>{formatAge(report.age)}</span>
|
|
|
|
const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?');
|
|
|
|
</div>
|
|
|
|
const color = getFreqColor(freqMHz);
|
|
|
|
</div>
|
|
|
|
const displayCall = activeTab === 'tx' ? report.receiver : report.sender;
|
|
|
|
);
|
|
|
|
const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid;
|
|
|
|
})}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* === WSJT-X View === */}
|
|
|
|
return (
|
|
|
|
{panelMode === 'wsjtx' && (
|
|
|
|
<div
|
|
|
|
<>
|
|
|
|
key={`${displayCall}-${report.freq}-${i}`}
|
|
|
|
{/* WSJT-X Tabs */}
|
|
|
|
onClick={() => onShowOnMap?.(report)}
|
|
|
|
<div style={{ display: 'flex', gap: '2px', marginBottom: '4px', flexShrink: 0 }}>
|
|
|
|
style={{
|
|
|
|
{[
|
|
|
|
display: 'grid',
|
|
|
|
{ key: 'decodes', label: `Decodes (${wsjtxDecodes.length})` },
|
|
|
|
gridTemplateColumns: '52px 1fr auto',
|
|
|
|
{ key: 'qsos', label: `QSOs (${wsjtxQsos.length})` },
|
|
|
|
gap: '5px',
|
|
|
|
].map(tab => (
|
|
|
|
padding: '3px 4px',
|
|
|
|
<button
|
|
|
|
borderRadius: '2px',
|
|
|
|
key={tab.key}
|
|
|
|
background: i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent',
|
|
|
|
onClick={() => setWsjtxTab(tab.key)}
|
|
|
|
cursor: report.lat && report.lon ? 'pointer' : 'default',
|
|
|
|
style={{
|
|
|
|
}}
|
|
|
|
flex: 1,
|
|
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68,136,255,0.12)'}
|
|
|
|
background: wsjtxTab === tab.key ? 'var(--bg-tertiary)' : 'transparent',
|
|
|
|
onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'}
|
|
|
|
color: wsjtxTab === tab.key ? '#a78bfa' : 'var(--text-muted)',
|
|
|
|
>
|
|
|
|
border: 'none',
|
|
|
|
<span style={{ color, fontWeight: '600', fontSize: '10px' }}>{freqMHz}</span>
|
|
|
|
borderBottom: wsjtxTab === tab.key ? '2px solid #a78bfa' : '2px solid transparent',
|
|
|
|
<span style={{
|
|
|
|
fontSize: '10px', padding: '3px 6px', cursor: 'pointer',
|
|
|
|
color: 'var(--text-primary)', fontWeight: '600', fontSize: '11px',
|
|
|
|
borderRadius: '3px 3px 0 0',
|
|
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
|
|
|
|
}}
|
|
|
|
}}>
|
|
|
|
>
|
|
|
|
{displayCall}
|
|
|
|
{tab.label}
|
|
|
|
{grid && <span style={{ color: 'var(--text-muted)', fontWeight: '400', marginLeft: '4px', fontSize: '9px' }}>{grid}</span>}
|
|
|
|
</button>
|
|
|
|
</span>
|
|
|
|
))}
|
|
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '3px', fontSize: '9px' }}>
|
|
|
|
</div>
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>{report.mode}</span>
|
|
|
|
|
|
|
|
{report.snr != null && (
|
|
|
|
|
|
|
|
<span style={{ color: report.snr >= 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}>
|
|
|
|
|
|
|
|
{report.snr > 0 ? '+' : ''}{report.snr}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
<span style={{ color: 'var(--text-muted)' }}>{formatAge(report.age)}</span>
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* No WSJT-X connected */}
|
|
|
|
{/* === WSJT-X content === */}
|
|
|
|
{!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? (
|
|
|
|
{panelMode === 'wsjtx' && (
|
|
|
|
<div style={{
|
|
|
|
<>
|
|
|
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
{/* No client connected */}
|
|
|
|
flexDirection: 'column', gap: '6px', color: 'var(--text-muted)',
|
|
|
|
{!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? (
|
|
|
|
fontSize: '11px', textAlign: 'center', padding: '8px'
|
|
|
|
|
|
|
|
}}>
|
|
|
|
|
|
|
|
<div>Waiting for WSJT-X...</div>
|
|
|
|
|
|
|
|
<div style={{ fontSize: '10px', opacity: 0.7 }}>
|
|
|
|
|
|
|
|
Settings → Reporting → UDP Server
|
|
|
|
|
|
|
|
<br />
|
|
|
|
|
|
|
|
Address: {'{server IP}'} Port: {wsjtxPort || 2237}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
) : (
|
|
|
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
{/* Decodes / QSOs content */}
|
|
|
|
|
|
|
|
<div style={{
|
|
|
|
<div style={{
|
|
|
|
flex: 1, overflowY: 'auto', overflowX: 'hidden',
|
|
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
fontSize: '11px', fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
|
|
|
flexDirection: 'column', gap: '8px', color: 'var(--text-muted)',
|
|
|
|
|
|
|
|
fontSize: '11px', textAlign: 'center', padding: '16px 8px', height: '100%'
|
|
|
|
}}>
|
|
|
|
}}>
|
|
|
|
{wsjtxTab === 'decodes' && (
|
|
|
|
<div style={{ fontSize: '12px' }}>Waiting for WSJT-X...</div>
|
|
|
|
<>
|
|
|
|
<div style={{ fontSize: '10px', opacity: 0.6, lineHeight: 1.5 }}>
|
|
|
|
{filteredDecodes.length === 0 ? (
|
|
|
|
In WSJT-X: Settings → Reporting → UDP Server
|
|
|
|
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '20px', fontSize: '11px' }}>
|
|
|
|
<br />
|
|
|
|
{wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening for decodes...'}
|
|
|
|
Address: 127.0.0.1 Port: {wsjtxPort || 2237}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
) : (
|
|
|
|
</div>
|
|
|
|
filteredDecodes.map((d, i) => (
|
|
|
|
) : wsjtxTab === 'decodes' ? (
|
|
|
|
<div
|
|
|
|
<>
|
|
|
|
key={d.id || i}
|
|
|
|
{filteredDecodes.length === 0 ? (
|
|
|
|
style={{
|
|
|
|
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
|
|
|
|
display: 'flex', gap: '6px', padding: '2px 0',
|
|
|
|
{wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening...'}
|
|
|
|
borderBottom: '1px solid var(--border-color)',
|
|
|
|
</div>
|
|
|
|
alignItems: 'baseline',
|
|
|
|
) : (
|
|
|
|
opacity: d.lowConfidence ? 0.6 : 1,
|
|
|
|
filteredDecodes.map((d, i) => (
|
|
|
|
}}
|
|
|
|
<div
|
|
|
|
>
|
|
|
|
key={d.id || i}
|
|
|
|
<span style={{ color: 'var(--text-muted)', minWidth: '48px', fontSize: '10px' }}>{d.time}</span>
|
|
|
|
style={{
|
|
|
|
<span style={{ color: getSnrColor(d.snr), minWidth: '28px', textAlign: 'right', fontSize: '10px' }}>
|
|
|
|
display: 'flex', gap: '5px', padding: '2px 2px',
|
|
|
|
{d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''}
|
|
|
|
borderBottom: '1px solid var(--border-color)',
|
|
|
|
</span>
|
|
|
|
alignItems: 'baseline',
|
|
|
|
<span style={{ color: 'var(--text-muted)', minWidth: '28px', textAlign: 'right', fontSize: '10px' }}>{d.dt}</span>
|
|
|
|
opacity: d.lowConfidence ? 0.5 : 1,
|
|
|
|
<span style={{
|
|
|
|
}}
|
|
|
|
color: d.band ? getBandColor(d.dialFrequency / 1000000) : 'var(--text-muted)',
|
|
|
|
>
|
|
|
|
minWidth: '36px', textAlign: 'right', fontSize: '10px'
|
|
|
|
<span style={{ color: 'var(--text-muted)', minWidth: '44px', fontSize: '10px' }}>{d.time}</span>
|
|
|
|
}}>{d.freq}</span>
|
|
|
|
<span style={{ color: getSnrColor(d.snr), minWidth: '26px', textAlign: 'right', fontSize: '10px' }}>
|
|
|
|
<span style={{
|
|
|
|
{d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''}
|
|
|
|
color: getMsgColor(d), flex: 1, whiteSpace: 'nowrap',
|
|
|
|
</span>
|
|
|
|
overflow: 'hidden', textOverflow: 'ellipsis',
|
|
|
|
<span style={{ color: 'var(--text-muted)', minWidth: '24px', textAlign: 'right', fontSize: '10px' }}>{d.dt}</span>
|
|
|
|
}}>{d.message}</span>
|
|
|
|
<span style={{
|
|
|
|
</div>
|
|
|
|
color: d.band ? getBandColor(d.dialFrequency / 1000000) : 'var(--text-muted)',
|
|
|
|
))
|
|
|
|
minWidth: '32px', textAlign: 'right', fontSize: '10px'
|
|
|
|
)}
|
|
|
|
}}>{d.freq}</span>
|
|
|
|
</>
|
|
|
|
<span style={{
|
|
|
|
|
|
|
|
color: getMsgColor(d), flex: 1, whiteSpace: 'nowrap',
|
|
|
|
|
|
|
|
overflow: 'hidden', textOverflow: 'ellipsis',
|
|
|
|
|
|
|
|
}}>{d.message}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
))
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
{wsjtxTab === 'qsos' && (
|
|
|
|
) : (
|
|
|
|
<>
|
|
|
|
/* QSOs tab */
|
|
|
|
{wsjtxQsos.length === 0 ? (
|
|
|
|
<>
|
|
|
|
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '20px', fontSize: '11px' }}>
|
|
|
|
{wsjtxQsos.length === 0 ? (
|
|
|
|
No QSOs logged yet
|
|
|
|
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
|
|
|
|
</div>
|
|
|
|
No QSOs logged yet
|
|
|
|
) : (
|
|
|
|
</div>
|
|
|
|
[...wsjtxQsos].reverse().map((q, i) => (
|
|
|
|
) : (
|
|
|
|
<div key={i} style={{
|
|
|
|
[...wsjtxQsos].reverse().map((q, i) => (
|
|
|
|
display: 'flex', gap: '6px', padding: '3px 0',
|
|
|
|
<div key={i} style={{
|
|
|
|
borderBottom: '1px solid var(--border-color)', alignItems: 'baseline',
|
|
|
|
display: 'flex', gap: '5px', padding: '3px 2px',
|
|
|
|
}}>
|
|
|
|
borderBottom: '1px solid var(--border-color)', alignItems: 'baseline',
|
|
|
|
<span style={{ color: q.band ? getBandColor(q.frequency / 1000000) : 'var(--accent-green)', fontWeight: '600', minWidth: '70px' }}>
|
|
|
|
}}>
|
|
|
|
{q.dxCall}
|
|
|
|
<span style={{
|
|
|
|
</span>
|
|
|
|
color: q.band ? getBandColor(q.frequency / 1000000) : 'var(--accent-green)',
|
|
|
|
<span style={{ color: 'var(--text-muted)', minWidth: '40px', fontSize: '10px' }}>{q.band}</span>
|
|
|
|
fontWeight: '600', minWidth: '65px'
|
|
|
|
<span style={{ color: 'var(--text-muted)', minWidth: '30px', fontSize: '10px' }}>{q.mode}</span>
|
|
|
|
}}>{q.dxCall}</span>
|
|
|
|
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.reportSent}/{q.reportRecv}</span>
|
|
|
|
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.band}</span>
|
|
|
|
{q.dxGrid && <span style={{ color: '#a78bfa', fontSize: '10px' }}>{q.dxGrid}</span>}
|
|
|
|
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.mode}</span>
|
|
|
|
</div>
|
|
|
|
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.reportSent}/{q.reportRecv}</span>
|
|
|
|
))
|
|
|
|
{q.dxGrid && <span style={{ color: '#a78bfa', fontSize: '10px' }}>{q.dxGrid}</span>}
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</>
|
|
|
|
))
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* WSJT-X status bar */}
|
|
|
|
{/* ── WSJT-X status footer ── */}
|
|
|
|
{activeClients.length > 0 && (
|
|
|
|
{panelMode === 'wsjtx' && activeClients.length > 0 && (
|
|
|
|
<div style={{
|
|
|
|
<div style={{
|
|
|
|
fontSize: '9px', color: 'var(--text-muted)',
|
|
|
|
fontSize: '9px', color: 'var(--text-muted)',
|
|
|
|
borderTop: '1px solid var(--border-color)',
|
|
|
|
borderTop: '1px solid var(--border-color)',
|
|
|
|
paddingTop: '3px', marginTop: '3px',
|
|
|
|
paddingTop: '2px', marginTop: '2px',
|
|
|
|
display: 'flex', justifyContent: 'space-between', flexShrink: 0
|
|
|
|
display: 'flex', justifyContent: 'space-between', flexShrink: 0
|
|
|
|
}}>
|
|
|
|
}}>
|
|
|
|
<span>
|
|
|
|
<span>{activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')}</span>
|
|
|
|
{activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')}
|
|
|
|
{primaryClient?.dialFrequency && (
|
|
|
|
</span>
|
|
|
|
<span style={{ color: '#a78bfa' }}>{(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz</span>
|
|
|
|
{primaryClient?.dialFrequency && (
|
|
|
|
|
|
|
|
<span style={{ color: '#a78bfa' }}>
|
|
|
|
|
|
|
|
{(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default PSKReporterPanel;
|
|
|
|
export default PSKReporterPanel;
|
|
|
|
|
|
|
|
|
|
|
|
export { PSKReporterPanel };
|
|
|
|
export { PSKReporterPanel };
|
|
|
|
|