NeahNew/components/observatory/observatory-view.tsx
2025-05-06 22:01:13 +02:00

364 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 (
<main className="w-full h-screen bg-white">
<div className="w-full h-full px-4 pt-12 pb-4 flex flex-col">
{/* Title */}
<div className="bg-white border-b border-gray-100 py-3 px-6">
<h1 className="text-xl font-bold text-gray-800">Observatoire de La Paix et du Vivre-Ensemble</h1>
</div>
{/* Main Content */}
<div className="grid grid-cols-2 gap-6 py-6 px-6 flex-1 overflow-auto bg-gray-50">
{/* News Feed Section */}
<div className="h-full overflow-hidden">
<div className="bg-white rounded-lg shadow-sm border border-gray-100 h-full flex flex-col">
<div className="px-6 py-4 border-b border-gray-100 flex-shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-800">
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-2">
<Button
variant="outline"
size="sm"
onClick={() => setUseAccumulatedNews(!useAccumulatedNews)}
className="h-8 px-3 text-xs"
>
{useAccumulatedNews
? `Showing All (${accumulatedNews.length})`
: `Showing Latest (${news.length})`}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => invalidateCache()}
className="h-8 px-3 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="px-6 py-4">
<div className="flex justify-between items-start mb-2">
<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-2">
<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-2 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-sm border border-gray-100 h-full flex flex-col">
<div className="px-6 py-4 border-b border-gray-100 flex-shrink-0">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-800">
World Map
{selectedCountry && (
<span className="text-sm font-normal ml-2 text-gray-500">
(Selected: {selectedCountry})
</span>
)}
</h2>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedCountry(null)}
className="h-8 px-3 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>
</main>
);
}