489 lines
23 KiB
TypeScript
489 lines
23 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import Image from 'next/image';
|
||
import Link from 'next/link';
|
||
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 } from '@/app/components/ui/card';
|
||
import { Badge } from '@/app/components/ui/badge';
|
||
import { Share2, ArrowLeft, Calendar, User, Tag, Clock, Eye, FileX } from 'lucide-react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import rehypeRaw from 'rehype-raw';
|
||
import rehypeSanitize from 'rehype-sanitize';
|
||
import Header from '@/app/components/Header';
|
||
import Footer from '@/app/components/Footer';
|
||
|
||
interface NewsArticlePageClientProps {
|
||
articleId: string;
|
||
locale: string;
|
||
}
|
||
|
||
// 翻译数据,与首页保持一致
|
||
const translations = {
|
||
'zh-CN': {
|
||
nav: {
|
||
home: '首页',
|
||
products: '产品与服务',
|
||
news: '新闻资讯',
|
||
support: '客户支持',
|
||
about: '关于我们',
|
||
contact: '联系我们',
|
||
},
|
||
footer: {
|
||
sections: [
|
||
{
|
||
title: '目录',
|
||
items: ['首页', '产品与服务', '新闻资讯', '客户支持', '关于我们'],
|
||
},
|
||
{
|
||
title: '热门产品',
|
||
items: ['轻量云服务器', '站群服务器', 'EC2服务器', '高防服务器', 'S3云存储'],
|
||
},
|
||
{
|
||
title: '客户支持',
|
||
items: ['技术支持', '在线客服', '帮助文档', '服务状态'],
|
||
},
|
||
{
|
||
title: '公司信息',
|
||
items: ['关于我们', '联系我们', '隐私政策', '服务条款'],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
'zh-TW': {
|
||
nav: {
|
||
home: '首頁',
|
||
products: '產品與服務',
|
||
news: '新聞資訊',
|
||
support: '客戶支持',
|
||
about: '關於我們',
|
||
contact: '聯繫我們',
|
||
},
|
||
footer: {
|
||
sections: [
|
||
{
|
||
title: '目錄',
|
||
items: ['首頁', '產品與服務', '新聞資訊', '客戶支持', '關於我們'],
|
||
},
|
||
{
|
||
title: '熱門產品',
|
||
items: ['輕量雲服務器', '站群服務器', 'EC2服務器', '高防服務器', 'S3雲存儲'],
|
||
},
|
||
{
|
||
title: '客戶支持',
|
||
items: ['技術支持', '在線客服', '幫助文檔', '服務狀態'],
|
||
},
|
||
{
|
||
title: '公司信息',
|
||
items: ['關於我們', '聯繫我們', '隱私政策', '服務條款'],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
'en': {
|
||
nav: {
|
||
home: 'Home',
|
||
products: 'Products & Services',
|
||
news: 'News',
|
||
support: 'Support',
|
||
about: 'About Us',
|
||
contact: 'Contact Us',
|
||
},
|
||
footer: {
|
||
sections: [
|
||
{
|
||
title: 'Directory',
|
||
items: ['Home', 'Products & Services', 'News', 'Support', 'About Us'],
|
||
},
|
||
{
|
||
title: 'Popular Products',
|
||
items: ['Lightweight Cloud Server', 'Station Group Server', 'EC2 Server', 'High-Defense Server', 'S3 Cloud Storage'],
|
||
},
|
||
{
|
||
title: 'Customer Support',
|
||
items: ['Technical Support', 'Online Service', 'Documentation', 'Service Status'],
|
||
},
|
||
{
|
||
title: 'Company Info',
|
||
items: ['About Us', 'Contact Us', 'Privacy Policy', 'Terms of Service'],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
};
|
||
|
||
export default function NewsArticlePageClient({ articleId, locale }: NewsArticlePageClientProps) {
|
||
const { t } = useTranslation('article');
|
||
const router = useRouter();
|
||
const [article, setArticle] = useState<Article | null>(null);
|
||
const [relatedArticles, setRelatedArticles] = useState<Article[]>([]);
|
||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [readingProgress, setReadingProgress] = useState(0);
|
||
const [language, setLanguage] = useState(locale);
|
||
|
||
// 获取当前语言的翻译
|
||
const currentTranslations = translations[language as keyof typeof translations] || translations['zh-CN'];
|
||
|
||
// 阅读进度条
|
||
useEffect(() => {
|
||
// 确保在客户端执行
|
||
if (typeof window === 'undefined') return;
|
||
|
||
const updateReadingProgress = () => {
|
||
const scrollTop = window.scrollY;
|
||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||
const progress = (scrollTop / docHeight) * 100;
|
||
setReadingProgress(Math.min(100, Math.max(0, progress)));
|
||
};
|
||
|
||
// 防抖处理
|
||
let timeoutId: NodeJS.Timeout;
|
||
const throttledUpdateProgress = () => {
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
timeoutId = setTimeout(updateReadingProgress, 16); // ~60fps
|
||
};
|
||
|
||
window.addEventListener('scroll', throttledUpdateProgress, { passive: true });
|
||
return () => {
|
||
window.removeEventListener('scroll', throttledUpdateProgress);
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const fetchArticle = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
// 清除之前的文章数据,避免DOM操作错误
|
||
setArticle(null);
|
||
setRelatedArticles([]);
|
||
|
||
const response = await fetch(`/api/articles/${articleId}?locale=${locale}`);
|
||
if (!response.ok) {
|
||
if (response.status === 404) {
|
||
const errorMessage = locale === 'en'
|
||
? 'This article is not available in English. Please try viewing it in Chinese or check other articles.'
|
||
: locale === 'zh-TW'
|
||
? '此文章沒有繁體中文版本。請嘗試查看中文版本或查看其他文章。'
|
||
: '此文章不存在或已被移除。请检查网址是否正确。';
|
||
throw new Error(errorMessage);
|
||
}
|
||
throw new Error('Failed to fetch article');
|
||
}
|
||
|
||
const data = await response.json();
|
||
setArticle(data.article);
|
||
setRelatedArticles(data.relatedArticles);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||
console.error('Error fetching article:', err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchArticle();
|
||
}, [articleId, locale]);
|
||
|
||
// 估算阅读时间
|
||
const estimateReadingTime = (content: string) => {
|
||
const wordsPerMinute = 200;
|
||
const words = content.split(/\s+/).length;
|
||
return Math.ceil(words / wordsPerMinute);
|
||
};
|
||
|
||
// 分享文章
|
||
const handleShare = async () => {
|
||
if (navigator.share && article) {
|
||
try {
|
||
await navigator.share({
|
||
title: article.metadata.title,
|
||
text: article.metadata.description,
|
||
url: typeof window !== 'undefined' ? window.location.href : '',
|
||
});
|
||
} catch (err) {
|
||
console.error('Error sharing article:', err);
|
||
}
|
||
} else {
|
||
// 复制链接到剪贴板
|
||
try {
|
||
await navigator.clipboard.writeText(typeof window !== 'undefined' ? window.location.href : '');
|
||
alert('链接已复制到剪贴板');
|
||
} catch (err) {
|
||
console.error('Error copying link:', err);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 渲染加载状态
|
||
const renderSkeleton = () => (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<Header language={language} setLanguage={setLanguage} translations={currentTranslations} locale={locale} />
|
||
<div className="container mx-auto px-4 py-8">
|
||
<div className="max-w-4xl mx-auto bg-white rounded-lg shadow-sm p-8">
|
||
<Skeleton className="h-12 w-3/4 mb-8" />
|
||
<Skeleton className="w-full h-96 mb-8 rounded-lg" />
|
||
<div className="flex gap-4 mb-8">
|
||
<Skeleton className="h-6 w-24" />
|
||
<Skeleton className="h-6 w-24" />
|
||
<Skeleton className="h-6 w-24" />
|
||
</div>
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-3/4" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Footer translations={currentTranslations} />
|
||
</div>
|
||
);
|
||
|
||
// 渲染错误状态
|
||
const renderError = () => {
|
||
const errorTitle = locale === 'en'
|
||
? 'Article Not Found'
|
||
: locale === 'zh-TW'
|
||
? '文章未找到'
|
||
: '文章未找到';
|
||
|
||
const backButtonText = locale === 'en'
|
||
? 'Go Back'
|
||
: locale === 'zh-TW'
|
||
? '返回上一頁'
|
||
: '返回上一页';
|
||
|
||
const newsListText = locale === 'en'
|
||
? 'View All News'
|
||
: locale === 'zh-TW'
|
||
? '查看所有新聞'
|
||
: '查看所有新闻';
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<Header language={language} setLanguage={setLanguage} translations={currentTranslations} locale={locale} />
|
||
<div className="container mx-auto px-4 py-8">
|
||
<div className="max-w-4xl mx-auto bg-white rounded-lg shadow-sm p-8 text-center">
|
||
<div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-6">
|
||
<FileX className="w-8 h-8 text-red-600" />
|
||
</div>
|
||
<h2 className="text-2xl font-bold text-red-600 mb-4">{errorTitle}</h2>
|
||
<p className="text-gray-600 mb-6 max-w-md mx-auto">{error}</p>
|
||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||
<Button onClick={() => router.back()} variant="outline">
|
||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||
{backButtonText}
|
||
</Button>
|
||
<Button onClick={() => router.push(locale === 'zh-CN' ? '/news' : `/${locale}/news`)}>
|
||
{newsListText}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Footer translations={currentTranslations} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
if (isLoading) {
|
||
return renderSkeleton();
|
||
}
|
||
|
||
if (error || !article) {
|
||
return renderError();
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
{/* 阅读进度条 */}
|
||
<div className="fixed top-0 left-0 w-full h-1 bg-gray-200 z-50">
|
||
<div
|
||
className="h-full bg-blue-600 transition-all duration-150 ease-out"
|
||
style={{ width: `${readingProgress}%` }}
|
||
/>
|
||
</div>
|
||
|
||
<Header language={language} setLanguage={setLanguage} translations={currentTranslations} locale={locale} />
|
||
|
||
<main className="container mx-auto px-4 py-8">
|
||
<div className="max-w-4xl mx-auto">
|
||
{/* 返回按钮 */}
|
||
<Button variant="ghost" className="mb-6 hover:bg-blue-50" onClick={() => router.back()}>
|
||
<ArrowLeft className="mr-2" size={20} />
|
||
返回新闻列表
|
||
</Button>
|
||
|
||
{/* 文章主体 */}
|
||
<article className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||
{/* 文章封面图 */}
|
||
<div className="relative w-full h-[400px] md:h-[500px]">
|
||
<Image
|
||
src={article.metadata.image || '/api/placeholder/800/500'}
|
||
alt={article.metadata.title}
|
||
fill
|
||
className="object-cover"
|
||
priority
|
||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||
<div className="absolute bottom-6 left-6 right-6">
|
||
<Badge variant="secondary" className="mb-4 bg-white/90 text-gray-800">
|
||
{article.metadata.category}
|
||
</Badge>
|
||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white leading-tight">
|
||
{article.metadata.title}
|
||
</h1>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6 md:p-8 lg:p-12">
|
||
{/* 文章元数据 */}
|
||
<div className="flex flex-wrap items-center gap-4 md:gap-6 mb-8 text-gray-600 border-b border-gray-100 pb-6">
|
||
<div className="flex items-center">
|
||
<Calendar className="mr-2" size={16} />
|
||
<span className="text-sm">{formatDate(article.metadata.date, locale)}</span>
|
||
</div>
|
||
<div className="flex items-center">
|
||
<User className="mr-2" size={16} />
|
||
<span className="text-sm">{article.metadata.author}</span>
|
||
</div>
|
||
<div className="flex items-center">
|
||
<Clock className="mr-2" size={16} />
|
||
<span className="text-sm">{estimateReadingTime(article.content)} 分钟阅读</span>
|
||
</div>
|
||
<div className="flex items-center">
|
||
<Eye className="mr-2" size={16} />
|
||
<span className="text-sm">阅读量 {Math.floor(Math.random() * 1000) + 100}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 文章摘要 */}
|
||
{article.metadata.excerpt && (
|
||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-8 rounded-r-lg">
|
||
<p className="text-gray-700 italic text-lg leading-relaxed">
|
||
{article.metadata.excerpt}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 文章内容 */}
|
||
<div className="prose prose-lg prose-blue max-w-none mb-12
|
||
prose-headings:text-gray-900 prose-headings:font-bold
|
||
prose-p:text-gray-700 prose-p:leading-relaxed prose-p:mb-6
|
||
prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
|
||
prose-strong:text-gray-900 prose-strong:font-semibold
|
||
prose-code:bg-gray-100 prose-code:px-2 prose-code:py-1 prose-code:rounded
|
||
prose-pre:bg-gray-900 prose-pre:text-gray-100
|
||
prose-blockquote:border-l-4 prose-blockquote:border-blue-500 prose-blockquote:bg-blue-50 prose-blockquote:pl-6 prose-blockquote:py-4
|
||
prose-ul:my-6 prose-ol:my-6
|
||
prose-li:my-2 prose-li:text-gray-700
|
||
prose-img:rounded-lg prose-img:shadow-md">
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm]}
|
||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||
components={{
|
||
h1: ({ children }) => <h1 className="text-3xl font-bold mt-8 mb-4 text-gray-900">{children}</h1>,
|
||
h2: ({ children }) => <h2 className="text-2xl font-bold mt-8 mb-4 text-gray-900">{children}</h2>,
|
||
h3: ({ children }) => <h3 className="text-xl font-bold mt-6 mb-3 text-gray-900">{children}</h3>,
|
||
p: ({ children }) => <p className="mb-6 text-gray-700 leading-relaxed">{children}</p>,
|
||
img: ({ src, alt }) => (
|
||
<div className="my-8">
|
||
<Image
|
||
src={src || '/api/placeholder/600/400'}
|
||
alt={alt || ''}
|
||
width={600}
|
||
height={400}
|
||
className="rounded-lg shadow-md w-full h-auto"
|
||
/>
|
||
</div>
|
||
),
|
||
}}
|
||
>
|
||
{article.content}
|
||
</ReactMarkdown>
|
||
</div>
|
||
|
||
{/* 文章标签 */}
|
||
<div className="flex flex-wrap gap-2 mb-8 pt-6 border-t border-gray-100">
|
||
<span className="text-sm text-gray-600 mr-2">标签:</span>
|
||
{article.metadata.tags.map((tag) => (
|
||
<Badge key={tag} variant="outline" className="hover:bg-blue-50">
|
||
#{tag}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
|
||
{/* 分享按钮 */}
|
||
<div className="flex justify-center">
|
||
<Button
|
||
variant="outline"
|
||
className="hover:bg-blue-50 hover:border-blue-300"
|
||
onClick={handleShare}
|
||
>
|
||
<Share2 className="mr-2" size={20} />
|
||
分享文章
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
{/* 相关文章 */}
|
||
{relatedArticles.length > 0 && (
|
||
<section className="mt-12">
|
||
<h2 className="text-2xl font-bold mb-6 text-gray-900">相关文章推荐</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{relatedArticles.map((relatedArticle) => (
|
||
<Card
|
||
key={relatedArticle.id}
|
||
className="hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
|
||
>
|
||
<CardContent className="p-0">
|
||
<Link href={`/${locale}/news/${relatedArticle.metadata.slug}`}>
|
||
<div className="relative h-48 rounded-t-lg overflow-hidden">
|
||
<Image
|
||
src={relatedArticle.metadata.image || '/api/placeholder/400/250'}
|
||
alt={relatedArticle.metadata.title}
|
||
fill
|
||
className="object-cover transition-transform duration-300 hover:scale-105"
|
||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||
/>
|
||
</div>
|
||
<div className="p-4">
|
||
<Badge variant="secondary" className="mb-2 text-xs">
|
||
{relatedArticle.metadata.category}
|
||
</Badge>
|
||
<h3 className="text-lg font-semibold mb-2 hover:text-blue-600 transition-colors line-clamp-2">
|
||
{relatedArticle.metadata.title}
|
||
</h3>
|
||
<p className="text-gray-600 text-sm line-clamp-2 mb-3">
|
||
{relatedArticle.metadata.excerpt || relatedArticle.metadata.description}
|
||
</p>
|
||
<div className="flex items-center text-xs text-gray-500">
|
||
<Calendar className="mr-1" size={12} />
|
||
{formatDate(relatedArticle.metadata.date, locale)}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<Footer translations={currentTranslations} />
|
||
</div>
|
||
);
|
||
}
|