Deployables / frontend /src /App.jsx
skander101's picture
fix: sort date groups by published_iso (YYYY-MM-DD) instead of display string
3bb0198
Raw
History Blame Contribute Delete
14.3 kB
import { useState, useEffect, useRef, useCallback } from 'react'
import { fetchCategories, fetchPosts, fetchStatus } from './api'
const CAT_LABELS = {
government: '\u{1F3DB}\uFE0F Government',
conglomerate: '\u{1F3E2} Media Conglomerate',
private_equity: '\u{1F4B0} Private Equity',
wealthy_private: '\u{1F464} Wealthy Private Owner',
corporate: '\u{1F4CA} Corporate',
independent: '\u2705 Independent',
}
function groupByDate(clusters) {
const getDate = (c) => c.articles[0]?.published_iso || ''
const getDisplay = (c) => c.articles[0]?.published || 'Unknown'
const map = {}
for (const c of clusters) {
const key = getDate(c)
if (!map[key]) map[key] = { display: getDisplay(c), items: [] }
map[key].items.push(c)
}
const sorted = Object.entries(map).sort((a, b) => {
if (!a[0]) return 1
if (!b[0]) return -1
return b[0].localeCompare(a[0])
})
return sorted.map(([, group]) => ({
date: group.display,
items: group.items.sort((a, b) => (b.final_score || 0) - (a.final_score || 0)),
}))
}
function SafeImage({ src, className, wrapClass }) {
const [failed, setFailed] = useState(false)
if (!src || failed) {
return <div className="card-image-placeholder">{'\u{1F4F0}'}</div>
}
return (
<div className={wrapClass}>
<img className={className} src={src} alt="" loading="lazy" onError={() => setFailed(true)} />
</div>
)
}
function ScoreBar({ score }) {
const barRef = useRef(null)
const animated = useRef(false)
useEffect(() => {
if (animated.current) return
const el = barRef.current
if (!el) return
const reveal = () => {
if (animated.current) return
animated.current = true
const target = score * 100
el.style.width = '0%'
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.width = target + '%'
})
})
}
const parent = el.closest('.card')
if (parent && parent.classList.contains('revealed')) {
reveal()
} else {
const obs = new MutationObserver(() => {
if (parent && parent.classList.contains('revealed')) {
reveal()
obs.disconnect()
}
})
if (parent) obs.observe(parent, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}
}, [score])
return (
<div className="score-bar-wrap">
<div className="score-bar-fill" ref={barRef}></div>
</div>
)
}
export default function App() {
const [categories, setCategories] = useState([])
const [allClusters, setAllClusters] = useState([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState(0)
const [status, setStatus] = useState('starting')
const [total, setTotal] = useState(0)
const [sponsorInfo, setSponsorInfo] = useState(null)
const tabsRef = useRef(null)
const underlineRef = useRef(null)
const observerRef = useRef(null)
useEffect(() => {
Promise.all([fetchCategories(), fetchPosts(), fetchStatus()])
.then(([catRes, postRes, statusRes]) => {
setCategories(catRes.categories)
setAllClusters(postRes.clusters)
setTotal(postRes.meta.total)
setStatus(statusRes.pipeline_status)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!underlineRef.current || !tabsRef.current) return
const tab = tabsRef.current.children[activeTab]
if (!tab) return
underlineRef.current.style.left = tab.offsetLeft + 'px'
underlineRef.current.style.width = tab.offsetWidth + 'px'
}, [activeTab, categories])
useEffect(() => {
observerRef.current = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('revealed')
observerRef.current.unobserve(entry.target)
}
}
}, { threshold: 0.1, rootMargin: '0px 0px 40px 0px' })
return () => observerRef.current?.disconnect()
}, [])
useEffect(() => {
if (loading) return
const timer = setTimeout(() => {
const tabKey = categories[activeTab]
if (!tabKey) return
const tc = document.getElementById('tab-' + tabKey.replace(/[\s/]+/g, '_'))
if (!tc) return
tc.querySelectorAll('.card:not(.revealed)').forEach(c => {
observerRef.current?.observe(c)
})
}, 50)
return () => clearTimeout(timer)
}, [activeTab, loading, categories])
useEffect(() => {
if (loading || total === 0) return
const el = document.getElementById('count-total')
if (!el) return
let current = 0
const step = Math.max(1, Math.floor(total / 20))
const interval = setInterval(() => {
current += step
if (current >= total) {
current = total
clearInterval(interval)
}
el.textContent = current
}, 40)
return () => clearInterval(interval)
}, [loading, total])
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') setSponsorInfo(null)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [])
const tabId = (name) => name.replace(/[\s/]+/g, '_')
const tabKey = categories[activeTab]
const filtered = tabKey ? allClusters.filter(c => c.category === tabKey) : []
const dateGroups = groupByDate(filtered)
return (
<>
<div className="header">
<div className="header-inner">
<div>
<h1>News Digest</h1>
<span>
<span id="count-total" data-target={total}>0</span>
{' stories across '}{categories.length}{' topics'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="status">
<span className="status-dot"></span>
{status}
</span>
</div>
</div>
</div>
<div className="container">
{loading ? (
<div className="grid">
{Array.from({ length: 6 }).map((_, i) => (
<div className="card" key={i} style={{ opacity: 1, transform: 'none', borderColor: '#1a1a1a' }}>
<div className="skeleton skeleton-img"></div>
<div className="card-body">
<div className="skeleton skeleton-text-short"></div>
<div className="skeleton skeleton-text"></div>
<div className="skeleton skeleton-text" style={{ width: '60%' }}></div>
<div style={{ marginTop: 'auto' }}>
<div className="skeleton" style={{ height: 4, marginBottom: 3 }}></div>
<div className="skeleton skeleton-text-short" style={{ marginBottom: 0 }}></div>
</div>
</div>
</div>
))}
</div>
) : total === 0 ? (
<div className="empty">
<h2>No news yet</h2>
<p>Run <code>python webapp.py</code> to fetch and analyze articles</p>
</div>
) : (
<>
<div className="tabs" ref={tabsRef}>
{categories.map((cat, i) => (
<button
key={cat}
className={'tab' + (i === activeTab ? ' active' : '')}
onClick={() => setActiveTab(i)}
>
{cat}{' '}
<span className="tab-count">
({allClusters.filter(c => c.category === cat).length})
</span>
</button>
))}
<div className="tab-underline" ref={underlineRef}></div>
</div>
{categories.map((cat, i) => (
<div
key={cat}
id={'tab-' + tabId(cat)}
style={{ display: i === activeTab ? 'block' : 'none' }}
>
{dateGroups.length === 0 ? (
<div className="empty"><h2>No {cat} stories found</h2></div>
) : (
dateGroups.map(group => (
<div className="date-group" key={group.date}>
<div className="date-header">{group.date}</div>
<div className="grid">
{group.items.map((c, idx) => {
const a = c.articles[0]
return (
<div className="card" key={c.top_post_url || idx}>
<SafeImage
src={c.image_url}
className="card-image"
wrapClass="card-image-wrap"
/>
<div className="card-body">
<div className="card-rank">#{idx + 1}</div>
<div className="card-topic">{c.topic}</div>
<div className="card-title">{a.title}</div>
{a.summary && (
<div className="card-summary">
{a.summary.length > 160 ? a.summary.slice(0, 160) + '\u2026' : a.summary}
</div>
)}
<div className="card-topics">
{(a.topics || []).slice(0, 3).map(t => (
<span className="topic-tag" key={t}>{t}</span>
))}
</div>
<div className="card-meta">
<span className="source">{a.domain}</span>
<span>{'\u{1F6E1}\uFE0F'} {c.avg_trustworthiness != null ? (c.avg_trustworthiness * 100).toFixed(0) + '%' : ''}</span>
{a.article_leaning && (
<span className={'bias-badge bias-' + a.article_leaning}>{a.article_leaning}</span>
)}
{a.source_bias && a.source_bias !== a.article_leaning && (
<span style={{ fontSize: 9, color: '#64748b' }}>src:{a.source_bias}</span>
)}
{a.source_factuality && (
<span className={'factuality-badge factuality-' + a.source_factuality}>{a.source_factuality}</span>
)}
{c.total_coverage > 1 && <span>{'\u{1F4F0}'} {c.total_coverage}</span>}
{a.sponsor && <span className="sponsor">{'\u{1F3E2}'} {a.sponsor}</span>}
</div>
<ScoreBar score={c.final_score} />
<div className="score-label">
<span>Relevance</span>
<span>{c.final_score}</span>
</div>
<div className="card-actions">
<button className="btn btn-primary" onClick={() => setSponsorInfo(a.sponsor_info)}>
{'\u{1F3E2}'} Sponsors
</button>
<a href={c.top_post_url} className="btn btn-secondary" target="_blank">Open</a>
{a.published && <span className="card-date">{a.published}</span>}
</div>
</div>
</div>
)
})}
</div>
</div>
))
)}
</div>
))}
</>
)}
</div>
<div
className={'modal-overlay' + (sponsorInfo ? ' active' : '')}
onClick={(e) => { if (e.target === e.currentTarget) setSponsorInfo(null) }}
>
<div className="modal">
<button className="modal-close" onClick={() => setSponsorInfo(null)}>{'\u2715'}</button>
{sponsorInfo ? (
<>
<h2>{sponsorInfo.display}</h2>
<div className="parent">Parent: {sponsorInfo.parent || '\u2014'}</div>
<div className="modal-row">
{sponsorInfo.category && (
<span className={'category-badge cat-' + sponsorInfo.category}>
{CAT_LABELS[sponsorInfo.category] || sponsorInfo.category}
</span>
)}
{sponsorInfo.bias && (
<span className={'bias-badge bias-' + sponsorInfo.bias}>{sponsorInfo.bias}</span>
)}
{sponsorInfo.factuality && (
<span className={'factuality-badge factuality-' + sponsorInfo.factuality}>{sponsorInfo.factuality}</span>
)}
</div>
{sponsorInfo.owners && sponsorInfo.owners.length > 0 && (
<div className="modal-section">
<h3>Shareholders / Funders</h3>
<div className="owner-list">
{sponsorInfo.owners.map(o => (
<span key={o} style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
<span className="owner-tag">{o}</span>
{sponsorInfo.owner_wikis && sponsorInfo.owner_wikis[o] && (
<a href={sponsorInfo.owner_wikis[o]} className="owner-wiki" target="_blank" title="Wikipedia">{'\u{1F4D6}'}</a>
)}
</span>
))}
</div>
</div>
)}
{sponsorInfo.wikipedia && (
<a href={sponsorInfo.wikipedia} className="modal-wiki" target="_blank">{'\u{1F4D6}'} Wikipedia</a>
)}
</>
) : (
<p style={{ color: '#666' }}>No sponsor data available.</p>
)}
</div>
</div>
</>
)
}