476 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|