Add news feed panel to dashboard: exec summary pinned + scrolling headlines + 15min auto-refresh
Some checks failed
build-and-deploy / build (push) Has been cancelled
Some checks failed
build-and-deploy / build (push) Has been cancelled
- New News tab consuming GET /api/news (headlines: source domain badge, relative timestamp, link-out target=_blank) and GET /api/news/summaries (latest executive summary pinned in a glowing briefing card). - Four states handled: loading, empty (summarizer idle w/o GEMINI key), error, success; HTML-escaped titles/domains (newsEsc). - Lightweight markdown renderer for summary bodies (bold/headers/lists). - Auto-refresh every 15min (NEWS_REFRESH_MS) + lazy load on tab open + manual refresh button. - Stretch hook: if articles ever carry lat/lon, headline pins plot on the GIBS map (renderNewsPins) + geotag indicator in the feed. - Dark neon styling matching existing dashboard theme.
This commit is contained in:
parent
aec3ecae9a
commit
acbf5cc56d
1 changed files with 199 additions and 2 deletions
|
|
@ -138,6 +138,53 @@
|
|||
.cam-pop td { padding: 0.12rem 0.2rem; vertical-align: top; }
|
||||
.cam-pop td.k { color: var(--muted); text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.04em; white-space: nowrap; width: 34%; }
|
||||
.cam-pop a { color: var(--accent); word-break: break-all; }
|
||||
|
||||
/* ── News panel (scraper feed + exec summaries) ─────────────── */
|
||||
.news-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-bottom: 0.9rem; flex-wrap: wrap; }
|
||||
.news-refresh { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.news-updated { font-size: 0.72rem; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.news-summary {
|
||||
position: relative; background: linear-gradient(160deg, rgba(56,189,248,0.10), rgba(30,41,59,0.6) 45%);
|
||||
border: 1px solid rgba(56,189,248,0.35); border-radius: 12px; padding: 1rem 1.25rem 1.1rem;
|
||||
box-shadow: 0 0 24px rgba(56,189,248,0.14), inset 0 0 32px rgba(56,189,248,0.05);
|
||||
margin-bottom: 1.2rem; overflow: hidden;
|
||||
}
|
||||
.news-summary::after {
|
||||
content: ''; position: absolute; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(120px 60px at 85% -10%, rgba(56,189,248,0.22), transparent 70%);
|
||||
}
|
||||
.ns-kicker { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); text-shadow: 0 0 12px rgba(56,189,248,0.6); display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ns-badge { font-size: 0.6rem; font-weight: 700; background: rgba(56,189,248,0.16); border: 1px solid rgba(56,189,248,0.4); border-radius: 9999px; padding: 0.12rem 0.5rem; color: var(--accent); letter-spacing: 0.08em; }
|
||||
.ns-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; margin: 0.35rem 0 0.6rem; }
|
||||
.ns-body { font-size: 0.92rem; line-height: 1.62; color: var(--text); white-space: pre-wrap; position: relative; z-index: 1; }
|
||||
.ns-body h1, .ns-body h2, .ns-body h3, .ns-body h4 { font-size: 1rem; margin: 0.5rem 0 0.25rem; color: var(--accent); }
|
||||
.ns-body ul, .ns-body ol { margin: 0.35rem 0 0.35rem 1.2rem; }
|
||||
.ns-body li { margin-bottom: 0.2rem; }
|
||||
.ns-body code { background: rgba(15,23,42,0.6); border: 1px solid var(--border); border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.85em; }
|
||||
.ns-empty { color: var(--muted); font-size: 0.85rem; font-style: italic; }
|
||||
.ns-loading { color: var(--muted); font-size: 0.85rem; }
|
||||
.ns-error { color: var(--red); font-size: 0.85rem; }
|
||||
.news-list-head { display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 0.4rem; }
|
||||
.news-count { font-size: 0.7rem; color: var(--accent); font-family: ui-monospace, monospace; }
|
||||
.news-list { max-height: 46vh; min-height: 120px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; background: rgba(15,23,42,0.35); }
|
||||
.news-list::-webkit-scrollbar { width: 8px; }
|
||||
.news-list::-webkit-scrollbar-track { background: rgba(30,41,59,0.4); border-radius: 8px; }
|
||||
.news-list::-webkit-scrollbar-thumb { background: rgba(56,189,248,0.35); border-radius: 8px; }
|
||||
.news-list::-webkit-scrollbar-thumb:hover { background: rgba(56,189,248,0.55); }
|
||||
.news-item {
|
||||
display: flex; align-items: center; gap: 0.75rem; padding: 0.55rem 0.9rem;
|
||||
border-bottom: 1px solid rgba(51,65,85,0.5); text-decoration: none; color: var(--text); transition: background .15s, box-shadow .15s;
|
||||
}
|
||||
.news-item:last-child { border-bottom: none; }
|
||||
.news-item:hover { background: rgba(56,189,248,0.07); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.news-dom { font-size: 0.66rem; font-family: ui-monospace, monospace; color: var(--accent); background: rgba(56,189,248,0.1); border: 1px solid rgba(56,189,248,0.25); border-radius: 9999px; padding: 0.12rem 0.5rem; white-space: nowrap; flex-shrink: 0; max-width: 30%; overflow: hidden; text-overflow: ellipsis; }
|
||||
.news-title { flex: 1; font-size: 0.87rem; line-height: 1.35; color: var(--text); word-break: break-word; }
|
||||
.news-item:hover .news-title { color: var(--accent); }
|
||||
.news-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; white-space: nowrap; flex-shrink: 0; }
|
||||
.news-geo { color: var(--green); font-size: 0.8rem; flex-shrink: 0; text-shadow: 0 0 8px rgba(74,222,128,0.6); }
|
||||
.news-loading, .news-error { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; }
|
||||
.news-error { color: var(--red); }
|
||||
.news-empty { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -185,6 +232,7 @@
|
|||
<button class="btn" onclick="showTab('ingest')">Ingest</button>
|
||||
<button class="btn" onclick="showTab('keys')">Keys</button>
|
||||
<button class="btn" onclick="showTab('map')">Map</button>
|
||||
<button class="btn" onclick="showTab('news')">News</button>
|
||||
</div>
|
||||
|
||||
<!-- Recent Events -->
|
||||
|
|
@ -323,6 +371,38 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News (scraper feed + executive summaries) -->
|
||||
<div class="section" id="tab-news" style="display:none">
|
||||
<div class="news-head">
|
||||
<div>
|
||||
<h2>Global News Feed</h2>
|
||||
<p class="sub">Scraped hourly from ~257 RSS sources · latest executive summary pinned above</p>
|
||||
</div>
|
||||
<div class="news-refresh">
|
||||
<span class="news-updated" id="news-updated">—</span>
|
||||
<button class="btn" id="news-refresh-btn" onclick="loadNews(true)">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Executive summary (pinned) -->
|
||||
<div class="news-summary" id="news-summary-wrap">
|
||||
<div class="ns-kicker">Executive Summary <span class="ns-badge" id="ns-badge">LATEST</span></div>
|
||||
<div class="ns-time" id="ns-time">—</div>
|
||||
<div class="ns-body" id="ns-body">
|
||||
<div class="ns-empty">No executive summary yet — the Gemini summarizer is idle until <code>GEMINI_API_KEY</code> is set on the Pi.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrolling headline list -->
|
||||
<div class="news-list-head">
|
||||
<span>Recent Headlines</span>
|
||||
<span class="news-count" id="news-count">0 articles</span>
|
||||
</div>
|
||||
<div class="news-list" id="news-list">
|
||||
<div class="news-loading">Loading feed…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div class="section" id="search-results" style="display:none">
|
||||
<h2>Search Results (<span id="search-total">0</span>)</h2>
|
||||
|
|
@ -496,16 +576,17 @@ async function clearKey(name) {
|
|||
}
|
||||
|
||||
function showTab(name) {
|
||||
['recent','alerts','entities','ingest','keys','map'].forEach(t => {
|
||||
['recent','alerts','entities','ingest','keys','map','news'].forEach(t => {
|
||||
document.getElementById('tab-'+t).style.display = t===name?'block':'none';
|
||||
});
|
||||
document.querySelectorAll('.tab-bar .btn').forEach((b,i) => {
|
||||
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map'][i]===name);
|
||||
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map','news'][i]===name);
|
||||
});
|
||||
if (name==='alerts') loadAlerts();
|
||||
if (name==='entities') loadEntities();
|
||||
if (name==='keys') loadKeys();
|
||||
if (name==='map') initMap();
|
||||
if (name==='news') loadNews();
|
||||
}
|
||||
|
||||
async function ingestRSS() {
|
||||
|
|
@ -549,6 +630,122 @@ async function checkHealth() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── News feed (scraper pipe + exec summaries) ─────────────────────────────
|
||||
let newsInterval = null;
|
||||
const NEWS_REFRESH_MS = 900000; // 15 min
|
||||
let newsGeo = [];
|
||||
let newsPinsLayer = null;
|
||||
|
||||
function newsEsc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g,
|
||||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function newsTimeAgo(ts) {
|
||||
if (!ts) return '—';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60000) return `${Math.floor(diff/1000)}s ago`;
|
||||
if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
|
||||
return `${Math.floor(diff/86400000)}d ago`;
|
||||
}
|
||||
function newsSimpleMD(text) {
|
||||
// Lightweight markdown→HTML for summary bodies (bold, headers, lists, paragraphs)
|
||||
if (!text) return '';
|
||||
let h = newsEsc(String(text));
|
||||
// code blocks (already escaped so match escaped backticks)
|
||||
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
// bold
|
||||
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// headers (start of line # through ##)
|
||||
h = h.replace(/^### (.+)$/gm, '<h4>$1</h4>');
|
||||
h = h.replace(/^## (.+)$/gm, '<h3>$1</h3>');
|
||||
h = h.replace(/^# (.+)$/gm, '<h2>$1</h2>');
|
||||
// unordered lists
|
||||
h = h.replace(/^[\*\-]\s(.+)$/gm, '<li>$1</li>');
|
||||
h = h.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
|
||||
// double-newline = paragraph
|
||||
h = h.replace(/\n{2,}/g, '</p><p>');
|
||||
// single newline = br
|
||||
h = h.replace(/\n/g, '<br>');
|
||||
return '<p>'+h+'</p>';
|
||||
}
|
||||
async function loadNews(force) {
|
||||
document.getElementById('news-updated').textContent = 'updating…';
|
||||
let articles = [];
|
||||
let summaries = [];
|
||||
try {
|
||||
const [rArticles, rSumm] = await Promise.all([
|
||||
fetch(`${API}/api/news?limit=100`),
|
||||
fetch(`${API}/api/news/summaries?limit=1`)
|
||||
]);
|
||||
if (rArticles.ok) articles = await rArticles.json();
|
||||
if (rSumm.ok) summaries = await rSumm.json();
|
||||
} catch(e) {
|
||||
document.getElementById('news-list').innerHTML = '<div class="news-error">Failed to load news feed: ' + newsEsc(e.message || e) + '</div>';
|
||||
document.getElementById('ns-body').innerHTML = '<div class="ns-error">Failed to load summary: ' + newsEsc(e.message || e) + '</div>';
|
||||
document.getElementById('news-updated').textContent = 'error';
|
||||
console.error('News load failed', e);
|
||||
return;
|
||||
}
|
||||
renderNewsSummary(summaries);
|
||||
renderNewsList(articles);
|
||||
// Geo-pin stretch: if articles carry lat/lon (future backend), collect + plot on GIBS map
|
||||
newsGeo = articles.filter(a => typeof a.lat === 'number' && typeof a.lon === 'number');
|
||||
if (newsGeo.length && map) {
|
||||
renderNewsPins();
|
||||
}
|
||||
document.getElementById('news-updated').textContent = 'updated ' + new Date().toLocaleTimeString();
|
||||
if (!newsInterval) {
|
||||
newsInterval = setInterval(() => loadNews(false), NEWS_REFRESH_MS);
|
||||
}
|
||||
}
|
||||
function renderNewsSummary(summaries) {
|
||||
const bodyEl = document.getElementById('ns-body');
|
||||
const timeEl = document.getElementById('ns-time');
|
||||
const badgeEl = document.getElementById('ns-badge');
|
||||
if (!summaries || !summaries.length) {
|
||||
bodyEl.innerHTML = '<div class="ns-empty">No executive summary yet — the Gemini summarizer is idle until <code>GEMINI_API_KEY</code> is set on the Pi.</div>';
|
||||
timeEl.textContent = '—';
|
||||
badgeEl.textContent = 'WAITING';
|
||||
return;
|
||||
}
|
||||
const s = summaries[0];
|
||||
badgeEl.textContent = 'LATEST';
|
||||
timeEl.textContent = s.batch_timestamp ? 'Batch: ' + new Date(s.batch_timestamp).toLocaleString() : '—';
|
||||
bodyEl.innerHTML = newsSimpleMD(s.summary_text || '');
|
||||
}
|
||||
function renderNewsList(articles) {
|
||||
const listEl = document.getElementById('news-list');
|
||||
document.getElementById('news-count').textContent = articles.length + ' articles';
|
||||
if (!articles || !articles.length) {
|
||||
listEl.innerHTML = '<div class="news-empty">No articles scraped yet — the scraper runs hourly at :00. Check news-scraper logs.</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = articles.map(a => {
|
||||
const geoTag = a.lat != null && a.lon != null ? '<span class="news-geo" title="geotagged">📍</span>' : '';
|
||||
return '<a class="news-item" href="' + newsEsc(a.url || '#') + '" target="_blank" rel="noopener">' +
|
||||
'<span class="news-dom">' + newsEsc(a.domain || '?') + '</span>' +
|
||||
'<span class="news-title">' + newsEsc((a.title||'').trim()) + '</span>' +
|
||||
geoTag +
|
||||
'<span class="news-time">' + newsTimeAgo(a.timestamp) + '</span>' +
|
||||
'</a>';
|
||||
}).join('');
|
||||
}
|
||||
function renderNewsPins() {
|
||||
if (!map || !newsGeo.length) return;
|
||||
if (newsPinsLayer) { map.removeLayer(newsPinsLayer); newsPinsLayer = null; }
|
||||
newsPinsLayer = L.layerGroup(newsGeo.map(a => {
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html:'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f0abfc;box-shadow:0 0 10px #f0abfc,0 0 2px #0f172a;border:1px solid #0f172a;"></span>',
|
||||
iconSize: [12, 12], iconAnchor: [6, 6],
|
||||
});
|
||||
return L.marker([a.lat, a.lon], { icon })
|
||||
.bindPopup(`<div class="cam-pop"><b>${newsEsc(a.title||'Headline')}</b><br><a href="${newsEsc(a.url||'#')}" target="_blank" rel="noopener">read article →</a><br><span class="k">${newsEsc(a.domain||'')}</span></div>`);
|
||||
}));
|
||||
newsPinsLayer.addTo(map);
|
||||
}
|
||||
|
||||
// ── Map (NASA GIBS basemap + FIRMS heatmap + open cameras) ───────────────
|
||||
let map = null, mapLayer = null, mapInitStarted = false;
|
||||
let firesHeat = null, camsGroup = null, firesOn = false, camsOn = false;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue