← back to Norma
components/NewsTab.tsx
1411 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
ExternalLink,
Newspaper,
RefreshCw,
Search,
Clock,
Inbox,
Send,
ChevronDown,
ChevronUp,
User,
Filter,
X,
Mail,
Scan,
Users,
Pin,
PinOff,
} from 'lucide-react';
import { useToast } from './ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import { SkeletonList } from './Skeleton';
import SortDropdown from './shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
import ComposeModal from './email-sends/ComposeModal';
import { getOutletFaviconUrl } from '@/lib/outlet-logo';
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 MentionEntity {
name: string;
type: 'employee' | 'alias' | 'competitor';
match_context: string;
}
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;
is_used: boolean;
source_type: string;
created_at: string;
author_name: string | null;
author_linkedin: string | null;
mentioned_entities: MentionEntity[] | null;
is_pinned: boolean;
}
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '';
return new Date(dateStr).toLocaleString('en-US', {
timeZone: 'America/Los_Angeles',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
}) + ' PT';
}
/* ─── Helpers: extract author guess from headline/outlet ─────────────────── */
function guessAuthorLinkedIn(outlet: string | null): string {
if (!outlet) return '';
return `https://www.linkedin.com/search/results/people/?keywords=${encodeURIComponent(outlet + ' journalist policy advocacy')}`;
}
function buildOutreachLetter(headline: string, outlet: string | null, authorName?: string | null): string {
const greeting = authorName ? 'Dear ' + authorName + ',' : 'Dear Editor/Reporter,';
return greeting + '\n' + `
I'm writing from our organization regarding your recent article: "${headline}"
Your coverage ${outlet ? `in ${outlet} ` : ''}plays a vital role in keeping important issues in the public conversation. Stories like yours help affected communities feel seen and drive policy change.
We work directly with communities, run advocacy campaigns, and provide resources for those navigating complex systems. We'd love to be a resource for your future reporting:
- Expert quotes on policy and community impact
- Real community stories (with permission) for human interest angles
- Data and research on relevant trends
- Policy analysis from our advocacy team
Would you be open to connecting? We're always available for background conversations or on-the-record interviews.
Best regards,
[Your Name]
[Your Title], [Your Organization]
[org-email]
[org-website]`;
}
/* ─── News Card ──────────────────────────────────────────────────────────── */
function NewsCard({ item, index, onEmail, onAuthorClick, onTogglePin, onCreatePost }: { item: NewsItem; index: number; onEmail?: (item: NewsItem) => void; onAuthorClick?: (name: string) => void; onTogglePin?: (id: string, pinned: boolean) => void; onCreatePost?: (item: NewsItem) => void }) {
const [showLetter, setShowLetter] = useState(false);
const [showArticle, setShowArticle] = useState(false);
const [articleContent, setArticleContent] = useState<string | null>(null);
const [fetchingArticle, setFetchingArticle] = useState(false);
const [authorName, setAuthorName] = useState<string | null>(item.author_name);
const [authorLinkedin, setAuthorLinkedin] = useState<string | null>(item.author_linkedin);
const [resolving, setResolving] = useState(false);
const [resolved, setResolved] = useState(!!item.author_name);
// Auto-resolve disabled — Google News URLs can't be followed server-side.
// Authors are resolved via batch script (scripts/batch-resolve-authors.js)
// or manually via the "Retry" button.
async function handleOpenArticle() {
if (showArticle) { setShowArticle(false); return; }
if (articleContent) { setShowArticle(true); return; }
if (!item.url) return;
setFetchingArticle(true);
try {
const res = await fetch(`/api/news/fetch-article?url=${encodeURIComponent(item.url)}`);
if (res.ok) {
const data = await res.json();
setArticleContent(data.content || data.text || 'Could not extract article content.');
} else {
setArticleContent('Failed to fetch article. Try opening in a new tab.');
}
} catch {
setArticleContent('Failed to fetch article. Try opening in a new tab.');
} finally {
setFetchingArticle(false);
setShowArticle(true);
}
}
async function handleResolve() {
if (!item.url || resolving) return;
setResolving(true);
try {
const res = await fetch('/api/news/resolve-authors?id=' + item.id);
const data = await res.json();
if (data.authorName) {
setAuthorName(data.authorName);
setAuthorLinkedin(data.linkedinUrl || null);
}
setResolved(true);
} catch { /* ignore */ }
setResolving(false);
}
const linkedInUrl = authorLinkedin || guessAuthorLinkedIn(item.outlet);
const letter = buildOutreachLetter(item.headline, item.outlet, authorName);
const [expanded, setExpanded] = useState(false);
return (
<motion.article
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.1, delay: index * 0.015 }}
style={{
borderBottom: '1px solid var(--color-border)',
padding: '10px 0',
...(item.is_pinned ? { backgroundColor: 'var(--color-primary-dim)', borderRadius: 'var(--radius-sm)', paddingLeft: 8, paddingRight: 8, marginBottom: 2 } : {}),
}}
>
{/* ���─ Row 1: Headline + author + outlet + time (always visible) ─── */}
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
onClick={() => setExpanded(!expanded)}
>
{/* Pin button */}
<button
type="button"
title={item.is_pinned ? 'Unpin article' : 'Pin to top'}
onClick={(e) => { e.stopPropagation(); onTogglePin?.(item.id, item.is_pinned); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 2,
flexShrink: 0,
color: item.is_pinned ? 'var(--color-primary)' : 'var(--color-text-muted)',
opacity: item.is_pinned ? 1 : 0.4,
transition: 'opacity 0.15s, color 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { if (!item.is_pinned) e.currentTarget.style.opacity = '0.4'; }}
>
{item.is_pinned ? <Pin size={13} /> : <PinOff size={13} />}
</button>
{/* Outlet favicon */}
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
title={item.outlet || 'Source'}
style={{ flexShrink: 0, lineHeight: 0 }}
>
{(() => {
const faviconUrl = getOutletFaviconUrl(item.outlet);
return faviconUrl ? (
<img
src={faviconUrl}
alt={item.outlet || ''}
width={22}
height={22}
style={{ borderRadius: 4, background: 'var(--color-surface-el)' }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; e.currentTarget.nextElementSibling && ((e.currentTarget.nextElementSibling as HTMLElement).style.display = 'flex'); }}
/>
) : null;
})()}
<div style={{
width: 22, height: 22, borderRadius: 4,
backgroundColor: 'var(--color-surface-el)',
display: getOutletFaviconUrl(item.outlet) ? 'none' : 'flex',
alignItems: 'center', justifyContent: 'center',
}}>
<Newspaper size={12} style={{ color: 'var(--color-text-muted)' }} />
</div>
</a>
) : (
<div style={{
width: 22, height: 22, borderRadius: 4,
backgroundColor: 'var(--color-surface-el)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<Newspaper size={12} style={{ color: 'var(--color-text-muted)' }} />
</div>
)}
<ChevronDown
size={14}
style={{
color: 'var(--color-text-muted)',
flexShrink: 0,
transform: expanded ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: 'transform 0.15s',
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="font-semibold hover:underline"
style={{ color: 'var(--color-text)', fontSize: 14, lineHeight: 1.4, textDecoration: 'none' }}
>
{item.headline}
</a>
) : (
<span className="font-semibold" style={{ color: 'var(--color-text)', fontSize: 14, lineHeight: 1.4 }}>
{item.headline}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
{authorName ? (
<button
type="button"
onClick={e => { e.stopPropagation(); onAuthorClick?.(authorName); }}
style={{ color: 'var(--color-primary)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 12, fontWeight: 600, textDecoration: 'underline', textDecorationStyle: 'dotted', textUnderlineOffset: '2px' }}
>
{authorName}
</button>
) : (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Unknown Author</span>
)}
{item.outlet && (
item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{ fontSize: 12, color: 'var(--color-text-secondary)', fontWeight: 500, textDecoration: 'none' }}
onMouseEnter={(e) => { e.currentTarget.style.textDecoration = 'underline'; }}
onMouseLeave={(e) => { e.currentTarget.style.textDecoration = 'none'; }}
>
{item.outlet}
</a>
) : (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)', fontWeight: 500 }}>{item.outlet}</span>
)
)}
{item.published_at && (
item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{ fontSize: 11, color: 'var(--color-text-muted)', textDecoration: 'none' }}
onMouseEnter={(e) => { e.currentTarget.style.textDecoration = 'underline'; }}
onMouseLeave={(e) => { e.currentTarget.style.textDecoration = 'none'; }}
>
{formatDateTime(item.published_at)}
</a>
) : (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{formatDateTime(item.published_at)}
</span>
)
)}
{/* Mention pills (always visible in collapsed row) */}
{item.mentioned_entities && item.mentioned_entities.length > 0 && (
<>
{item.mentioned_entities.filter(m => m.type === 'employee' || m.type === 'alias').map((m, idx) => (
item.url ? (
<a
key={`mention-${idx}`}
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
title={m.match_context}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 3,
padding: '1px 7px',
borderRadius: 9999,
fontSize: 10,
fontWeight: 600,
backgroundColor: m.type === 'employee' ? 'rgba(16,185,129,0.15)' : 'rgba(99,102,241,0.12)',
border: m.type === 'employee' ? '1px solid rgba(16,185,129,0.3)' : '1px solid rgba(99,102,241,0.25)',
color: m.type === 'employee' ? 'var(--color-primary)' : '#818cf8',
whiteSpace: 'nowrap',
textDecoration: 'none',
}}
>
<Users size={9} />
{m.name}
</a>
) : (
<span
key={`mention-${idx}`}
title={m.match_context}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 3,
padding: '1px 7px',
borderRadius: 9999,
fontSize: 10,
fontWeight: 600,
backgroundColor: m.type === 'employee' ? 'rgba(16,185,129,0.15)' : 'rgba(99,102,241,0.12)',
border: m.type === 'employee' ? '1px solid rgba(16,185,129,0.3)' : '1px solid rgba(99,102,241,0.25)',
color: m.type === 'employee' ? 'var(--color-primary)' : '#818cf8',
whiteSpace: 'nowrap',
}}
>
<Users size={9} />
{m.name}
</span>
)
))}
{item.mentioned_entities.filter(m => m.type === 'competitor').map((m, idx) => (
item.url ? (
<a
key={`comp-${idx}`}
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
title={m.match_context}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 3,
padding: '1px 7px',
borderRadius: 9999,
fontSize: 10,
fontWeight: 600,
backgroundColor: 'rgba(245,158,11,0.12)',
border: '1px solid rgba(245,158,11,0.25)',
color: 'var(--color-secondary)',
whiteSpace: 'nowrap',
textDecoration: 'none',
}}
>
{m.name}
</a>
) : (
<span
key={`comp-${idx}`}
title={m.match_context}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 3,
padding: '1px 7px',
borderRadius: 9999,
fontSize: 10,
fontWeight: 600,
backgroundColor: 'rgba(245,158,11,0.12)',
border: '1px solid rgba(245,158,11,0.25)',
color: 'var(--color-secondary)',
whiteSpace: 'nowrap',
}}
>
{m.name}
</span>
)
))}
</>
)}
</div>
</div>
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="btn btn-ghost btn-sm"
style={{ flexShrink: 0, fontSize: 11, padding: '4px 8px', textDecoration: 'none' }}
>
<ExternalLink size={12} />
</a>
)}
</div>
{/* ── Expanded: summary, tags, actions ─── */}
{expanded && (
<div style={{ marginLeft: 22, marginTop: 8 }}>
{/* Summary */}
{item.summary && (
<p style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.5, margin: '0 0 8px' }}>
{item.summary}
</p>
)}
{/* Tags */}
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{item.tags.slice(0, 8).map((tag) => (
item.url ? (
<a
key={tag}
href={item.url}
target="_blank"
rel="noopener noreferrer"
style={{
padding: '2px 8px', borderRadius: 9999, fontSize: 11,
backgroundColor: 'var(--color-primary-dim)',
border: '1px solid rgba(10,124,89,0.15)',
color: 'var(--color-primary)',
textDecoration: 'none',
}}
>
{tag}
</a>
) : (
<span
key={tag}
style={{
padding: '2px 8px', borderRadius: 9999, fontSize: 11,
backgroundColor: 'var(--color-primary-dim)',
border: '1px solid rgba(10,124,89,0.15)',
color: 'var(--color-primary)',
}}
>
{tag}
</span>
)
))}
{item.source_type && item.source_type !== 'manual' && (
item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
style={{
padding: '2px 8px', borderRadius: 9999, fontSize: 11,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-muted)',
textDecoration: 'none',
}}
>
{item.source_type}
</a>
) : (
<span style={{
padding: '2px 8px', borderRadius: 9999, fontSize: 11,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-muted)',
}}>
{item.source_type}
</span>
)
)}
</div>
)}
{/* ── Author Outreach Row ──────────────────────────────────────── */}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{/* LinkedIn link */}
{authorName && (
<a
href={linkedInUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md"
style={{
backgroundColor: 'rgba(10,102,194,0.1)',
border: '1px solid rgba(10,102,194,0.2)',
color: '#2563eb',
textDecoration: 'none',
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="#0a66c2"><path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"/></svg>
LinkedIn
</a>
)}
{/* Re-detect author button (only if unknown after auto-resolve) */}
{!authorName && resolved && item.url && (
<button
type="button"
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md"
style={{
backgroundColor: 'rgba(52,211,153,0.08)',
border: '1px solid rgba(52,211,153,0.2)',
color: 'var(--color-primary)',
cursor: 'pointer',
}}
onClick={handleResolve}
>
<Search size={10} />
Retry
</button>
)}
<button
type="button"
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md"
style={{
backgroundColor: 'rgba(34,197,94,0.08)',
border: '1px solid rgba(34,197,94,0.2)',
color: 'var(--color-primary)',
cursor: 'pointer',
}}
onClick={() => setShowLetter((v) => !v)}
>
<Send size={10} />
{showLetter ? 'Hide' : 'Outreach Letter'}
{showLetter ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
{item.url && (
<button
type="button"
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md"
style={{
backgroundColor: showArticle ? 'rgba(99,102,241,0.15)' : 'rgba(99,102,241,0.08)',
border: '1px solid rgba(99,102,241,0.2)',
color: '#818cf8',
cursor: 'pointer',
}}
onClick={handleOpenArticle}
disabled={fetchingArticle}
>
{fetchingArticle ? (
<><span className="spinner" style={{ width: 8, height: 8 }} /> Loading...</>
) : (
<><Newspaper size={10} /> {showArticle ? 'Hide Article' : 'Read Article'}</>
)}
</button>
)}
{onEmail && (
<button
type="button"
onClick={() => onEmail(item)}
className="btn btn-ghost btn-sm"
title="Start email from this article"
style={{
fontSize: '0.7rem',
padding: '4px 8px',
minHeight: 'auto',
}}
>
<Mail size={14} />
</button>
)}
{onCreatePost && (
<button
type="button"
onClick={() => onCreatePost(item)}
className="btn btn-ghost btn-sm"
title="Create social post from this article"
style={{
fontSize: '0.7rem',
padding: '4px 8px',
minHeight: 'auto',
color: '#10b981',
}}
>
<Send size={14} />
</button>
)}
</div>
{/* ── Collapsed Article Content ──────────────────────────────── */}
<AnimatePresence>
{showArticle && articleContent && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-3"
>
<div
className="text-xs leading-relaxed p-4 rounded-lg overflow-y-auto"
style={{
backgroundColor: 'var(--color-bg)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
maxHeight: 400,
whiteSpace: 'pre-wrap',
fontFamily: 'inherit',
lineHeight: 1.7,
}}
>
{articleContent}
</div>
<div className="flex gap-2 mt-2">
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost btn-sm"
style={{ fontSize: '0.7rem' }}
>
<ExternalLink size={10} /> Open Original
</a>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: '0.7rem' }}
onClick={() => navigator.clipboard.writeText(articleContent)}
>
Copy Text
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── Collapsed Draft Letter ───────────────────────────────────── */}
{showLetter && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-3"
>
<pre
className="text-xs leading-relaxed p-3 rounded-lg overflow-x-auto"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
whiteSpace: 'pre-wrap',
fontFamily: 'inherit',
maxHeight: 300,
overflowY: 'auto',
}}
>
{letter}
</pre>
<div className="flex gap-2 mt-2">
<button
type="button"
className="btn btn-secondary btn-sm"
style={{ fontSize: '0.7rem' }}
onClick={() => navigator.clipboard.writeText(letter)}
>
Copy Letter
</button>
</div>
</motion.div>
)}
</div>
)}
</motion.article>
);
}
/* ─── Outlet option type ─────────────────────────────────────────────────── */
interface OutletOption {
outlet: string;
count: number;
}
/* ─── Tag option type ──────────────────────────────────────────────────── */
interface TagOption {
tag: string;
count: number;
}
/* ─── NewsTab ────────────────────────────────────────────────────────────── */
export default function NewsTab({ onNavigate, onNavigateToJournalist }: { onNavigate?: (tab: string) => void; onNavigateToJournalist?: (name: string) => void } = {}) {
const { addToast } = useToast();
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300);
const [displayCount, setDisplayCount] = useState(30);
const [batchResolving, setBatchResolving] = useState(false);
const [resolveStats, setResolveStats] = useState<string | null>(null);
const [sortBy, setSortBy] = useState('date_desc');
const [dateRange, setDateRange] = useState('month');
const [selectedOutlet, setSelectedOutlet] = useState('');
const [outlets, setOutlets] = useState<OutletOption[]>([]);
const [allTags, setAllTags] = useState<TagOption[]>([]);
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const sortedNews = useClientSort(news, sortBy, NEWS_SORT_CONFIGS);
const [composeOpen, setComposeOpen] = useState(false);
const [composeNews, setComposeNews] = useState<{ headline: string; summary: string; url: string } | undefined>();
const [mentionsOnly, setMentionsOnly] = useState(false);
const [detectingMentions, setDetectingMentions] = useState(false);
const [detectStats, setDetectStats] = useState<string | null>(null);
const [ingesting, setIngesting] = useState(false);
const [ingestStats, setIngestStats] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
/* Fetch outlet list for the filter dropdown */
useEffect(() => {
fetch('/api/news/outlets')
.then((r) => r.json())
.then((data) => {
if (data.outlets) setOutlets(data.outlets);
})
.catch(() => { /* ignore — dropdown just won't populate */ });
}, []);
/* Fetch tag list (scoped by date range and outlet) */
const fetchTags = useCallback(async () => {
try {
const params = new URLSearchParams();
if (dateRange) params.set('date_range', dateRange);
if (selectedOutlet) params.set('outlet', selectedOutlet);
const res = await fetch(`/api/news/tags${params.toString() ? '?' + params.toString() : ''}`);
if (res.ok) {
const data = await res.json();
setAllTags(data.tags ?? []);
}
} catch { /* ignore */ }
}, [dateRange, selectedOutlet]);
useEffect(() => { fetchTags(); }, [fetchTags]);
/* Toggle a tag in the selection set */
const toggleTag = useCallback((tag: string) => {
setSelectedTags((prev) => {
const next = new Set(prev);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
return next;
});
setDisplayCount(30);
}, []);
const clearAllTags = useCallback(() => {
setSelectedTags(new Set());
setDisplayCount(30);
}, []);
/* Filter visible tags by search query (client-side) */
const visibleTags = allTags.filter((t) =>
!debouncedSearch.trim() || t.tag.toLowerCase().includes(debouncedSearch.trim().toLowerCase())
);
/* Serialize selected tags for stable dependency tracking */
const selectedTagsKey = Array.from(selectedTags).sort().join(',');
const fetchNews = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim());
if (selectedOutlet) params.set('outlet', selectedOutlet);
if (dateRange) params.set('date_range', dateRange);
if (selectedTagsKey) params.set('tags', selectedTagsKey);
if (mentionsOnly) params.set('mentions_only', 'true');
const res = await fetch(`/api/news${params.toString() ? '?' + params.toString() : ''}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setNews(data.news ?? []);
if (data.last_updated) setLastUpdated(data.last_updated);
} catch (err) {
setError((err as Error).message || 'Failed to load news.');
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch, selectedOutlet, dateRange, selectedTagsKey, mentionsOnly]);
useEffect(() => { fetchNews(); }, [fetchNews]);
/* ── Pin/unpin an article ──────────────────────────────────────────────── */
const togglePin = useCallback(async (id: string, currentlyPinned: boolean) => {
// Optimistic update
setNews((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_pinned: !currentlyPinned } : n))
);
try {
const res = await fetch(`/api/news/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ is_pinned: !currentlyPinned }),
});
if (!res.ok) throw new Error('Failed');
addToast(!currentlyPinned ? 'Article pinned to top' : 'Article unpinned', 'success');
} catch {
// Rollback
setNews((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_pinned: currentlyPinned } : n))
);
addToast('Failed to update pin', 'error');
}
}, [addToast]);
const visibleNews = sortedNews.slice(0, displayCount);
return (
<div className="p-4 sm:p-6 max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
<div className="flex items-center gap-2.5">
<div
style={{
width: 32, height: 32, borderRadius: 'var(--radius-md)',
background: 'linear-gradient(135deg, rgba(52,211,153,0.2), rgba(16,185,129,0.2))',
border: '1px solid rgba(52,211,153,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}
aria-hidden="true"
>
<Newspaper size={16} style={{ color: 'var(--color-primary)' }} />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
News
</h1>
{lastUpdated && !loading && (
<span className="text-xs" style={{ color: 'var(--color-text-muted)', fontWeight: 400 }} title={`Last updated: ${formatDateTime(lastUpdated)}`}>
<Clock size={10} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 3, opacity: 0.6 }} />
{formatDateTime(lastUpdated)}
</span>
)}
</div>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
{loading ? 'Loading...' : `${news.length} article${news.length !== 1 ? 's' : ''}${selectedOutlet ? ` from ${selectedOutlet}` : ''} — latest first`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={async () => {
setIngesting(true);
setIngestStats(null);
try {
const res = await fetch('/api/cron/ingest-news', {
method: 'POST',
credentials: 'include',
});
const data = await res.json();
if (data.success) {
setIngestStats(`Refreshed: ${data.inserted} new, ${data.skipped} skipped out of ${data.total} articles`);
addToast(`${data.inserted} new articles ingested`, 'success');
await fetchNews();
} else {
setIngestStats(data.error || 'Ingest failed');
addToast(data.error || 'News refresh failed', 'error');
}
} catch {
setIngestStats('Error refreshing news');
addToast('Failed to refresh news', 'error');
}
setIngesting(false);
}}
disabled={ingesting}
className="btn btn-primary btn-sm"
style={{ fontSize: '0.75rem' }}
>
<RefreshCw size={13} style={{ animation: ingesting ? 'spin 0.7s linear infinite' : 'none' }} />
{ingesting ? 'Refreshing...' : 'Refresh News'}
</button>
<SortDropdown options={NEWS_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
<button
type="button"
onClick={async () => {
setBatchResolving(true);
setResolveStats(null);
try {
const res = await fetch('/api/news/resolve-authors', { method: 'POST' });
const data = await res.json();
setResolveStats(data.message || 'Done');
// Refresh news to get updated author data
await fetchNews();
} catch { setResolveStats('Error'); }
setBatchResolving(false);
}}
disabled={batchResolving}
className="btn btn-ghost btn-sm"
style={{ fontSize: '0.7rem' }}
>
<User size={12} style={{ animation: batchResolving ? 'spin 0.7s linear infinite' : 'none' }} />
{batchResolving ? 'Resolving...' : 'Find Authors'}
</button>
<button
type="button"
onClick={async () => {
setDetectingMentions(true);
setDetectStats(null);
try {
const res = await fetch('/api/news/detect-mentions', { method: 'POST' });
const data = await res.json();
setDetectStats(data.message || 'Done');
await fetchNews();
} catch { setDetectStats('Error detecting mentions'); }
setDetectingMentions(false);
}}
disabled={detectingMentions}
className="btn btn-ghost btn-sm"
style={{ fontSize: '0.7rem' }}
title="Scan articles for employee/org name mentions"
>
<Scan size={12} style={{ animation: detectingMentions ? 'spin 0.7s linear infinite' : 'none' }} />
{detectingMentions ? 'Scanning...' : 'Detect Mentions'}
</button>
<button
type="button"
onClick={fetchNews}
disabled={loading}
className="btn btn-ghost btn-sm"
aria-label="Refresh news"
>
<RefreshCw
size={14}
aria-hidden="true"
style={{ animation: loading ? 'spin 0.7s linear infinite' : 'none' }}
/>
</button>
</div>
</div>
{/* Resolve stats banner */}
{resolveStats && (
<div className="mb-3 text-xs px-3 py-2 rounded-lg flex items-center gap-2" style={{ backgroundColor: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.2)', color: 'var(--color-primary)' }}>
<User size={12} />
{resolveStats}
<button type="button" className="ml-auto text-xs" style={{ color: 'var(--color-text-muted)', cursor: 'pointer', background: 'none', border: 'none' }} onClick={() => setResolveStats(null)}>dismiss</button>
</div>
)}
{/* Detect mentions stats banner */}
{detectStats && (
<div className="mb-3 text-xs px-3 py-2 rounded-lg flex items-center gap-2" style={{ backgroundColor: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.2)', color: 'var(--color-primary)' }}>
<Scan size={12} />
{detectStats}
<button type="button" className="ml-auto text-xs" style={{ color: 'var(--color-text-muted)', cursor: 'pointer', background: 'none', border: 'none' }} onClick={() => setDetectStats(null)}>dismiss</button>
</div>
)}
{/* Ingest stats banner */}
{ingestStats && (
<div className="mb-3 text-xs px-3 py-2 rounded-lg flex items-center gap-2" style={{ backgroundColor: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.2)', color: '#818cf8' }}>
<Newspaper size={12} />
{ingestStats}
<button type="button" className="ml-auto text-xs" style={{ color: 'var(--color-text-muted)', cursor: 'pointer', background: 'none', border: 'none' }} onClick={() => setIngestStats(null)}>dismiss</button>
</div>
)}
{/* Date range tabs */}
<div className="flex items-center gap-1 mb-4" style={{
borderBottom: '1px solid var(--color-border)',
paddingBottom: 0,
}}>
{([
{ value: 'today', label: 'Today' },
{ value: 'week', label: 'This Week' },
{ value: 'month', label: 'This Month' },
{ value: '3months', label: '3 Months' },
{ value: '6months', label: '6 Months' },
{ value: '', label: 'All' },
] as const).map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => { setDateRange(tab.value); setDisplayCount(30); }}
style={{
padding: '8px 14px',
fontSize: 13,
fontWeight: dateRange === tab.value ? 600 : 400,
color: dateRange === tab.value ? 'var(--color-primary)' : 'var(--color-text-secondary)',
background: 'none',
border: 'none',
borderBottom: dateRange === tab.value ? '2px solid var(--color-primary)' : '2px solid transparent',
cursor: 'pointer',
transition: 'color 0.15s, border-color 0.15s',
marginBottom: -1,
}}
>
{tab.label}
</button>
))}
</div>
{/* Search bar + Author filter */}
<div className="flex gap-2 mb-4" style={{ flexWrap: 'wrap' }}>
<div className="relative" style={{ flex: '1 1 auto', minWidth: 200 }}>
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2"
style={{ color: 'var(--color-text-muted)' }}
aria-hidden="true"
/>
<input
type="text"
className="input w-full"
style={{ paddingLeft: '2.25rem' }}
placeholder="Search headlines, outlets, authors..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label="Search news"
/>
</div>
{/* Author/Outlet filter dropdown */}
<div className="relative" style={{ minWidth: 200, flexShrink: 0 }}>
<Filter
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: 'var(--color-text-muted)' }}
aria-hidden="true"
/>
<select
className="input w-full appearance-none"
style={{
paddingLeft: '2.25rem',
paddingRight: selectedOutlet ? '2rem' : '0.75rem',
cursor: 'pointer',
}}
value={selectedOutlet}
onChange={(e) => {
setSelectedOutlet(e.target.value);
setDisplayCount(30);
}}
aria-label="Filter by author/outlet"
>
<option value="">All Authors ({outlets.reduce((s, o) => s + o.count, 0)})</option>
{outlets.map((o) => (
<option key={o.outlet} value={o.outlet}>
{o.outlet} ({o.count})
</option>
))}
</select>
{selectedOutlet && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text-muted)',
padding: 2,
lineHeight: 1,
}}
onClick={() => { setSelectedOutlet(''); setDisplayCount(30); }}
aria-label="Clear author filter"
title="Clear filter"
>
<X size={14} />
</button>
)}
</div>
{/* Mentions Only toggle */}
<button
type="button"
onClick={() => { setMentionsOnly(!mentionsOnly); setDisplayCount(30); }}
className="btn btn-sm"
style={{
flexShrink: 0,
fontSize: '0.7rem',
padding: '6px 10px',
backgroundColor: mentionsOnly ? 'rgba(16,185,129,0.15)' : 'transparent',
border: mentionsOnly ? '1px solid rgba(16,185,129,0.4)' : '1px solid var(--color-border)',
color: mentionsOnly ? 'var(--color-primary)' : 'var(--color-text-secondary)',
borderRadius: 'var(--radius-md)',
cursor: 'pointer',
transition: 'var(--transition)',
whiteSpace: 'nowrap',
}}
title="Show only articles that mention our people or organization"
aria-pressed={mentionsOnly}
>
<Users size={12} />
Mentions Us
</button>
</div>
{/* Mentions-only active badge */}
{mentionsOnly && (
<div
className="mb-3 flex items-center gap-2 text-xs px-3 py-2 rounded-lg"
style={{
backgroundColor: 'rgba(16,185,129,0.08)',
border: '1px solid rgba(16,185,129,0.2)',
color: 'var(--color-primary)',
}}
>
<Users size={12} />
Showing only articles mentioning our people or organization
<button
type="button"
className="ml-auto"
style={{ color: 'var(--color-text-muted)', cursor: 'pointer', background: 'none', border: 'none' }}
onClick={() => { setMentionsOnly(false); setDisplayCount(30); }}
>
clear
</button>
</div>
)}
{/* Active filter badge */}
{selectedOutlet && (
<div
className="mb-3 flex items-center gap-2 text-xs px-3 py-2 rounded-lg"
style={{
backgroundColor: 'rgba(16,185,129,0.08)',
border: '1px solid rgba(16,185,129,0.2)',
color: 'var(--color-primary)',
}}
>
<Filter size={12} />
Filtered by: <strong>{selectedOutlet}</strong>
<span style={{ color: 'var(--color-text-muted)' }}>
({news.length} article{news.length !== 1 ? 's' : ''})
</span>
<button
type="button"
className="ml-auto"
style={{ color: 'var(--color-text-muted)', cursor: 'pointer', background: 'none', border: 'none' }}
onClick={() => { setSelectedOutlet(''); setDisplayCount(30); }}
>
clear
</button>
</div>
)}
{/* ── Tag Filter Pills ──────────────────────────────────────────────── */}
{visibleTags.length > 0 && (
<div className="mb-4">
<div
className="flex items-center gap-2 overflow-x-auto pb-1"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--color-border) transparent',
}}
>
{selectedTags.size > 0 && (
<button
type="button"
onClick={clearAllTags}
className="inline-flex items-center gap-1 shrink-0"
style={{
padding: '4px 10px',
borderRadius: 9999,
fontSize: 11,
fontWeight: 600,
backgroundColor: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.25)',
color: 'var(--color-error)',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
<X size={10} />
Clear all
</button>
)}
{visibleTags.map(({ tag, count }) => {
const isSelected = selectedTags.has(tag);
return (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className="shrink-0"
style={{
padding: '4px 10px',
borderRadius: 9999,
fontSize: 11,
fontWeight: 500,
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'var(--transition)',
backgroundColor: isSelected ? 'var(--color-primary)' : 'var(--color-surface-el)',
border: isSelected
? '1px solid var(--color-primary)'
: '1px solid var(--color-border)',
color: isSelected ? '#fff' : 'var(--color-text-secondary)',
}}
>
{tag}
<span
style={{
marginLeft: 4,
fontSize: 10,
opacity: 0.7,
fontWeight: 400,
}}
>
{count}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Active tag filter badge */}
{selectedTags.size > 0 && (
<div
className="mb-3 flex items-center gap-2 text-xs px-3 py-2 rounded-lg flex-wrap"
style={{
backgroundColor: 'var(--color-primary-dim)',
border: '1px solid rgba(16,185,129,0.2)',
color: 'var(--color-primary)',
}}
>
<Filter size={12} />
Tags:
{Array.from(selectedTags).map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1"
style={{
padding: '1px 8px',
borderRadius: 9999,
fontSize: 10,
fontWeight: 600,
backgroundColor: 'var(--color-primary)',
color: '#fff',
}}
>
{tag}
<button
type="button"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#fff', padding: 0, lineHeight: 1 }}
onClick={() => toggleTag(tag)}
aria-label={`Remove ${tag} filter`}
>
<X size={10} />
</button>
</span>
))}
<span style={{ color: 'var(--color-text-muted)' }}>
({news.length} article{news.length !== 1 ? 's' : ''})
</span>
<button
type="button"
className="ml-auto"
style={{ color: 'var(--color-text-muted)', cursor: 'pointer', background: 'none', border: 'none' }}
onClick={clearAllTags}
>
clear all
</button>
</div>
)}
{/* Loading */}
{loading && (
<div aria-live="polite" aria-busy="true">
<SkeletonList count={5} />
</div>
)}
{/* Error */}
{!loading && error && (
<div
role="alert"
className="rounded-xl px-4 py-3 flex items-center justify-between gap-3"
style={{ backgroundColor: 'rgba(239,68,68,0.05)', border: '1px solid rgba(239,68,68,0.3)' }}
>
<p className="text-sm" style={{ color: 'var(--color-error)' }}>{error}</p>
<button type="button" onClick={fetchNews} className="btn btn-secondary btn-sm">
<RefreshCw size={12} aria-hidden="true" /> Retry
</button>
</div>
)}
{/* Empty */}
{!loading && !error && news.length === 0 && (
<div
className="rounded-xl flex flex-col items-center justify-center py-16 gap-3"
style={{ backgroundColor: 'var(--color-surface)', border: '1px dashed var(--color-border)' }}
>
<Inbox size={36} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
<p className="font-medium text-lg" style={{ color: 'var(--color-text)' }}>
{searchQuery ? 'No articles match your search' : 'No news articles yet'}
</p>
<p className="text-sm text-center max-w-xs" style={{ color: 'var(--color-text-muted)' }}>
{searchQuery ? 'Try a different search term.' : 'News will appear here once the ingestion feeds are synced.'}
</p>
</div>
)}
{/* News list */}
{!loading && !error && visibleNews.length > 0 && (
<>
<div className="flex flex-col" role="feed" aria-label="Advocacy news articles">
<AnimatePresence initial={false}>
{visibleNews.map((item, i) => (
<NewsCard
key={item.id}
item={item}
index={i}
onTogglePin={togglePin}
onEmail={(newsItem) => {
setComposeNews({
headline: newsItem.headline,
summary: newsItem.summary || '',
url: newsItem.url || '',
});
setComposeOpen(true);
}}
onCreatePost={(newsItem) => {
if (typeof window !== 'undefined') {
localStorage.setItem('social-compose-prefill', JSON.stringify({
headline: newsItem.headline,
summary: newsItem.summary || '',
url: newsItem.url || '',
source: 'news',
}));
}
if (onNavigate) onNavigate('social-compose');
}}
onAuthorClick={(name) => {
if (onNavigateToJournalist) {
onNavigateToJournalist(name);
} else if (onNavigate) {
onNavigate('journalists');
}
}}
/>
))}
</AnimatePresence>
</div>
{/* Load more */}
{displayCount < news.length && (
<div className="flex justify-center mt-4">
<button
type="button"
onClick={() => setDisplayCount((c) => c + 30)}
className="btn btn-secondary"
>
Load More ({news.length - displayCount} remaining)
</button>
</div>
)}
</>
)}
{/* ── Compose Modal (from news article) ───────────────────────────── */}
<ComposeModal
open={composeOpen}
onClose={() => setComposeOpen(false)}
newsItem={composeNews}
/>
</div>
);
}