news scroller

pull/46/head
accius 2 days ago
parent ec0ec3af80
commit bac64fc86b

@ -703,6 +703,74 @@ app.get('/api/noaa/aurora', async (req, res) => {
} }
}); });
// DX News from dxnews.com
let dxNewsCache = { data: null, timestamp: 0 };
const DXNEWS_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
app.get('/api/dxnews', async (req, res) => {
try {
if (dxNewsCache.data && (Date.now() - dxNewsCache.timestamp) < DXNEWS_CACHE_TTL) {
return res.json(dxNewsCache.data);
}
const response = await fetch('https://dxnews.com/', {
headers: { 'User-Agent': 'OpenHamClock/1.0 (amateur radio dashboard)' }
});
const html = await response.text();
// Parse news items from HTML
const items = [];
// Match pattern: <h3><a href="URL" title="TITLE">TITLE</a></h3> followed by date and description
const articleRegex = /<h3[^>]*>\s*<a\s+href="([^"]+)"\s+title="([^"]+)"[^>]*>[^<]*<\/a>\s*<\/h3>\s*[\s\S]*?(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s*[\s\S]*?<\/li>\s*<\/ul>\s*([\s\S]*?)(?:<ul|<div\s+class="more"|<\/div>)/g;
// Simpler approach: split by article blocks
const blocks = html.split(/<h3[^>]*>\s*<a\s+href="/);
for (let i = 1; i < blocks.length && items.length < 20; i++) {
try {
const block = blocks[i];
// Extract URL
const urlMatch = block.match(/^([^"]+)"/);
// Extract title
const titleMatch = block.match(/title="([^"]+)"/);
// Extract date
const dateMatch = block.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/);
// Extract description - text after the date, before "Views" or next element
const descParts = block.split(/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/);
let desc = '';
if (descParts[1]) {
// Get text content, strip HTML tags
desc = descParts[1]
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.replace(/Views\d+.*$/, '')
.replace(/More\.\.\..*$/, '')
.trim()
.substring(0, 200);
}
if (titleMatch && urlMatch) {
items.push({
title: titleMatch[1],
url: 'https://dxnews.com/' + urlMatch[1],
date: dateMatch ? dateMatch[1] : null,
description: desc || titleMatch[1]
});
}
} catch (e) {
// Skip malformed entries
}
}
const result = { items, fetched: new Date().toISOString() };
dxNewsCache = { data: result, timestamp: Date.now() };
res.json(result);
} catch (error) {
console.error('DX News fetch error:', error.message);
if (dxNewsCache.data) return res.json(dxNewsCache.data);
res.status(500).json({ error: 'Failed to fetch DX news', items: [] });
}
});
// POTA Spots // POTA Spots
// POTA cache (2 minutes) // POTA cache (2 minutes)
let potaCache = { data: null, timestamp: 0 }; let potaCache = { data: null, timestamp: 0 };

@ -0,0 +1,176 @@
/**
* DXNewsTicker Component
* Scrolling news banner showing latest DX news headlines from dxnews.com
*/
import React, { useState, useEffect, useRef } from 'react';
export const DXNewsTicker = () => {
const [news, setNews] = useState([]);
const [loading, setLoading] = useState(true);
const tickerRef = useRef(null);
const contentRef = useRef(null);
const [animDuration, setAnimDuration] = useState(120);
// Fetch news
useEffect(() => {
const fetchNews = async () => {
try {
const res = await fetch('/api/dxnews');
if (res.ok) {
const data = await res.json();
if (data.items && data.items.length > 0) {
setNews(data.items);
}
}
} catch (err) {
console.error('DX News ticker fetch error:', err);
} finally {
setLoading(false);
}
};
fetchNews();
// Refresh every 30 minutes
const interval = setInterval(fetchNews, 30 * 60 * 1000);
return () => clearInterval(interval);
}, []);
// Calculate animation duration based on content width
useEffect(() => {
if (contentRef.current && tickerRef.current) {
const contentWidth = contentRef.current.scrollWidth;
const containerWidth = tickerRef.current.offsetWidth;
// ~50px per second scroll speed
const duration = Math.max(30, (contentWidth + containerWidth) / 50);
setAnimDuration(duration);
}
}, [news]);
if (loading || news.length === 0) return null;
// Build ticker text: "TITLE description TITLE description ..."
const tickerItems = news.map(item => ({
title: item.title,
desc: item.description
}));
return (
<div
ref={tickerRef}
style={{
position: 'absolute',
bottom: '8px',
left: '8px',
right: '50%',
height: '28px',
background: 'rgba(0, 0, 0, 0.85)',
border: '1px solid #444',
borderRadius: '6px',
overflow: 'hidden',
zIndex: 999,
display: 'flex',
alignItems: 'center'
}}
>
{/* DX NEWS label */}
<div style={{
background: 'rgba(255, 136, 0, 0.9)',
color: '#000',
fontWeight: '700',
fontSize: '10px',
fontFamily: 'JetBrains Mono, monospace',
padding: '0 8px',
height: '100%',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
borderRight: '1px solid #444',
letterSpacing: '0.5px'
}}>
📰 DX NEWS
</div>
{/* Scrolling content */}
<div style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%',
maskImage: 'linear-gradient(to right, transparent 0%, black 3%, black 97%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to right, transparent 0%, black 3%, black 97%, transparent 100%)'
}}>
<div
ref={contentRef}
style={{
display: 'inline-flex',
alignItems: 'center',
height: '100%',
whiteSpace: 'nowrap',
animation: `dxnews-scroll ${animDuration}s linear infinite`,
paddingLeft: '100%'
}}
>
{tickerItems.map((item, i) => (
<span key={i} style={{ display: 'inline-flex', alignItems: 'center' }}>
<span style={{
color: '#ff8800',
fontWeight: '700',
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace',
marginRight: '6px'
}}>
{item.title}
</span>
<span style={{
color: '#aaa',
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace',
marginRight: '12px'
}}>
{item.desc}
</span>
<span style={{
color: '#555',
fontSize: '10px',
marginRight: '12px'
}}>
</span>
</span>
))}
{/* Duplicate for seamless loop */}
{tickerItems.map((item, i) => (
<span key={`dup-${i}`} style={{ display: 'inline-flex', alignItems: 'center' }}>
<span style={{
color: '#ff8800',
fontWeight: '700',
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace',
marginRight: '6px'
}}>
{item.title}
</span>
<span style={{
color: '#aaa',
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace',
marginRight: '12px'
}}>
{item.desc}
</span>
<span style={{
color: '#555',
fontSize: '10px',
marginRight: '12px'
}}>
</span>
</span>
))}
</div>
</div>
</div>
);
};
export default DXNewsTicker;

