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:
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -108,5 +108,19 @@
|
|||||||
"loadFailed": "Failed to load",
|
"loadFailed": "Failed to load",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"hotKeyword": "“{word}” is the hottest keyword this quarter!"
|
"hotKeyword": "“{word}” 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
@@ -106,7 +106,20 @@
|
|||||||
"subtitle": "从 “大型语言模型” 到 “Agent 工作流”。探索 AI 话语的演变历程。",
|
"subtitle": "从 “大型语言模型” 到 “Agent 工作流”。探索 AI 话语的演变历程。",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"loadFailed": "加载失败",
|
"loadFailed": "加载失败",
|
||||||
"retry": "重试",
|
"retry": "重试"
|
||||||
"hotKeyword": "“{word}” 是本季度最热门词汇!"
|
},
|
||||||
|
"timeline": {
|
||||||
|
"metaTitle": "AI 发展时间轴 - Agent Park",
|
||||||
|
"metaDescription": "探索人工智能大语言模型的发展历程,从 2017 年 Transformer 到今天",
|
||||||
|
"title": "AI 的故事",
|
||||||
|
"subtitle": "钉住。堆叠。之字形。",
|
||||||
|
"emptyData": "暂无数据",
|
||||||
|
"collecting": "时间轴数据正在收集中...",
|
||||||
|
"joinThePark": "加入 Agent Park",
|
||||||
|
"subscribeDesc": "订阅 Agent Park 周刊。没有垃圾邮件,只有干货。",
|
||||||
|
"emailPlaceholder": "您的电子邮箱...",
|
||||||
|
"subscribe": "订阅",
|
||||||
|
"agreeToBeCool": "我同意保持礼貌。",
|
||||||
|
"earlier": "更早"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user