NeahNew/components/observatory/observatory-view.tsx
2025-05-04 21:16:54 +02:00

357 lines
13 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RefreshCw, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ObservatoryMap } from "./observatory-map";
import { toast } from "@/components/ui/use-toast";
// News item interface matching the API response
interface NewsItem {
id: number;
title: string;
displayDate: string;
timestamp: string;
source: string;
description: string | null;
category: string | null;
url: string;
}
export function ObservatoryView() {
const [news, setNews] = useState<NewsItem[]>([]);
const [accumulatedNews, setAccumulatedNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
const [isBrowser, setIsBrowser] = useState(false);
const [useAccumulatedNews, setUseAccumulatedNews] = useState(true);
// Check if we're in the browser
useEffect(() => {
setIsBrowser(true);
}, []);
// Fetch news data
const fetchNews = async (forceRefresh = false) => {
setLoading(true);
try {
console.log('Requesting news with limit=100...');
const url = forceRefresh
? '/api/news?limit=100&refresh=true'
: '/api/news?limit=100';
console.log(`Fetching from: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch news');
}
const data = await response.json();
console.log(`Observatory received ${data.length} news articles`);
// Log first 5 articles for debugging
if (data.length > 0) {
console.log('First 5 articles:', data.slice(0, 5));
} else {
console.log('No articles received from API');
}
setNews(data);
// Update accumulated news by adding new unique articles
setAccumulatedNews(prev => {
// Use a map to keep track of unique articles by ID
const uniqueMap = new Map<number, NewsItem>();
// Add existing accumulated articles to the map
prev.forEach(item => uniqueMap.set(item.id, item));
// Add new articles to the map (will overwrite if ID already exists)
data.forEach((item: NewsItem) => uniqueMap.set(item.id, item));
// Convert map values back to an array
const newAccumulated = Array.from(uniqueMap.values());
// Sort by timestamp (newest first)
newAccumulated.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
console.log(`Accumulated ${newAccumulated.length} unique articles`);
return newAccumulated;
});
setError(null);
} catch (err) {
setError('Failed to fetch news');
console.error('Error fetching news:', err);
} finally {
setLoading(false);
}
};
// Fetch news on component mount
useEffect(() => {
fetchNews();
}, []);
// Extract countries from news data (simplified version)
const extractCountries = (newsItems: NewsItem[]) => {
// This is a simplified implementation
// In a real app, we would use NLP or a more sophisticated technique
const countries = [
'France', 'USA', 'Canada', 'UK', 'Germany', 'Japan', 'China',
'India', 'Brazil', 'Australia', 'Russia', 'Italy', 'Spain',
'Sudan', 'New York', 'United Nations', 'Ukraine', 'Egypt',
'Mexico', 'South Africa', 'Nigeria', 'Argentina', 'Pakistan',
'Indonesia', 'Saudi Arabia', 'Iran', 'Turkey', 'South Korea',
'Thailand', 'Vietnam', 'Philippines', 'Malaysia', 'Singapore',
'Israel', 'Palestine', 'Syria', 'Iraq', 'Afghanistan',
'Morocco', 'Algeria', 'Tunisia', 'Kenya', 'Ethiopia',
'Greece', 'Poland', 'Sweden', 'Norway', 'Denmark', 'Finland',
'Netherlands', 'Belgium', 'Portugal', 'Switzerland', 'Austria'
];
// Sort countries by length (to prioritize longer names)
const sortedCountries = [...countries].sort((a, b) => b.length - a.length);
const result: Record<string, NewsItem[]> = {};
newsItems.forEach(item => {
// For title and description
const titleAndDesc = [
item.title || '',
item.description || ''
].join(' ').toLowerCase();
// Check each country
sortedCountries.forEach(country => {
if (titleAndDesc.includes(country.toLowerCase())) {
if (!result[country]) {
result[country] = [];
}
// Only add once per item
if (!result[country].some(existingItem => existingItem.id === item.id)) {
result[country].push(item);
}
}
});
});
return result;
};
// Handle country selection on the map
const handleCountrySelect = (country: string) => {
setSelectedCountry(selectedCountry === country ? null : country);
};
// Get news filtered by selected country
const getFilteredNews = () => {
// Use either accumulated or direct news based on user preference
const sourceNews = useAccumulatedNews ? accumulatedNews : news;
if (!selectedCountry) return sourceNews;
const countriesMap = extractCountries(sourceNews);
const filtered = countriesMap[selectedCountry] || [];
console.log(`Filtered news for ${selectedCountry}: ${filtered.length} of ${sourceNews.length} total articles`);
return filtered;
};
// Invalidate cache
const invalidateCache = async () => {
try {
const response = await fetch('/api/news/purge-cache', {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to invalidate cache');
}
toast({
title: "Cache purged",
description: "News cache has been invalidated. Fetching fresh data...",
});
// Fetch fresh data
fetchNews(true);
} catch (err) {
console.error('Error invalidating cache:', err);
toast({
title: "Error",
description: "Failed to invalidate cache",
variant: "destructive"
});
}
};
// Loading state
if (loading) {
return (
<div className="w-full h-screen flex items-center justify-center">
<RefreshCw className="h-10 w-10 animate-spin text-gray-400" />
</div>
);
}
// Error state
if (error) {
return (
<div className="w-full h-screen flex flex-col items-center justify-center">
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={() => fetchNews(true)}>Retry</Button>
</div>
);
}
const filteredNews = getFilteredNews();
const countriesMap = extractCountries(news);
return (
<div className="w-full h-screen bg-[#f5f4ef] flex flex-col">
{/* Main Content */}
<div className="grid grid-cols-2 gap-6 p-6 flex-1 overflow-hidden">
{/* News Feed Section */}
<div className="h-full overflow-hidden">
<div className="bg-white rounded-lg shadow-md h-full flex flex-col">
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">
Latest News
<span className="text-sm font-normal ml-2 text-gray-500">
({filteredNews.length} articles)
{news.length < 10 && (
<span className="italic ml-1">
(API limitation)
</span>
)}
</span>
</h2>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setUseAccumulatedNews(!useAccumulatedNews)}
className="h-8 px-2 text-xs"
>
{useAccumulatedNews
? `Showing All (${accumulatedNews.length})`
: `Showing Latest (${news.length})`}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => invalidateCache()}
className="h-8 px-2 text-xs"
aria-label="Purge cache"
>
Purge Cache
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => fetchNews(true)}
className="h-8 w-8 p-0"
aria-label="Refresh news"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filteredNews.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Globe className="h-12 w-12 mb-2 text-gray-300" />
<p>{selectedCountry ? `No news found for ${selectedCountry}` : 'No news found'}</p>
</div>
) : (
<ul className="divide-y divide-gray-100">
{filteredNews.map((item) => (
<li key={item.id}>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="block hover:bg-gray-50 transition-colors"
>
<div className="p-4">
<div className="flex justify-between items-start mb-1">
<h3 className="font-medium text-gray-900 flex-grow">{item.title}</h3>
<span className="text-xs text-gray-500 whitespace-nowrap ml-2">{item.displayDate}</span>
</div>
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-medium text-gray-500">{item.source}</span>
{item.category && <span className="text-xs bg-gray-100 text-gray-600 rounded-full px-2 py-0.5">{item.category}</span>}
</div>
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{item.description}</p>
</div>
</a>
</li>
))}
</ul>
)}
</div>
</div>
</div>
{/* Map Section */}
<div className="h-full overflow-hidden">
<div className="bg-white rounded-lg shadow-md h-full flex flex-col">
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
<div className="flex justify-between items-center">
<h2 className="text-lg font-medium">
World Map
{selectedCountry && (
<span className="text-sm font-normal ml-2 text-gray-500">
(Selected: {selectedCountry})
</span>
)}
</h2>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCountry(null)}
className="h-8 px-2 text-xs"
disabled={!selectedCountry}
>
Clear Selection
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => fetchNews(true)}
className="h-8 w-8 p-0"
aria-label="Refresh news"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1 relative">
{isBrowser && (
<ObservatoryMap
countries={Object.entries(countriesMap).map(([name, items]) => ({
name,
count: items.length
}))}
onCountrySelect={handleCountrySelect}
selectedCountry={selectedCountry}
/>
)}
</div>
</div>
</div>
</div>
</div>
);
}