← back to Patty
components/news/NewsFeedTab.tsx
196 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { ExternalLink, Loader2, ChevronDown } from 'lucide-react';
interface Article {
id: string;
title: string;
url: string;
author: string | null;
summary: string | null;
published_at: string;
sentiment: string;
sentiment_score: number;
geo_state: string | null;
tags: string[];
feed_name: string;
feed_category: string;
station: string | null;
article_num: number;
}
const CATEGORIES = ['all', 'national_tv', 'newspaper', 'local_tv', 'wire', 'blog', 'magazine', 'substack', 'government', 'regional'];
const CAT_LABELS: Record<string, string> = {
all: 'All', national_tv: 'TV National', newspaper: 'Newspaper', local_tv: 'TV Local',
wire: 'Wire', blog: 'Blog', magazine: 'Magazine', substack: 'Substack',
government: 'Government', regional: 'Regional',
};
function sentimentDot(s: string) {
const colors: Record<string, string> = { positive: '#22c55e', negative: '#ef4444', neutral: '#6b7280' };
return <span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: colors[s] || '#6b7280', display: 'inline-block', flexShrink: 0 }} />;
}
function decodeEntities(str: string) {
if (!str) return str;
return str
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
.replace(/\uFFFD+/g, "'");
}
export default function NewsFeedTab() {
const [articles, setArticles] = useState<Article[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [category, setCategory] = useState('all');
const [offset, setOffset] = useState(0);
const LIMIT = 50;
const fetchArticles = useCallback(async (reset = false) => {
const newOffset = reset ? 0 : offset;
reset ? setLoading(true) : setLoadingMore(true);
try {
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
if (category !== 'all') params.set('category', category);
const res = await fetch(`/api/pulse/articles?${params}`);
if (res.ok) {
const data = await res.json();
const newArticles = data.articles || [];
if (reset) {
setArticles(newArticles);
setOffset(LIMIT);
} else {
setArticles(prev => [...prev, ...newArticles]);
setOffset(prev => prev + LIMIT);
}
setTotal(data.total || 0);
}
} catch (err) { console.error('Fetch articles error:', err); }
finally { setLoading(false); setLoadingMore(false); }
}, [category, offset]);
useEffect(() => { fetchArticles(true); }, [category]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div style={{ padding: 24, maxWidth: 1200 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
News Feed
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
{total.toLocaleString()} articles
</span>
</h2>
</div>
{/* Category Filters */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20 }}>
{CATEGORIES.map(cat => (
<button
key={cat}
onClick={() => setCategory(cat)}
style={{
padding: '5px 12px', borderRadius: 16, border: '1px solid',
borderColor: category === cat ? '#7c3aed' : 'var(--color-border)',
backgroundColor: category === cat ? '#7c3aed22' : 'transparent',
color: category === cat ? '#a78bfa' : 'var(--color-text-muted)',
fontSize: 12, fontWeight: 500, cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{CAT_LABELS[cat] || cat}
</button>
))}
</div>
{loading ? (
<div style={{ padding: 32, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
<Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> Loading articles...
</div>
) : (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{articles.map(a => (
<div key={a.id} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px',
backgroundColor: 'var(--color-surface)', borderBottom: '1px solid var(--color-border)',
transition: 'background-color 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface)'; }}
>
{/* Article # */}
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 500, minWidth: 36, textAlign: 'right', fontFamily: 'monospace' }}>
#{a.article_num}
</span>
{/* Feed badge */}
<span style={{
fontSize: 10, fontWeight: 600, padding: '2px 6px', borderRadius: 4,
backgroundColor: '#7c3aed22', color: '#a78bfa', whiteSpace: 'nowrap', minWidth: 60, textAlign: 'center',
}}>
{a.feed_name?.length > 14 ? a.feed_name.substring(0, 12) + '…' : a.feed_name}
</span>
{/* Title */}
<a
href={a.url}
target="_blank"
rel="noopener noreferrer"
style={{
flex: 1, fontSize: 13, color: 'var(--color-text)', textDecoration: 'none',
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
fontWeight: 500,
}}
>
{decodeEntities(a.title)}
</a>
{/* Sentiment dot */}
{sentimentDot(a.sentiment)}
{/* State */}
{a.geo_state && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 500 }}>{a.geo_state}</span>
)}
{/* Time */}
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap', minWidth: 55, textAlign: 'right' }}>
{a.published_at ? new Date(a.published_at).toLocaleTimeString('en-US', { timeZone: 'America/Los_Angeles', hour: 'numeric', minute: '2-digit', hour12: true }) : ''}
</span>
{/* Link icon */}
<a href={a.url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
<ExternalLink size={13} />
</a>
</div>
))}
</div>
{/* Load More */}
{articles.length < total && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 20 }}>
<button
onClick={() => fetchArticles(false)}
disabled={loadingMore}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 20px',
borderRadius: 8, border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)', color: 'var(--color-text)',
cursor: loadingMore ? 'wait' : 'pointer', fontSize: 13,
}}
>
{loadingMore ? <Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <ChevronDown size={14} />}
{loadingMore ? 'Loading...' : `Load More (${articles.length} of ${total.toLocaleString()})`}
</button>
</div>
)}
</>
)}
</div>
);
}