From bac64fc86bb8d3a413788695c45a3be22ea123c5 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 20:22:34 -0500 Subject: [PATCH] news scroller --- server.js | 68 ++++++++++++ src/components/DXNewsTicker.jsx | 176 ++++++++++++++++++++++++++++++++ src/components/WorldMap.jsx | 19 ++-- src/components/index.js | 1 + src/styles/main.css | 1 + 5 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 src/components/DXNewsTicker.jsx diff --git a/server.js b/server.js index 213b9d0..11c86c3 100644 --- a/server.js +++ b/server.js @@ -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:

TITLE

followed by date and description + const articleRegex = /]*>\s*]*>[^<]*<\/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]*?)(?:)/g; + + // Simpler approach: split by article blocks + const blocks = html.split(/]*>\s*]+>/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 cache (2 minutes) let potaCache = { data: null, timestamp: 0 }; diff --git a/src/components/DXNewsTicker.jsx b/src/components/DXNewsTicker.jsx new file mode 100644 index 0000000..4e27c02 --- /dev/null +++ b/src/components/DXNewsTicker.jsx @@ -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 ( +
+ {/* DX NEWS label */} +
+ 📰 DX NEWS +
+ + {/* Scrolling content */} +
+
+ {tickerItems.map((item, i) => ( + + + {item.title} + + + {item.desc} + + + ◆ + + + ))} + {/* Duplicate for seamless loop */} + {tickerItems.map((item, i) => ( + + + {item.title} + + + {item.desc} + + + ◆ + + + ))} +
+
+
+ ); +}; + +export default DXNewsTicker; diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 4bfb23f..8b1caa8 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -14,6 +14,7 @@ import { filterDXPaths, getBandColor } from '../utils/callsign.js'; import { getAllLayers } from '../plugins/layerRegistry.js'; import PluginLayer from './PluginLayer.jsx'; +import { DXNewsTicker } from './DXNewsTicker.jsx'; export const WorldMap = ({ @@ -732,22 +733,26 @@ export const WorldMap = ({ )} - {/* Legend */} + {/* DX News Ticker - left side of bottom bar */} + + + {/* Legend - right side */}
{showDXPaths && (
diff --git a/src/components/index.js b/src/components/index.js index 3093e72..e0294c4 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -18,3 +18,4 @@ export { SolarPanel } from './SolarPanel.jsx'; export { PropagationPanel } from './PropagationPanel.jsx'; export { DXpeditionPanel } from './DXpeditionPanel.jsx'; export { PSKReporterPanel } from './PSKReporterPanel.jsx'; +export { DXNewsTicker } from './DXNewsTicker.jsx'; diff --git a/src/styles/main.css b/src/styles/main.css index cec6b54..ef225f9 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -323,6 +323,7 @@ body::before { @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 blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } +@keyframes dxnews-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } .loading-spinner { width: 14px; height: 14px;