AwsLinker/app/components/news/NewsArticlePageClient.tsx

489 lines
23 KiB
TypeScript
Raw Permalink Normal View History

2025-09-16 17:19:58 +08:00
'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>
);
}