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

489 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}