244 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|