@ -1,7 +1,11 @@
/ * *
* PSKReporter Panel
* Shows where your digital mode signals are being received
* Uses MQTT WebSocket for real - time data
* PSKReporter + WSJT - X Panel
* Digital mode spots — toggle between internet ( PSKReporter ) and local ( WSJT - X UDP )
*
* 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 { usePSKReporter } from '../hooks/usePSKReporter.js' ;
@ -13,42 +17,43 @@ const PSKReporterPanel = ({
showOnMap ,
onToggleMap ,
filters = { } ,
onOpenFilters
onOpenFilters ,
/ / W S J T - X p r o p s
wsjtxDecodes = [ ] ,
wsjtxClients = { } ,
wsjtxQsos = [ ] ,
wsjtxStats = { } ,
wsjtxLoading ,
wsjtxEnabled ,
wsjtxPort ,
wsjtxRelayEnabled ,
showWSJTXOnMap ,
onToggleWSJTXMap
} ) => {
const [ activeTab , setActiveTab ] = useState ( 'tx' ) ; / / D e f a u l t t o ' t x ' ( B e i n g H e a r d )
const [ panelMode , setPanelMode ] = useState ( 'psk' ) ;
const [ activeTab , setActiveTab ] = useState ( 'tx' ) ;
const [ wsjtxTab , setWsjtxTab ] = useState ( 'decodes' ) ;
const [ wsjtxFilter , setWsjtxFilter ] = useState ( 'all' ) ; / / ' a l l ' | ' c q ' | b a n d n a m e
/ / P S K R e p o r t e r h o o k
const {
txReports ,
txCount ,
rxReports ,
rxCount ,
loading ,
error ,
connected ,
source ,
refresh
txReports , txCount , rxReports , rxCount ,
loading , error , connected , source , refresh
} = usePSKReporter ( callsign , {
minutes : 15 ,
enabled : callsign && callsign !== 'N0CALL'
} ) ;
/ / Fi l t e r r e p o r t s b y b a n d , g r i d , a n d m o d e
/ / ─ ─ P S K f i l t e r i n g ─ ─
const filterReports = ( reports ) => {
return reports . filter ( r => {
/ / B a n d f i l t e r
if ( filters ? . bands ? . length && ! filters . bands . includes ( r . band ) ) return false ;
/ / G r i d f i l t e r ( p r e f i x m a t c h )
if ( filters ? . grids ? . length ) {
const grid = activeTab === 'tx' ? r . receiverGrid : r . senderGrid ;
if ( ! grid ) return false ;
const gridPrefix = grid . substring ( 0 , 2 ) . toUpperCase ( ) ;
if ( ! filters . grids . includes ( gridPrefix ) ) return false ;
if ( ! filters . grids . includes ( grid . substring ( 0 , 2 ) . toUpperCase ( ) ) ) return false ;
}
/ / M o d e f i l t e r
if ( filters ? . modes ? . length && ! filters . modes . includes ( r . mode ) ) return false ;
return true ;
} ) ;
} ;
@ -56,203 +61,235 @@ const PSKReporterPanel = ({
const filteredTx = useMemo ( ( ) => filterReports ( txReports ) , [ txReports , filters , activeTab ] ) ;
const filteredRx = useMemo ( ( ) => filterReports ( rxReports ) , [ rxReports , filters , activeTab ] ) ;
const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx ;
const pskFilterCount = [ filters ? . bands ? . length , filters ? . grids ? . length , filters ? . modes ? . length ] . filter ( Boolean ) . length ;
/ / C o u n t a c t i v e f i l t e r s
const getActiveFilterCount = ( ) => {
let count = 0 ;
if ( filters ? . bands ? . length ) count ++ ;
if ( filters ? . grids ? . length ) count ++ ;
if ( filters ? . modes ? . length ) count ++ ;
return count ;
} ;
const filterCount = getActiveFilterCount ( ) ;
const getFreqColor = ( freqMHz ) => ! freqMHz ? 'var(--text-muted)' : getBandColor ( parseFloat ( freqMHz ) ) ;
const formatAge = ( m ) => m < 1 ? 'now' : m < 60 ? ` ${ m } m ` : ` ${ Math . floor ( m / 60 ) } h ` ;
/ / G e t b a n d c o l o r f r o m f r e q u e n c y
const getFreqColor = ( freqMHz ) => {
if ( ! freqMHz ) return 'var(--text-muted)' ;
const freq = parseFloat ( freqMHz ) ;
return getBandColor ( freq ) ;
} ;
/ / ─ ─ W S J T - X h e l p e r s ─ ─
const activeClients = Object . entries ( wsjtxClients ) ;
const primaryClient = activeClients [ 0 ] ? . [ 1 ] || null ;
/ / F o r m a t a g e
const formatAge = ( minutes ) => {
if ( minutes < 1 ) return 'now' ;
if ( minutes < 60 ) return ` ${ minutes } m ` ;
return ` ${ Math . floor ( minutes / 60 ) } h ` ;
} ;
/ / B u i l d u n i f i e d f i l t e r o p t i o n s : A l l , C Q O n l y , t h e n e a c h a v a i l a b l e b a n d
const wsjtxFilterOptions = useMemo ( ( ) => {
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 ] ) ;
/ / G e t s t a t u s i n d i c a t o r
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 > ;
const filteredDecodes = useMemo ( ( ) => {
let filtered = [ ... wsjtxDecodes ] ;
if ( wsjtxFilter === 'cq' ) {
filtered = filtered . filter ( d => d . type === 'CQ' ) ;
} else if ( wsjtxFilter !== 'all' ) {
/ / B a n d f i l t e r
filtered = filtered . filter ( d => d . band === wsjtxFilter ) ;
}
return null ;
return filtered . reverse ( ) ;
} , [ wsjtxDecodes , wsjtxFilter ] ) ;
const getSnrColor = ( snr ) => {
if ( snr == null ) return 'var(--text-muted)' ;
if ( snr >= 0 ) return '#4ade80' ;
if ( snr >= - 10 ) return '#fbbf24' ;
if ( snr >= - 18 ) return '#fb923c' ;
return '#ef4444' ;
} ;
if ( ! callsign || callsign === 'N0CALL' ) {
return (
< div className = "panel" style = { { padding : '10px' } } >
< div style = { { fontSize : '12px' , color : 'var(--accent-primary)' , fontWeight : '700' , marginBottom : '6px' } } >
📡 PSKReporter
< / div >
< div style = { { color : 'var(--text-muted)' , textAlign : 'center' , padding : '10px' , fontSize : '11px' } } >
Set callsign in Settings
< / div >
< / div >
) ;
}
const getMsgColor = ( d ) => {
if ( d . type === 'CQ' ) return '#60a5fa' ;
if ( [ 'RR73' , '73' , 'RRR' ] . includes ( d . exchange ) ) return '#4ade80' ;
if ( d . exchange ? . startsWith ( 'R' ) ) return '#fbbf24' ;
return 'var(--text-primary)' ;
} ;
/ / A c t i v e m a p t o g g l e f o r c u r r e n t m o d e
const isMapOn = panelMode === 'psk' ? showOnMap : showWSJTXOnMap ;
const handleMapToggle = panelMode === 'psk' ? onToggleMap : onToggleWSJTXMap ;
/ / C o m p a c t s t a t u s d o t
const statusDot = connected
? { color : '#4ade80' , char : '●' }
: ( source === 'connecting' || source === 'reconnecting' )
? { color : '#fbbf24' , char : '◐' }
: error ? { color : '#ef4444' , char : '●' } : null ;
/ / ─ ─ S h a r e d s t y l e s ─ ─
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' ,
fontSize : '10px' ,
cursor : 'pointer' ,
lineHeight : 1 ,
} ) ;
return (
< div className = "panel" style = { {
padding : '10px' ,
padding : ' 8px 10px',
display : 'flex' ,
flexDirection : 'column' ,
height : '100%' ,
overflow : 'hidden'
} } >
{ /* Header */ }
{ /* ── Row 1: Mode toggle + controls ── */}
< div style = { {
fontSize : '12px' ,
color : 'var(--accent-primary)' ,
fontWeight : '700' ,
marginBottom : '6px' ,
display : 'flex' ,
alignItems : 'center' ,
justifyContent : 'space-between' ,
alignItems : 'center'
marginBottom : '5px' ,
flexShrink : 0 ,
} } >
< span > 📡 PSKReporter { getStatusIndicator ( ) } < / span >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '6px' } } >
< span style = { { fontSize : '9px' , color : 'var(--text-muted)' } } >
{ filteredReports . length } / { activeTab === 'tx' ? txCount : rxCount }
< / span >
< button
onClick = { onOpenFilters }
style = { {
background : filterCount > 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)' ,
border : ` 1px solid ${ filterCount > 0 ? '#ffaa00' : '#666' } ` ,
color : filterCount > 0 ? '#ffaa00' : '#888' ,
padding : '2px 8px' ,
borderRadius : '4px' ,
fontSize : '10px' ,
fontFamily : 'JetBrains Mono' ,
cursor : 'pointer'
} }
>
🔍 Filters
{ /* Mode toggle */ }
< div style = { { display : 'flex' } } >
< button onClick = { ( ) => setPanelMode ( 'psk' ) } style = { segBtn ( panelMode === 'psk' , 'var(--accent-primary)' ) } >
PSKReporter
< / button >
< button
onClick = { refresh }
disabled = { loading }
title = { connected ? 'Reconnect' : 'Connect' }
style = { {
background : 'rgba(100, 100, 100, 0.3)' ,
border : '1px solid #666' ,
color : '#888' ,
padding : '2px 6px' ,
borderRadius : '4px' ,
fontSize : '10px' ,
cursor : loading ? 'not-allowed' : 'pointer' ,
opacity : loading ? 0.5 : 1
} }
>
🔄
< button onClick = { ( ) => setPanelMode ( 'wsjtx' ) } style = { segBtn ( panelMode === 'wsjtx' , '#a78bfa' ) } >
WSJT - X
< / button >
{ onToggleMap && (
< button
onClick = { onToggleMap }
< / div >
{ /* Right controls */ }
< div style = { { display : 'flex' , alignItems : 'center' , gap : '4px' } } >
{ /* PSK: status dot + filter + refresh */ }
{ panelMode === 'psk' && (
< >
{ statusDot && (
< span style = { { color : statusDot . color , fontSize : '10px' , lineHeight : 1 } } > { statusDot . char } < / span >
) }
< button onClick = { onOpenFilters } style = { iconBtn ( pskFilterCount > 0 , '#ffaa00' ) } >
{ pskFilterCount > 0 ? ` 🔍 ${ pskFilterCount } ` : '🔍' }
< / button >
< button onClick = { refresh } disabled = { loading } style = { {
... iconBtn ( false ) ,
opacity : loading ? 0.4 : 1 ,
cursor : loading ? 'not-allowed' : 'pointer' ,
} } > 🔄 < / button >
< / >
) }
{ /* WSJT-X: mode/band info + unified filter */ }
{ panelMode === 'wsjtx' && (
< >
{ primaryClient && (
< span style = { { fontSize : '9px' , color : 'var(--text-muted)' } } >
{ primaryClient . mode } { primaryClient . band }
{ primaryClient . transmitting && < span style = { { color : '#ef4444' , marginLeft : '2px' } } > TX < / span > }
< / span >
) }
< select
value = { wsjtxFilter }
onChange = { ( e ) => setWsjtxFilter ( e . target . value ) }
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 8px' ,
borderRadius : '4px' ,
background : 'var(--bg-tertiary)' ,
color : wsjtxFilter !== 'all' ? '#a78bfa' : 'var(--text-primary)' ,
border : ` 1px solid ${ wsjtxFilter !== 'all' ? '#a78bfa55' : 'var(--border-color)' } ` ,
borderRadius : '3px' ,
fontSize : '10px' ,
fontFamily : 'JetBrains Mono' ,
cursor : 'pointer'
padding : '1px 4px' ,
cursor : 'pointer' ,
maxWidth : '90px' ,
} }
>
🗺 ️ { showOnMap ? 'ON' : 'OFF' }
{ wsjtxFilterOptions . map ( o => (
< option key = { o . value } value = { o . value } > { o . label } < / option >
) ) }
< / select >
< / >
) }
{ /* Map toggle (always visible) */ }
{ handleMapToggle && (
< button onClick = { handleMapToggle } style = { iconBtn ( isMapOn , panelMode === 'psk' ? '#4488ff' : '#a78bfa' ) } >
🗺 ️
< / button >
) }
< / div >
< / div >
{ /* Tabs */ }
< div style = { {
display : 'flex' ,
gap : '4px' ,
marginBottom : '6px'
} } >
< 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)' ,
border : ` 1px solid ${ activeTab === 'tx' ? '#4ade80' : '#555' } ` ,
borderRadius : '3px' ,
color : activeTab === 'tx' ? '#4ade80' : '#888' ,
cursor : 'pointer' ,
fontSize : '10px' ,
fontFamily : 'JetBrains Mono'
} }
>
📤 Being Heard ( { filterCount > 0 ? ` ${ filteredTx . length } ` : txCount } )
{ /* ── Row 2: Sub-tabs ── */ }
< div style = { { display : 'flex' , gap : '4px' , marginBottom : '5px' , flexShrink : 0 } } >
{ panelMode === 'psk' ? (
< >
< button onClick = { ( ) => setActiveTab ( 'tx' ) } style = { subTabBtn ( activeTab === 'tx' , '#4ade80' ) } >
▲ Heard ( { pskFilterCount > 0 ? filteredTx . length : txCount } )
< / button >
< button
onClick = { ( ) => setActiveTab ( 'rx' ) }
style = { {
flex : 1 ,
padding : '4px 6px' ,
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 onClick = { ( ) => setActiveTab ( 'rx' ) } style = { subTabBtn ( activeTab === 'rx' , '#60a5fa' ) } >
▼ Hearing ( { pskFilterCount > 0 ? filteredRx . length : rxCount } )
< / button >
< / >
) : (
< >
< button onClick = { ( ) => setWsjtxTab ( 'decodes' ) } style = { subTabBtn ( wsjtxTab === 'decodes' , '#a78bfa' ) } >
Decodes ( { filteredDecodes . length } )
< / button >
< button onClick = { ( ) => setWsjtxTab ( 'qsos' ) } style = { subTabBtn ( wsjtxTab === 'qsos' , '#a78bfa' ) } >
QSOs ( { wsjtxQsos . length } )
< / button >
< / >
) }
< / div >
{ /* Reports list */ }
{ error && ! connected ? (
< div style = { { textAlign : 'center' , padding : '10px' , color : 'var(--text-muted)' , fontSize : '11px' } } >
⚠ ️ Connection failed - click 🔄 to retry
{ /* ── Content area ── */ }
< div style = { { flex : 1 , overflow : 'auto' , fontSize : '11px' , fontFamily : "'JetBrains Mono', monospace" } } >
{ /* === PSKReporter content === */ }
{ panelMode === 'psk' && (
< >
{ ( ! callsign || callsign === 'N0CALL' ) ? (
< div style = { { color : 'var(--text-muted)' , textAlign : 'center' , padding : '16px' , fontSize : '11px' } } >
Set your callsign in Settings to see reports
< / 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 ...
) : error && ! connected ? (
< div style = { { textAlign : 'center' , padding : '12px' , color : 'var(--text-muted)' , fontSize : '11px' } } >
Connection failed — tap 🔄
< / div >
) : ! connected && filteredReports . length === 0 && filterCount === 0 ? (
< div style = { { textAlign : 'center' , padding : '10px' , color : 'var(--text-muted)' , fontSize : '11px' } } >
Waiting for connection ...
) : loading && filteredReports . length === 0 && pskFilterCount === 0 ? (
< div style = { { textAlign : 'center' , padding : '16px' , color : 'var(--text-muted)' , fontSize : '11px' } } >
< div className = "loading-spinner" style = { { margin : '0 auto 8px' } } / >
Connecting ...
< / div >
) : filteredReports . length === 0 ? (
< div style = { { textAlign : 'center' , padding : '1 0 px', color : 'var(--text-muted)' , fontSize : '11px' } } >
{ f ilterCount > 0
< div style = { { textAlign : 'center' , padding : '1 2 px', color : 'var(--text-muted)' , fontSize : '11px' } } >
{ pskF ilterCount > 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 ) => {
filteredReports . slice ( 0 , 25 ) . 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 ;
@ -261,58 +298,167 @@ const PSKReporterPanel = ({
return (
< div
key = { ` ${ displayCall } - ${ report . freq } - ${ i } ` }
onClick = { ( ) => onShowOnMap && report . lat && report . lon && onShowOnMap ( report ) }
onClick = { ( ) => onShowOnMap ? . ( report ) }
style = { {
display : 'grid' ,
gridTemplateColumns : '55px 1fr auto' ,
gap : '6px' ,
padding : '4px 6px' ,
borderRadius : '3px' ,
marginBottom : '2px' ,
background : i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent' ,
gridTemplateColumns : '52px 1fr auto' ,
gap : '5px' ,
padding : '3px 4px' ,
borderRadius : '2px' ,
background : i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent' ,
cursor : report . lat && report . lon ? 'pointer' : 'default' ,
transition : 'background 0.15s' ,
borderLeft : '2px solid transparent'
} }
onMouseEnter = { ( e ) => e . currentTarget . style . background = 'rgba(68, 136, 255, 0.15 )'}
onMouseLeave = { ( e ) => e . currentTarget . style . background = i % 2 === 0 ? 'rgba(255,255,255,0.0 3 )' : 'transparent' }
onMouseEnter = { ( e ) => e . currentTarget . style . background = 'rgba(68,136,255,0.12)' }
onMouseLeave = { ( e ) => e . currentTarget . style . background = i % 2 === 0 ? 'rgba(255,255,255,0.0 2 )' : 'transparent' }
>
< div style = { { color , fontWeight : '600' , fontSize : '11px' } } >
{ freqMHz }
< / div >
< div style = { {
color : 'var(--text-primary)' ,
fontWeight : '600' ,
overflow : 'hidden' ,
textOverflow : 'ellipsis' ,
whiteSpace : 'nowrap' ,
fontSize : '11px'
< span style = { { color , fontWeight : '600' , fontSize : '10px' } } > { freqMHz } < / span >
< span style = { {
color : 'var(--text-primary)' , fontWeight : '600' , fontSize : '11px' ,
overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap'
} } >
{ displayCall }
{ grid && < span style = { { color : 'var(--text-muted)' , fontWeight : '400' , marginLeft : '4px' , fontSize : '9px' } } > { grid } < / span > }
< / div >
< div style = { {
display : 'flex' ,
alignItems : 'center' ,
gap : '4px' ,
fontSize : '10px'
} } >
< / span >
< span style = { { display : 'flex' , alignItems : 'center' , gap : '3px' , fontSize : '9px' } } >
< span style = { { color : 'var(--text-muted)' } } > { report . mode } < / span >
{ report . snr !== null && report . snr !== undefined && (
< span style = { {
color : report . snr >= 0 ? '#4ade80' : report . snr >= - 10 ? '#fbbf24' : '#f97316' ,
fontWeight : '600'
} } >
{ 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)' , fontSize : '9px' } } >
{ formatAge ( report . age ) }
< span style = { { color : 'var(--text-muted)' } } > { formatAge ( report . age ) } < / span >
< / span >
< / div >
< / div >
) ;
} ) }
} )
) }
< / >
) }
{ /* === WSJT-X content === */ }
{ panelMode === 'wsjtx' && (
< >
{ /* No client connected */ }
{ ! wsjtxLoading && activeClients . length === 0 && wsjtxDecodes . length === 0 ? (
< div style = { {
display : 'flex' , alignItems : 'center' , justifyContent : 'center' ,
flexDirection : 'column' , gap : '8px' , color : 'var(--text-muted)' ,
fontSize : '11px' , textAlign : 'center' , padding : '16px 8px' , height : '100%'
} } >
< div style = { { fontSize : '12px' } } > Waiting for WSJT - X ... < / div >
{ wsjtxRelayEnabled ? (
< div style = { { fontSize : '10px' , opacity : 0.8 , lineHeight : 1.6 } } >
< div style = { { marginBottom : '8px' } } >
Download the relay agent for your PC :
< / div >
< div style = { { display : 'flex' , gap : '4px' , justifyContent : 'center' , flexWrap : 'wrap' } } >
< a href = "/api/wsjtx/relay/download/linux"
style = { {
padding : '4px 10px' , borderRadius : '4px' , fontSize : '10px' , fontWeight : '600' ,
background : 'rgba(167,139,250,0.2)' , border : '1px solid #a78bfa55' ,
color : '#a78bfa' , textDecoration : 'none' , cursor : 'pointer' ,
} } > 🐧 Linux < / a >
< a href = "/api/wsjtx/relay/download/mac"
style = { {
padding : '4px 10px' , borderRadius : '4px' , fontSize : '10px' , fontWeight : '600' ,
background : 'rgba(167,139,250,0.2)' , border : '1px solid #a78bfa55' ,
color : '#a78bfa' , textDecoration : 'none' , cursor : 'pointer' ,
} } > 🍎 Mac < / a >
< a href = "/api/wsjtx/relay/download/windows"
style = { {
padding : '4px 10px' , borderRadius : '4px' , fontSize : '10px' , fontWeight : '600' ,
background : 'rgba(167,139,250,0.2)' , border : '1px solid #a78bfa55' ,
color : '#a78bfa' , textDecoration : 'none' , cursor : 'pointer' ,
} } > 🪟 Windows < / a >
< / div >
< div style = { { fontSize : '9px' , opacity : 0.5 , marginTop : '6px' } } >
Requires Node . js · Run the script , then start WSJT - X
< / div >
< / div >
) : (
< div style = { { fontSize : '10px' , opacity : 0.6 , lineHeight : 1.5 } } >
In WSJT - X : Settings → Reporting → UDP Server
< br / >
Address : 127.0 .0 .1 & nbsp ; Port : { wsjtxPort || 2237 }
< / div >
) }
< / div >
) : wsjtxTab === 'decodes' ? (
< >
{ filteredDecodes . length === 0 ? (
< div style = { { color : 'var(--text-muted)' , textAlign : 'center' , padding : '16px' , fontSize : '11px' } } >
{ wsjtxDecodes . length > 0 ? 'No decodes match filter' : 'Listening...' }
< / div >
) : (
filteredDecodes . map ( ( d , i ) => (
< div
key = { d . id || i }
style = { {
display : 'flex' , gap : '5px' , padding : '2px 2px' ,
borderBottom : '1px solid var(--border-color)' ,
alignItems : 'baseline' ,
opacity : d . lowConfidence ? 0.5 : 1 ,
} }
>
< span style = { { color : 'var(--text-muted)' , minWidth : '44px' , fontSize : '10px' } } > { d . time } < / span >
< span style = { { color : getSnrColor ( d . snr ) , minWidth : '26px' , textAlign : 'right' , fontSize : '10px' } } >
{ d . snr != null ? ( d . snr >= 0 ? ` + ${ d . snr } ` : d . snr ) : '' }
< / span >
< span style = { { color : 'var(--text-muted)' , minWidth : '24px' , textAlign : 'right' , fontSize : '10px' } } > { d . dt } < / span >
< span style = { {
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 >
) )
) }
< / >
) : (
/* QSOs tab */
< >
{ wsjtxQsos . length === 0 ? (
< div style = { { color : 'var(--text-muted)' , textAlign : 'center' , padding : '16px' , fontSize : '11px' } } >
No QSOs logged yet
< / div >
) : (
[ ... wsjtxQsos ] . reverse ( ) . map ( ( q , i ) => (
< div key = { i } style = { {
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 : '65px'
} } > { q . dxCall } < / span >
< span style = { { color : 'var(--text-muted)' , fontSize : '10px' } } > { q . band } < / span >
< span style = { { color : 'var(--text-muted)' , fontSize : '10px' } } > { q . mode } < / span >
< 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 >
{ /* ── WSJT-X status footer ── */ }
{ panelMode === 'wsjtx' && activeClients . length > 0 && (
< div style = { {
fontSize : '9px' , color : 'var(--text-muted)' ,
borderTop : '1px solid var(--border-color)' ,
paddingTop : '2px' , marginTop : '2px' ,
display : 'flex' , justifyContent : 'space-between' , flexShrink : 0
} } >
< span > { activeClients . map ( ( [ id , c ] ) => ` ${ id } ${ c . version ? ` v ${ c . version } ` : '' } ` ) . join ( ', ' ) } < / span >
{ primaryClient ? . dialFrequency && (
< span style = { { color : '#a78bfa' } } > { ( primaryClient . dialFrequency / 1000000 ) . toFixed ( 6 ) } MHz < / span >
) }
< / div >
) }
< / div >
@ -320,5 +466,4 @@ const PSKReporterPanel = ({
} ;
export default PSKReporterPanel ;
export { PSKReporterPanel } ;