Files
robinhood/frontend/src/components/NewsFeed.tsx
T

244 lines
8.2 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Newspaper, TrendingUp, TrendingDown, Minus, ExternalLink, RefreshCw } from 'lucide-react';
import type { NewsFeed as NewsFeedType, NewsArticle, Sentiment } from '@/types';
import { newsApi } from '@/services/api';
export default function NewsFeed() {
const [newsFeed, setNewsFeed] = useState<NewsFeedType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | Sentiment>('ALL');
const [autoRefresh, setAutoRefresh] = useState(false);
const loadNews = async () => {
try {
setIsLoading(true);
const data = await newsApi.getNewsFeed(50);
setNewsFeed(data);
} catch (error) {
console.error('Error loading news:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadNews();
}, []);
useEffect(() => {
if (autoRefresh) {
const interval = setInterval(loadNews, 300000); // Refresh every 5 minutes
return () => clearInterval(interval);
}
}, [autoRefresh]);
const getSentimentIcon = (sentiment: Sentiment) => {
switch (sentiment) {
case 'POSITIVE':
return <TrendingUp className="w-4 h-4 text-green-500" />;
case 'NEGATIVE':
return <TrendingDown className="w-4 h-4 text-red-500" />;
case 'NEUTRAL':
return <Minus className="w-4 h-4 text-gray-400" />;
}
};
const getSentimentColor = (sentiment: Sentiment) => {
switch (sentiment) {
case 'POSITIVE':
return 'border-l-green-500 bg-green-500/5';
case 'NEGATIVE':
return 'border-l-red-500 bg-red-500/5';
case 'NEUTRAL':
return 'border-l-gray-500 bg-gray-500/5';
}
};
const getImpactBadge = (impact: string) => {
const colors = {
HIGH: 'bg-red-500/20 text-red-500',
MEDIUM: 'bg-yellow-500/20 text-yellow-500',
LOW: 'bg-blue-500/20 text-blue-500',
};
return colors[impact as keyof typeof colors] || colors.LOW;
};
const filteredArticles = newsFeed?.articles.filter(
(article) => filter === 'ALL' || article.sentiment === filter
) || [];
return (
<div className="card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Newspaper className="w-6 h-6 text-blue-500" />
<div>
<h3 className="text-lg font-semibold">Market News</h3>
{newsFeed && (
<p className="text-xs text-gray-400">
{newsFeed.total_count} articles Overall: {newsFeed.overall_sentiment}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadNews}
disabled={isLoading}
className="p-2 rounded-md hover:bg-dark-hover transition-colors"
title="Refresh news"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded"
/>
<span className="text-gray-400">Auto</span>
</label>
</div>
</div>
{newsFeed && (
<div className="mb-4">
<div className="grid grid-cols-4 gap-2 text-sm">
<button
onClick={() => setFilter('ALL')}
className={`px-3 py-2 rounded-md transition-colors ${
filter === 'ALL' ? 'bg-blue-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
All ({newsFeed.total_count})
</button>
<button
onClick={() => setFilter('POSITIVE')}
className={`px-3 py-2 rounded-md transition-colors ${
filter === 'POSITIVE' ? 'bg-green-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
Bullish ({newsFeed.bullish_count})
</button>
<button
onClick={() => setFilter('NEGATIVE')}
className={`px-3 py-2 rounded-md transition-colors ${
filter === 'NEGATIVE' ? 'bg-red-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
Bearish ({newsFeed.bearish_count})
</button>
<button
onClick={() => setFilter('NEUTRAL')}
className={`px-3 py-2 rounded-md transition-colors ${
filter === 'NEUTRAL' ? 'bg-gray-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
Neutral ({newsFeed.neutral_count})
</button>
</div>
</div>
)}
<div className="space-y-3 max-h-96 overflow-y-auto">
{isLoading && !newsFeed ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
) : filteredArticles.length > 0 ? (
filteredArticles.map((article) => (
<NewsArticleCard key={article.id} article={article} />
))
) : (
<p className="text-center text-gray-400 py-8">No news articles found</p>
)}
</div>
</div>
);
}
function NewsArticleCard({ article }: { article: NewsArticle }) {
const getSentimentColor = (sentiment: Sentiment) => {
switch (sentiment) {
case 'POSITIVE':
return 'border-l-green-500 bg-green-500/5';
case 'NEGATIVE':
return 'border-l-red-500 bg-red-500/5';
case 'NEUTRAL':
return 'border-l-gray-500 bg-gray-500/5';
}
};
const getSentimentIcon = (sentiment: Sentiment) => {
switch (sentiment) {
case 'POSITIVE':
return <TrendingUp className="w-4 h-4 text-green-500" />;
case 'NEGATIVE':
return <TrendingDown className="w-4 h-4 text-red-500" />;
case 'NEUTRAL':
return <Minus className="w-4 h-4 text-gray-400" />;
}
};
const getImpactBadge = (impact: string) => {
const colors = {
HIGH: 'bg-red-500/20 text-red-500',
MEDIUM: 'bg-yellow-500/20 text-yellow-500',
LOW: 'bg-blue-500/20 text-blue-500',
};
return colors[impact as keyof typeof colors] || colors.LOW;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else {
return date.toLocaleDateString();
}
};
return (
<div
className={`p-3 rounded-md border-l-4 ${getSentimentColor(
article.sentiment
)} hover:bg-dark-hover transition-colors cursor-pointer`}
onClick={() => window.open(article.url, '_blank')}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{getSentimentIcon(article.sentiment)}
<span className="text-xs font-medium text-gray-500">{article.source}</span>
<span className={`text-xs px-2 py-0.5 rounded ${getImpactBadge(article.impact_on_gold)}`}>
{article.impact_on_gold}
</span>
<span className="text-xs text-gray-500">{formatDate(article.published_at)}</span>
</div>
<h4 className="font-medium text-sm mb-1 line-clamp-2">{article.title}</h4>
{article.description && (
<p className="text-xs text-gray-400 line-clamp-2">{article.description}</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs px-2 py-0.5 rounded bg-dark-bg text-gray-400">
{article.category}
</span>
<span className="text-xs text-gray-500">
Relevance: {(article.relevance_score * 100).toFixed(0)}%
</span>
</div>
</div>
<ExternalLink className="w-4 h-4 text-gray-500 flex-shrink-0" />
</div>
</div>
);
}