@ -14,6 +14,7 @@ import { filterDXPaths, getBandColor } from '../utils/callsign.js';
import { getAllLayers } from '../plugins/layerRegistry.js'; import { getAllLayers } from '../plugins/layerRegistry.js';
import PluginLayer from './PluginLayer.jsx'; import PluginLayer from './PluginLayer.jsx';
import { DXNewsTicker } from './DXNewsTicker.jsx';
export const WorldMap = ({ export const WorldMap = ({
@ -732,22 +733,26 @@ export const WorldMap = ({
</button> </button>
)} )}
{/* Legend */} {/* DX News Ticker - left side of bottom bar */}
<DXNewsTicker />
{/* Legend - right side */}
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
bottom: '8px', bottom: '8px',
left: '50%', right: '8px',
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.85)', background: 'rgba(0, 0, 0, 0.85)',
border: '1px solid #444', border: '1px solid #444',
borderRadius: '6px', borderRadius: '6px',
padding: '8px 14px', padding: '6px 10px',
zIndex: 1000, zIndex: 1000,
display: 'flex', display: 'flex',
gap: '10px', gap: '8px',
alignItems: 'center', alignItems: 'center',
fontSize: '12px', fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace' fontFamily: 'JetBrains Mono, monospace',
flexWrap: 'nowrap',
maxWidth: '50%'
}}> }}>
{showDXPaths && ( {showDXPaths && (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>

@ -18,3 +18,4 @@ export { SolarPanel } from './SolarPanel.jsx';
export { PropagationPanel } from './PropagationPanel.jsx'; export { PropagationPanel } from './PropagationPanel.jsx';
export { DXpeditionPanel } from './DXpeditionPanel.jsx'; export { DXpeditionPanel } from './DXpeditionPanel.jsx';
export { PSKReporterPanel } from './PSKReporterPanel.jsx'; export { PSKReporterPanel } from './PSKReporterPanel.jsx';
export { DXNewsTicker } from './DXNewsTicker.jsx';

@ -323,6 +323,7 @@ body::before {
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
@keyframes dxnews-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
.loading-spinner { .loading-spinner {
width: 14px; height: 14px; width: 14px; height: 14px;

Loading…
Cancel
Save

Powered by TurnKey Linux.