← back to Grant
components/news/NewsTab.tsx
373 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
Newspaper,
Search,
Sparkles,
ExternalLink,
Loader2,
Calendar,
Tag,
User,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useAuth } from '../AuthProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const NEWS_SORT_OPTIONS = [
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'relevance_desc', label: 'Most Relevant' },
{ value: 'outlet', label: 'Outlet A-Z' },
];
const NEWS_SORT_CONFIGS: Record<string, SortConfig> = {
date_desc: { key: 'published_at', direction: 'desc', type: 'date' },
date_asc: { key: 'published_at', direction: 'asc', type: 'date' },
relevance_desc: { key: 'relevance_score', direction: 'desc', type: 'number' },
outlet: { key: 'outlet', direction: 'asc', type: 'string' },
};
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface NewsItem {
id: string;
headline: string;
outlet: string | null;
url: string | null;
published_at: string | null;
summary: string | null;
tags: string[];
relevance_score: number | null;
author_name: string | null;
source_type: string | null;
created_at: string;
}
const SOURCE_TYPES = [
{ id: 'all', label: 'All Sources' },
{ id: 'google_news', label: 'News' },
{ id: 'press_release', label: 'Press Release' },
{ id: 'policy_brief', label: 'Policy Brief' },
{ id: 'academic', label: 'Academic' },
{ id: 'industry', label: 'Industry' },
];
const OUTLET_COLORS: Record<string, string> = {
'NPR': '#e01e5a',
'The Washington Post': '#2b2b2b',
'Inside Higher Ed': '#006699',
'The Hill': '#2e68ab',
'Reuters': '#ff8000',
'CNBC': '#005689',
'Politico': '#e81c23',
'The New York Times': '#1a1a1a',
'Forbes': '#b5121b',
'AP News': '#ed1c24',
};
function formatDate(d: string | null): string {
if (!d) return 'Unknown date';
return new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
/* ─── Main Component ─────────────────────────────────────────────────────── */
export default function NewsTab() {
const { addToast } = useToast();
const { showAdmin } = useAuth();
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [discovering, setDiscovering] = useState(false);
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [sourceFilter, setSourceFilter] = useState('all');
const [sortBy, setSortBy] = useState('date_desc');
const sortedNews = useClientSort(news, sortBy, NEWS_SORT_CONFIGS);
const fetchNews = useCallback(async () => {
try {
const params = new URLSearchParams();
if (debouncedSearch) params.set('search', debouncedSearch);
if (sourceFilter !== 'all') params.set('source_type', sourceFilter);
const res = await fetch(`/api/news?${params}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setNews(data.rows || []);
}
} catch {
// fetch error handled by loading state
} finally {
setLoading(false);
}
}, [debouncedSearch, sourceFilter]);
useEffect(() => {
fetchNews();
}, [fetchNews]);
const handleDiscover = async () => {
setDiscovering(true);
try {
const res = await fetch('/api/news/discover', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
await fetchNews();
addToast('News articles discovered', 'success');
} else {
addToast('News discovery failed', 'error');
}
} catch {
addToast('News discovery failed', 'error');
} finally {
setDiscovering(false);
}
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>
News Monitoring
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
AI-curated news relevant to your mission, funders, and sector.
</p>
</div>
{showAdmin && (
<button
className="btn btn-secondary"
onClick={handleDiscover}
disabled={discovering}
>
{discovering ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Sparkles size={15} />
)}
{discovering ? 'Discovering...' : 'AI Discover News'}
</button>
)}
</div>
{/* Search */}
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2"
style={{ color: 'var(--color-text-muted)' }}
/>
<input
type="text"
className="input"
placeholder="Search news articles..."
aria-label="Search"
style={{ paddingLeft: '2.25rem' }}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Sort + Source Type Filter */}
<div className="flex gap-2 flex-wrap items-center">
<SortDropdown options={NEWS_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
{SOURCE_TYPES.map(({ id, label }) => {
const isActive = sourceFilter === id;
return (
<button
key={id}
className="btn btn-sm"
onClick={() => setSourceFilter(id)}
style={{
backgroundColor: isActive ? 'rgba(5,150,105,0.12)' : 'var(--color-surface-el)',
color: isActive ? '#34d399' : 'var(--color-text-secondary)',
border: isActive
? '1px solid rgba(5,150,105,0.3)'
: '1px solid var(--color-border)',
}}
>
{label}
</button>
);
})}
</div>
{/* News Cards */}
{loading ? (
<SkeletonList count={4} />
) : sortedNews.length === 0 ? (
<div className="card">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center mb-4"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<Newspaper size={24} style={{ color: 'var(--color-text-muted)' }} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
No news articles yet
</h3>
<p className="text-sm max-w-md" style={{ color: 'var(--color-text-muted)' }}>
Click "AI Discover News" to find relevant articles about student debt, education policy, and nonprofit funding.
</p>
</div>
</div>
) : (
<div className="space-y-3">
{sortedNews.map((item) => {
const relevancePct = item.relevance_score != null
? Math.round(item.relevance_score * 100)
: null;
const outletColor = item.outlet
? OUTLET_COLORS[item.outlet] || '#059669'
: '#6b7f75';
return (
<div key={item.id} className="card" style={{ padding: '1rem' }}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{/* Headline */}
<div className="flex items-center gap-2 flex-wrap mb-1">
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold hover:underline"
style={{ color: 'var(--color-text)' }}
>
{item.headline}
</a>
) : (
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{item.headline}
</h3>
)}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0"
>
<ExternalLink size={12} style={{ color: 'var(--color-text-muted)' }} />
</a>
)}
</div>
{/* Meta row */}
<div className="flex items-center gap-3 flex-wrap text-xs mb-2" style={{ color: 'var(--color-text-muted)' }}>
{item.outlet && (
<span
className="px-2 py-0.5 rounded-full font-semibold text-xs"
style={{
backgroundColor: `${outletColor}20`,
color: outletColor,
border: `1px solid ${outletColor}40`,
}}
>
{item.outlet}
</span>
)}
<span className="flex items-center gap-1">
<Calendar size={11} />
{formatDate(item.published_at)}
</span>
{item.author_name && (
<span className="flex items-center gap-1">
<User size={11} />
{item.author_name}
</span>
)}
{item.source_type && (
<span className="flex items-center gap-1">
<Tag size={11} />
{item.source_type.replace(/_/g, ' ')}
</span>
)}
</div>
{/* Summary */}
{item.summary && (
<p className="text-xs leading-relaxed mb-2" style={{ color: 'var(--color-text-secondary)' }}>
{item.summary}
</p>
)}
{/* Tags */}
{item.tags && item.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{item.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-0.5 rounded-full"
style={{
backgroundColor: 'rgba(59,130,246,0.1)',
color: '#60a5fa',
border: '1px solid rgba(59,130,246,0.2)',
}}
>
{tag}
</span>
))}
</div>
)}
</div>
{/* Relevance Score */}
{relevancePct != null && (
<div className="shrink-0 text-center">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-xs font-bold"
style={{
backgroundColor:
relevancePct >= 80
? 'rgba(34,197,94,0.15)'
: relevancePct >= 50
? 'rgba(245,158,11,0.15)'
: 'rgba(107,127,117,0.15)',
color:
relevancePct >= 80
? '#22c55e'
: relevancePct >= 50
? '#f59e0b'
: '#6b7f75',
border: `1px solid ${
relevancePct >= 80
? 'rgba(34,197,94,0.3)'
: relevancePct >= 50
? 'rgba(245,158,11,0.3)'
: 'rgba(107,127,117,0.3)'
}`,
}}
>
{relevancePct}%
</div>
<span className="text-xs mt-0.5 block" style={{ color: 'var(--color-text-muted)', fontSize: '0.625rem' }}>
relevance
</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}