← back to Patty
components/topics/TopicsTab.tsx
416 lines
'use client';
import { useState, useEffect } from 'react';
import {
Loader2, Sparkles, ChevronDown, ChevronUp,
Target, MapPin, FileText, ExternalLink, AlertTriangle,
DollarSign,
} from 'lucide-react';
import { Grant, formatAmount, STATUS_COLORS } from '../grants/types';
interface Topic {
id: string;
title: string;
description: string;
category: string;
urgency: string;
article_count: number;
avg_sentiment: number;
geo_states: string[];
petition_suggestion: string | null;
petition_target: string | null;
petition_tone: string | null;
is_ai_suggested: boolean;
created_at: string;
}
interface GeneratedPetition {
title: string;
summary: string;
body_html: string;
suggested_tags: string[];
suggested_goal: number;
}
const URGENCY_COLORS: Record<string, string> = {
critical: '#ef4444', high: '#f59e0b', medium: '#3b82f6', low: '#6b7280',
};
export default function TopicsTab({ onViewPetition, onViewGrant }: { onViewPetition?: (id: number) => void; onViewGrant?: (id: string) => void }) {
const [topics, setTopics] = useState<Topic[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [generated, setGenerated] = useState<Record<string, { petition: GeneratedPetition; petitionId: number }>>({});
const [expanded, setExpanded] = useState<string | null>(null);
const [discoveredGrants, setDiscoveredGrants] = useState<Record<string, Grant[]>>({});
const [findingGrants, setFindingGrants] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/pulse/topics');
if (res.ok) {
const data = await res.json();
setTopics(data.topics || []);
}
} catch (err) { console.error('Topics error:', err); }
finally { setLoading(false); }
})();
}, []);
async function generatePetition(topic: Topic) {
setGenerating(topic.id);
try {
// Step 1: Generate petition via Gemini
const genRes = await fetch('/api/petitions/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
topic: topic.title,
target: topic.petition_target || 'Elected Officials',
tone: topic.petition_tone || 'formal',
category: topic.category || 'policy',
}),
});
if (!genRes.ok) throw new Error('Generation failed');
const petition = await genRes.json();
// Step 2: Save as draft
const saveRes = await fetch('/api/petitions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: petition.title,
summary: petition.summary,
body_html: petition.body_html,
body_text: petition.body_text || '',
target: petition.suggested_target || topic.petition_target,
category: topic.category,
tags: petition.suggested_tags || [],
signature_goal: petition.suggested_goal || 1000,
status: 'draft',
}),
});
if (!saveRes.ok) throw new Error('Save failed');
const saved = await saveRes.json();
setGenerated(prev => ({
...prev,
[topic.id]: { petition, petitionId: saved.id || saved.petition?.id },
}));
setExpanded(topic.id);
// Step 3: Auto-discover related grants
try {
const grantRes = await fetch('/api/grants-proxy/grants/discover', { method: 'POST' });
if (grantRes.ok) {
const grantData = await grantRes.json();
if (grantData.grants?.length) {
setDiscoveredGrants(prev => ({ ...prev, [topic.id]: grantData.grants }));
}
}
} catch { /* grant discovery is optional */ }
} catch (err) {
console.error('Generate petition error:', err);
} finally {
setGenerating(null);
}
}
async function findGrantsForTopic(topic: Topic) {
setFindingGrants(topic.id);
try {
const res = await fetch('/api/grants-proxy/grants/discover', { method: 'POST' });
if (res.ok) {
const data = await res.json();
setDiscoveredGrants(prev => ({ ...prev, [topic.id]: data.grants || [] }));
setExpanded(topic.id);
}
} catch (err) {
console.error('Find grants error:', err);
} finally {
setFindingGrants(null);
}
}
if (loading) {
return (
<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 topics...
</div>
);
}
return (
<div style={{ padding: 24, maxWidth: 1000 }}>
<div style={{ marginBottom: 20 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
Topics
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
{topics.length} AI-suggested topics
</span>
</h2>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{topics.map(topic => {
const isGenerating = generating === topic.id;
const gen = generated[topic.id];
const isExpanded = expanded === topic.id;
return (
<div key={topic.id} style={{
borderRadius: 12, border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)', overflow: 'hidden',
}}>
{/* Topic Header */}
<div style={{ padding: '16px 20px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', margin: 0, lineHeight: 1.3 }}>
{topic.title}
</h3>
</div>
<p style={{ fontSize: 13, color: 'var(--color-text-muted)', margin: 0, lineHeight: 1.5 }}>
{topic.description}
</p>
{/* Meta row */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
{/* Urgency badge */}
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 10,
backgroundColor: (URGENCY_COLORS[topic.urgency] || '#6b7280') + '22',
color: URGENCY_COLORS[topic.urgency] || '#6b7280',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}>
{topic.urgency}
</span>
{/* Category */}
<span style={{
fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 10,
backgroundColor: 'var(--color-bg)', color: 'var(--color-text-muted)',
}}>
{topic.category}
</span>
{/* Article count */}
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 3 }}>
<FileText size={12} /> {topic.article_count} article{topic.article_count !== 1 ? 's' : ''}
</span>
{/* Geo states */}
{topic.geo_states?.length > 0 && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 3 }}>
<MapPin size={12} /> {topic.geo_states.join(', ')}
</span>
)}
{/* Sentiment */}
<span style={{
fontSize: 11,
color: topic.avg_sentiment < -0.3 ? '#ef4444' : topic.avg_sentiment > 0.3 ? '#22c55e' : 'var(--color-text-muted)',
}}>
Sentiment: {topic.avg_sentiment?.toFixed(2)}
</span>
</div>
</div>
{/* Action Buttons */}
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
{gen ? (
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => setExpanded(isExpanded ? null : topic.id)}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '7px 12px',
borderRadius: 8, border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)', color: 'var(--color-text)',
cursor: 'pointer', fontSize: 12,
}}
>
{isExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
Preview
</button>
<button
onClick={() => onViewPetition?.(gen.petitionId)}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '7px 12px',
borderRadius: 8, border: 'none',
backgroundColor: '#22c55e', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
Edit in Petitions <ExternalLink size={12} />
</button>
</div>
) : (
<button
onClick={() => generatePetition(topic)}
disabled={isGenerating || !!generating}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
borderRadius: 8, border: 'none',
background: isGenerating ? '#6b7280' : 'linear-gradient(135deg, #7c3aed, #6d28d9)',
color: '#fff', cursor: isGenerating ? 'wait' : 'pointer',
fontSize: 13, fontWeight: 600, opacity: generating && !isGenerating ? 0.5 : 1,
}}
>
{isGenerating ? (
<><Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> Generating...</>
) : (
<><Sparkles size={14} /> Generate Petition</>
)}
</button>
)}
{/* Find Grants button */}
<button
onClick={() => findGrantsForTopic(topic)}
disabled={findingGrants === topic.id}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 14px',
borderRadius: 8, border: '1px solid #059669',
backgroundColor: 'transparent', color: '#10b981',
cursor: findingGrants === topic.id ? 'wait' : 'pointer',
fontSize: 12, fontWeight: 600,
opacity: findingGrants === topic.id ? 0.7 : 1,
}}
>
{findingGrants === topic.id
? <><Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> Finding…</>
: <><DollarSign size={12} /> Find Grants</>
}
</button>
</div>
</div>
</div>
{/* Expanded Grants Only (no petition yet) */}
{!gen && isExpanded && discoveredGrants[topic.id]?.length > 0 && (
<div style={{
padding: '16px 20px', borderTop: '1px solid var(--color-border)',
backgroundColor: 'var(--color-bg)',
}}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
Discovered Grants ({discoveredGrants[topic.id].length})
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{discoveredGrants[topic.id].slice(0, 5).map(g => (
<div key={g.id}
onClick={() => onViewGrant?.(g.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
borderRadius: 8, backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)', cursor: 'pointer',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#059669'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
>
<DollarSign size={14} style={{ color: '#10b981', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
{g.title}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{g.funder} · {formatAmount(g.amount_min, g.amount_max)}
</div>
</div>
{g.ai_fit_score != null && (
<span style={{ fontSize: 11, fontWeight: 600, color: '#f59e0b', flexShrink: 0 }}>
{Math.round(g.ai_fit_score * 100)}%
</span>
)}
<ExternalLink size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
</div>
))}
</div>
</div>
)}
{/* Expanded Petition Preview */}
{gen && isExpanded && (
<div style={{
padding: '16px 20px', borderTop: '1px solid var(--color-border)',
backgroundColor: 'var(--color-bg)',
}}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#22c55e', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 8 }}>
Generated Petition — Saved as Draft
</div>
<h4 style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', margin: '0 0 8px' }}>
{gen.petition.title}
</h4>
<p style={{ fontSize: 13, color: 'var(--color-text-secondary)', margin: '0 0 12px', lineHeight: 1.6 }}>
{gen.petition.summary}
</p>
{gen.petition.suggested_tags?.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 12 }}>
{gen.petition.suggested_tags.map((tag, i) => (
<span key={i} style={{
fontSize: 10, padding: '2px 8px', borderRadius: 10,
backgroundColor: '#7c3aed22', color: '#a78bfa',
}}>{tag}</span>
))}
</div>
)}
{/* Discovered Grants inline */}
{discoveredGrants[topic.id]?.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
Related Grants Found ({discoveredGrants[topic.id].length})
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{discoveredGrants[topic.id].slice(0, 5).map(g => (
<div key={g.id}
onClick={() => onViewGrant?.(g.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
borderRadius: 8, backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)', cursor: 'pointer',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#059669'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
>
<DollarSign size={14} style={{ color: '#10b981', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
{g.title}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{g.funder} · {formatAmount(g.amount_min, g.amount_max)}
</div>
</div>
{g.ai_fit_score != null && (
<span style={{ fontSize: 11, fontWeight: 600, color: '#f59e0b', flexShrink: 0 }}>
{Math.round(g.ai_fit_score * 100)}%
</span>
)}
<ExternalLink size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{topics.length === 0 && (
<div style={{
padding: 40, textAlign: 'center',
color: 'var(--color-text-muted)', fontSize: 14,
}}>
<AlertTriangle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<div>No topics found. Topics are generated when news is fetched.</div>
</div>
)}
</div>
);
}