feat: add Timeline i18n support and deterministic EventCard rotation

- Add complete Chinese/English translations for timeline page
- Use event ID-based rotation instead of Math.random() for SSR consistency
- Enhance tape decoration styling with blur and transparency
- Fix locale-aware date formatting
This commit is contained in:
2026-02-01 15:40:01 +08:00
parent e6ff7470a1
commit 47a278ca1b
4 changed files with 70 additions and 25 deletions
+25 -17
View File
@@ -1,5 +1,6 @@
import { getAIEvents } from '@/hooks/useAIEvents'; import { getAIEvents } from '@/hooks/useAIEvents';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
export const revalidate = 3600; export const revalidate = 3600;
@@ -9,16 +10,23 @@ export async function generateMetadata({
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations('timeline');
return { return {
title: locale === 'zh' ? 'AI 发展时间轴' : 'AI Timeline', title: t('metaTitle'),
description: locale === 'zh' description: t('metaDescription'),
? '探索人工智能大语言模型的发展历程'
: 'Explore the evolution of AI large language models',
}; };
} }
export default async function TimelinePage() { export default async function TimelinePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations('timeline');
const tCommon = await getTranslations('common');
const events = await getAIEvents(); const events = await getAIEvents();
// 按年份分组 // 按年份分组
@@ -41,10 +49,10 @@ export default async function TimelinePage() {
<div className="min-h-screen bg-background-light dark:bg-background-dark flex items-center justify-center"> <div className="min-h-screen bg-background-light dark:bg-background-dark flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<h1 className="font-display font-black text-4xl mb-4"> <h1 className="font-display font-black text-4xl mb-4">
暂无数据 {t('emptyData')}
</h1> </h1>
<p className="font-mono text-gray-600"> <p className="font-mono text-gray-600">
时间轴数据正在收集中... {t('collecting')}
</p> </p>
</div> </div>
</div> </div>
@@ -75,11 +83,11 @@ export default async function TimelinePage() {
/> />
</svg> </svg>
<h1 className="font-display font-black text-6xl md:text-8xl tracking-tight leading-none text-black dark:text-white drop-shadow-sm"> <h1 className="font-display font-black text-6xl md:text-8xl tracking-tight leading-none text-black dark:text-white drop-shadow-sm">
THE STORY <br /> OF A.I. {t('title')}
</h1> </h1>
</div> </div>
<p className="mt-6 text-lg md:text-xl font-mono max-w-2xl mx-auto bg-white dark:bg-black border border-black dark:border-white p-2 rotate-1 inline-block shadow-[4px_4px_0px_0px_#000] dark:shadow-[4px_4px_0px_0px_#fff]"> <p className="mt-6 text-lg md:text-xl font-mono max-w-2xl mx-auto bg-white dark:bg-black border border-black dark:border-white p-2 rotate-1 inline-block shadow-[4px_4px_0px_0px_#000] dark:shadow-[4px_4px_0px_0px_#fff]">
Pinned. Stacked. Zigzagged. {t('subtitle')}
</p> </p>
</header> </header>
@@ -132,7 +140,7 @@ export default async function TimelinePage() {
} pt-20`} } pt-20`}
> >
<div className="flex flex-nowrap overflow-x-visible items-center justify-start py-10"> <div className="flex flex-nowrap overflow-x-visible items-center justify-start py-10">
{eventsByYear[year].map((event, eventIndex) => ( {eventsByYear[year]?.map((event, eventIndex) => (
<div <div
key={event.id} key={event.id}
className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56" className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56"
@@ -163,7 +171,7 @@ export default async function TimelinePage() {
{/* Date */} {/* Date */}
<div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2"> <div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2">
{new Date(event.eventDate).toLocaleDateString('zh-CN')} {new Date(event.eventDate).toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US')}
</div> </div>
{/* Source Link */} {/* Source Link */}
@@ -174,7 +182,7 @@ export default async function TimelinePage() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="absolute bottom-4 right-4 text-[10px] font-bold underline" className="absolute bottom-4 right-4 text-[10px] font-bold underline"
> >
来源 → {locale === 'zh' ? '来源 →' : 'Source →'}
</a> </a>
)} )}
</div> </div>
@@ -195,22 +203,22 @@ export default async function TimelinePage() {
</span> </span>
</div> </div>
<h2 className="font-display font-black text-3xl md:text-5xl mb-4 text-black uppercase tracking-tight"> <h2 className="font-display font-black text-3xl md:text-5xl mb-4 text-black uppercase tracking-tight">
Join the Park {t('joinThePark')}
</h2> </h2>
<p className="font-mono text-black mb-8 text-base font-bold"> <p className="font-mono text-black mb-8 text-base font-bold">
Subscribe to the Agent Park weekly zine. No spam, just ducks and data. {t('subscribeDesc')}
</p> </p>
<form className="flex flex-col md:flex-row gap-4"> <form className="flex flex-col md:flex-row gap-4">
<input <input
className="flex-1 bg-white border-4 border-black px-6 py-4 font-mono focus:ring-0 focus:border-black focus:shadow-[4px_4px_0px_0px_#000] transition-all placeholder:text-gray-500 text-black text-lg" className="flex-1 bg-white border-4 border-black px-6 py-4 font-mono focus:ring-0 focus:border-black focus:shadow-[4px_4px_0px_0px_#000] transition-all placeholder:text-gray-500 text-black text-lg"
placeholder="Your email here..." placeholder={t('emailPlaceholder')}
type="email" type="email"
/> />
<button <button
className="bg-black text-white px-10 py-4 font-black border-4 border-transparent hover:bg-white hover:text-black hover:border-black transition-all hover:shadow-[6px_6px_0px_0px_#000] uppercase text-lg" className="bg-black text-white px-10 py-4 font-black border-4 border-transparent hover:bg-white hover:text-black hover:border-black transition-all hover:shadow-[6px_6px_0px_0px_#000] uppercase text-lg"
type="button" type="button"
> >
SUBSCRIBE {t('subscribe')}
</button> </button>
</form> </form>
<div className="mt-6 flex items-center gap-3"> <div className="mt-6 flex items-center gap-3">
@@ -220,7 +228,7 @@ export default async function TimelinePage() {
type="checkbox" type="checkbox"
/> />
<label className="text-sm font-black text-black uppercase" htmlFor="check"> <label className="text-sm font-black text-black uppercase" htmlFor="check">
I agree to be cool. {t('agreeToBeCool')}
</label> </label>
</div> </div>
</div> </div>
+16 -6
View File
@@ -3,22 +3,32 @@ import { AIEvent } from '@prisma/client';
interface EventCardProps { interface EventCardProps {
event: AIEvent; event: AIEvent;
index: number; index: number;
baseIndex?: number;
} }
export function EventCard({ event, index }: EventCardProps) { export function EventCard({ event, index, baseIndex = 30 }: EventCardProps) {
const rotation = (Math.random() - 0.5) * 6; // Generate consistent rotation based on event ID
const zIndex = Math.max(1, 50 - index * 10); const rotation = ((parseInt(event.id.slice(-4), 36) % 14) - 7); // -7 to 7 degrees
const zIndex = Math.max(1, baseIndex - index * 10);
return ( return (
<div <div
className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56 hover:z-50 hover:-translate-y-5 hover:scale-105 transition-all duration-300" className="stack-card relative w-72 h-96 flex-shrink-0 bg-surface-light dark:bg-surface-dark border-4 border-black dark:border-white p-4 shadow-hard -mr-48 md:-mr-56"
style={{ style={{
zIndex, zIndex,
transform: `rotate(${rotation}deg)`, transform: `rotate(${rotation}deg)`,
}} }}
> >
{/* Tape decoration */} {/* Tape decoration with transparency and blur */}
<div className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6" /> <div
className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6 rotate-1"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.4)',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
backdropFilter: 'blur(2px)',
border: '1px solid rgba(255,255,255,0.6)',
}}
/>
{/* Image */} {/* Image */}
<div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden"> <div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
+14
View File
@@ -108,5 +108,19 @@
"loadFailed": "Failed to load", "loadFailed": "Failed to load",
"retry": "Retry", "retry": "Retry",
"hotKeyword": "&ldquo;{word}&rdquo; is the hottest keyword this quarter!" "hotKeyword": "&ldquo;{word}&rdquo; is the hottest keyword this quarter!"
},
"timeline": {
"metaTitle": "AI Timeline - Agent Park",
"metaDescription": "Explore the evolution of AI large language models from 2017 Transformer to today",
"title": "THE STORY OF A.I.",
"subtitle": "Pinned. Stacked. Zigzagged.",
"emptyData": "No data available",
"collecting": "Timeline data is being collected...",
"joinThePark": "Join the Park",
"subscribeDesc": "Subscribe to the Agent Park weekly zine. No spam, just ducks and data.",
"emailPlaceholder": "Your email here...",
"subscribe": "SUBSCRIBE",
"agreeToBeCool": "I agree to be cool.",
"earlier": "Earlier"
} }
} }
+15 -2
View File
@@ -106,7 +106,20 @@
"subtitle": "从 &ldquo;大型语言模型&rdquo; 到 &ldquo;Agent 工作流&rdquo;。探索 AI 话语的演变历程。", "subtitle": "从 &ldquo;大型语言模型&rdquo; 到 &ldquo;Agent 工作流&rdquo;。探索 AI 话语的演变历程。",
"loading": "加载中...", "loading": "加载中...",
"loadFailed": "加载失败", "loadFailed": "加载失败",
"retry": "重试", "retry": "重试"
"hotKeyword": "&ldquo;{word}&rdquo; 是本季度最热门词汇!" },
"timeline": {
"metaTitle": "AI 发展时间轴 - Agent Park",
"metaDescription": "探索人工智能大语言模型的发展历程,从 2017 年 Transformer 到今天",
"title": "AI 的故事",
"subtitle": "钉住。堆叠。之字形。",
"emptyData": "暂无数据",
"collecting": "时间轴数据正在收集中...",
"joinThePark": "加入 Agent Park",
"subscribeDesc": "订阅 Agent Park 周刊。没有垃圾邮件,只有干货。",
"emailPlaceholder": "您的电子邮箱...",
"subscribe": "订阅",
"agreeToBeCool": "我同意保持礼貌。",
"earlier": "更早"
} }
} }