AwsLinker/app/components/news/NewsPageClient.tsx
2025-09-16 17:19:58 +08:00

476 lines
22 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { Article } from '@/lib/types';
import { formatDate } from '@/lib/utils';
import { Skeleton } from '@/app/components/ui/skeleton';
import { Button } from '@/app/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader } from '@/app/components/ui/card';
import { Badge } from '@/app/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/app/components/ui/select';
import { Input } from '@/app/components/ui/input';
import { Search, Filter, Grid, List, Calendar, TrendingUp, Clock } from 'lucide-react';
import { useDebounce } from '@/hooks/useDebounce';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
interface NewsPageClientProps {
locale: string;
initialData?: {
articles: Article[];
categories: string[];
} | null;
}
export default function NewsPageClient({ locale, initialData }: NewsPageClientProps) {
const { t } = useTranslation('news');
const router = useRouter();
// 获取嵌入数据的优化版本
const getEmbeddedData = useCallback(() => {
if (typeof window !== 'undefined') {
try {
const scriptElement = document.getElementById('initial-news-data');
if (scriptElement) {
const data = JSON.parse(scriptElement.textContent || '{}');
console.log('[NewsPageClient] Found embedded data:', data);
return data;
}
} catch (error) {
console.error('Failed to parse embedded data:', error);
}
}
return null;
}, []);
const effectiveInitialData = initialData || getEmbeddedData();
// 状态管理
const [articles, setArticles] = useState<Article[]>(effectiveInitialData?.articles || []);
const [categories, setCategories] = useState<string[]>(effectiveInitialData?.categories || []);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState<string>('');
const [sortBy, setSortBy] = useState<'date' | 'title' | 'category'>('date');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [isLoading, setIsLoading] = useState<boolean>(!effectiveInitialData);
const [error, setError] = useState<string | null>(null);
// 搜索防抖
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// 检测静态模式
const isStaticMode = useCallback(() => {
if (effectiveInitialData) return true;
if (typeof window !== 'undefined') {
if (window.location.protocol === 'file:') return true;
const isDev = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname.includes('dev');
return !isDev;
}
return false;
}, [effectiveInitialData]);
// 获取文章数据的优化版本
const fetchArticles = useCallback(async () => {
if (effectiveInitialData && selectedCategory === 'all') {
console.log('[NewsPageClient] Using initial data directly');
return;
}
try {
setIsLoading(true);
setError(null);
if (isStaticMode() && effectiveInitialData) {
const filteredArticlesData = selectedCategory === 'all'
? effectiveInitialData.articles
: effectiveInitialData.articles.filter((article: Article) =>
article.metadata.category === selectedCategory);
setArticles(filteredArticlesData);
setCategories(effectiveInitialData.categories);
console.log(`[NewsPageClient] Filtered static data: ${filteredArticlesData.length} articles`);
return;
}
if (isStaticMode()) {
throw new Error('No static data available in static mode');
}
const response = await fetch(`/api/articles?locale=${locale}&category=${selectedCategory}`);
if (!response.ok) {
throw new Error('Failed to fetch articles');
}
const data = await response.json();
setArticles(data.articles);
setCategories(data.categories);
console.log(`[NewsPageClient] Loaded API data: ${data.articles.length} articles`);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
console.error('Error fetching articles:', err);
} finally {
setIsLoading(false);
}
}, [locale, selectedCategory, isStaticMode, effectiveInitialData]);
// 初始化数据
useEffect(() => {
if (effectiveInitialData && selectedCategory === 'all') {
console.log(`[NewsPageClient] Initializing with ${effectiveInitialData.articles.length} articles`);
setArticles(effectiveInitialData.articles);
setCategories(effectiveInitialData.categories);
setIsLoading(false);
return;
}
fetchArticles();
}, [fetchArticles, effectiveInitialData, selectedCategory]);
// 过滤和排序逻辑优化
const filteredAndSortedArticles = useMemo(() => {
let filtered = articles.filter((article) => {
const matchesSearch =
article.metadata.title.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
article.metadata.description.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
article.metadata.tags.some((tag) =>
tag.toLowerCase().includes(debouncedSearchQuery.toLowerCase())
);
const matchesCategory = selectedCategory === 'all' || article.metadata.category === selectedCategory;
return matchesSearch && matchesCategory;
});
// 排序逻辑
filtered.sort((a, b) => {
switch (sortBy) {
case 'date':
return new Date(b.metadata.date).getTime() - new Date(a.metadata.date).getTime();
case 'title':
return a.metadata.title.localeCompare(b.metadata.title);
case 'category':
return a.metadata.category.localeCompare(b.metadata.category);
default:
return 0;
}
});
return filtered;
}, [articles, debouncedSearchQuery, selectedCategory, sortBy]);
// 无限滚动
const {
items: displayedArticles,
loadMore,
hasMore,
isLoadingMore
} = useInfiniteScroll(filteredAndSortedArticles, 9);
// 事件处理器
const handleCategoryChange = (value: string) => {
setSelectedCategory(value);
};
const handleSortChange = (value: 'date' | 'title' | 'category') => {
setSortBy(value);
};
const handleSearch = (value: string) => {
setSearchQuery(value);
};
// 统计信息
const statsInfo = useMemo(() => {
const totalArticles = articles.length;
const filteredCount = filteredAndSortedArticles.length;
const categoriesCount = categories.length;
return {
total: totalArticles,
filtered: filteredCount,
categories: categoriesCount
};
}, [articles.length, filteredAndSortedArticles.length, categories.length]);
// 渲染骨架屏
const renderSkeleton = () => (
<div className={`grid gap-6 ${viewMode === 'grid' ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1'}`}>
{[...Array(9)].map((_, index) => (
<Card key={index} className={`flex ${viewMode === 'list' ? 'flex-row' : 'flex-col'} h-full`}>
<div className={`${viewMode === 'list' ? 'w-48 h-32' : 'w-full h-48'} relative`}>
<Skeleton className="w-full h-full rounded-t-lg" />
</div>
<CardContent className={`flex-grow p-6 ${viewMode === 'list' ? 'flex flex-col justify-between' : ''}`}>
<div>
<Skeleton className="h-4 w-20 mb-2" />
<Skeleton className="h-6 w-3/4 mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-2/3 mb-4" />
</div>
<div className="flex gap-2">
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
</CardContent>
</Card>
))}
</div>
);
// 渲染错误状态
const renderError = () => (
<div className="text-center py-12 bg-red-50 rounded-lg border border-red-200">
<div className="text-red-500 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h2 className="text-2xl font-bold text-red-600 mb-4">{t('error.title')}</h2>
<p className="text-gray-600 mb-6">{error}</p>
<Button onClick={fetchArticles} variant="outline" className="bg-white hover:bg-red-50">
{t('error.retry')}
</Button>
</div>
);
// 渲染空状态
const renderEmpty = () => (
<div className="text-center py-16 bg-gray-50 rounded-lg">
<div className="text-gray-400 mb-6">
<svg className="w-24 h-24 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">{t('news.noArticles')}</h2>
<p className="text-gray-600 mb-6 max-w-md mx-auto">{t('news.tryDifferentSearch')}</p>
<Button
onClick={() => {
setSearchQuery('');
setSelectedCategory('all');
}}
variant="outline"
className="bg-white hover:bg-gray-100"
>
{t('news.clearFilters')}
</Button>
</div>
);
// 渲染文章卡片
const renderArticleCard = (article: Article, index: number) => (
<Card
key={article.id}
className={`group ${viewMode === 'list' ? 'flex flex-row' : 'flex flex-col'} h-full hover:shadow-xl transition-all duration-300 hover:-translate-y-1 border-0 shadow-md bg-white overflow-hidden`}
>
<div className={`${viewMode === 'list' ? 'w-64 h-40' : 'w-full h-48'} relative overflow-hidden bg-gray-100`}>
<Link href={`/${locale}/news/${article.metadata.slug}`} className="block relative w-full h-full">
{article.metadata.image ? (
<Image
src={article.metadata.image}
alt={article.metadata.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-110"
sizes={viewMode === 'list' ? "256px" : "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"}
priority={index < 3}
loading={index < 3 ? "eager" : "lazy"}
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blue-100 to-purple-100 flex items-center justify-center">
<div className="text-gray-400">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
</div>
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300"></div>
</Link>
</div>
<CardContent className={`flex-grow p-4 sm:p-6 ${viewMode === 'list' ? 'flex flex-col justify-between' : ''}`}>
<div>
<div className="flex items-center gap-2 mb-3">
<Badge variant="secondary" className="bg-blue-100 text-blue-800 text-xs">
{t(`categories.${article.metadata.category}`)}
</Badge>
<div className="flex items-center text-xs text-gray-500">
<Calendar size={12} className="mr-1" />
{formatDate(article.metadata.date, locale)}
</div>
</div>
<Link href={`/${locale}/news/${article.metadata.slug}`}>
<h3 className={`font-bold text-gray-900 mb-3 line-clamp-2 group-hover:text-blue-600 transition-colors duration-200 ${viewMode === 'list' ? 'text-lg' : 'text-base sm:text-lg'} leading-tight`}>
{article.metadata.title}
</h3>
</Link>
<p className={`text-gray-600 mb-4 line-clamp-2 leading-relaxed ${viewMode === 'list' ? 'text-sm' : 'text-sm'}`}>
{article.metadata.description}
</p>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-1">
{article.metadata.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs bg-gray-50 text-gray-600 border-gray-200">
{tag}
</Badge>
))}
{article.metadata.tags.length > 3 && (
<Badge variant="outline" className="text-xs bg-gray-50 text-gray-500">
+{article.metadata.tags.length - 3}
</Badge>
)}
</div>
{viewMode === 'grid' && (
<Button
variant="outline"
size="sm"
className="w-full group-hover:bg-blue-50 group-hover:border-blue-200 group-hover:text-blue-700 transition-colors"
onClick={() => router.push(`/${locale}/news/${article.metadata.slug}`)}
>
{t('news.readMore')}
</Button>
)}
</div>
</CardContent>
</Card>
);
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* 页面头部 */}
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-2">
{t('news.title')}
</h1>
<p className="text-gray-600">
{t('news.latest')} {statsInfo.filtered} / {statsInfo.total}
</p>
</div>
<div className="hidden sm:flex items-center gap-4">
<TrendingUp className="text-blue-500" size={24} />
</div>
</div>
{/* 搜索和过滤区域 */}
<div className="flex flex-col lg:flex-row gap-4 p-6 bg-white rounded-lg shadow-sm border">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<Input
type="text"
placeholder={t('news.searchPlaceholder')}
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10 h-11"
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<Select value={selectedCategory} onValueChange={handleCategoryChange}>
<SelectTrigger className="w-full sm:w-48 h-11">
<Filter size={16} className="mr-2" />
<SelectValue placeholder={t('news.selectCategory')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('news.allCategories')}</SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{t(`categories.${category}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={handleSortChange}>
<SelectTrigger className="w-full sm:w-36 h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date"></SelectItem>
<SelectItem value="title"></SelectItem>
<SelectItem value="category"></SelectItem>
</SelectContent>
</Select>
<div className="flex border rounded-md">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
className="rounded-r-none"
>
<Grid size={16} />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="rounded-l-none"
>
<List size={16} />
</Button>
</div>
</div>
</div>
</div>
{/* 文章列表 */}
{isLoading ? (
renderSkeleton()
) : error ? (
renderError()
) : filteredAndSortedArticles.length === 0 ? (
renderEmpty()
) : (
<div className="space-y-6">
<div className={`grid gap-6 ${viewMode === 'grid' ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1'}`}>
{displayedArticles.map((article, index) => renderArticleCard(article, index))}
</div>
{/* 无限滚动加载更多 */}
{hasMore && (
<div className="text-center py-8">
<Button
onClick={loadMore}
disabled={isLoadingMore}
variant="outline"
className="min-w-32 h-11"
>
{isLoadingMore ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
...
</div>
) : (
'加载更多'
)}
</Button>
</div>
)}
{/* 底部统计信息 */}
{!hasMore && displayedArticles.length > 0 && (
<div className="text-center py-6 text-gray-500 text-sm border-t">
{displayedArticles.length}
</div>
)}
</div>
)}
</div>
);
}