refactor: extract EventCard and TimelineSection components
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { getAIEvents } from '@/hooks/useAIEvents';
|
import { getAIEvents } from '@/hooks/useAIEvents';
|
||||||
|
import { TimelineSection } from '@/components/timeline/TimelineSection';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
export const revalidate = 3600; // ISR 1小时
|
export const revalidate = 3600;
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -13,25 +14,21 @@ export async function generateMetadata({
|
|||||||
return {
|
return {
|
||||||
title: locale === 'zh' ? 'AI 发展时间轴' : 'AI Timeline',
|
title: locale === 'zh' ? 'AI 发展时间轴' : 'AI Timeline',
|
||||||
description: locale === 'zh'
|
description: locale === 'zh'
|
||||||
? '探索人工智能大语言模型的发展历程,从 2017 年 Transformer 到今天'
|
? '探索人工智能大语言模型的发展历程'
|
||||||
: 'Explore the evolution of AI large language models from 2017 Transformer to today',
|
: 'Explore the evolution of AI large language models',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function TimelinePage() {
|
export default async function TimelinePage() {
|
||||||
const events = await getAIEvents();
|
const events = await getAIEvents();
|
||||||
|
|
||||||
// 按年份分组
|
|
||||||
const eventsByYear = events.reduce((acc, event) => {
|
const eventsByYear = events.reduce((acc, event) => {
|
||||||
const year = new Date(event.eventDate).getFullYear();
|
const year = new Date(event.eventDate).getFullYear();
|
||||||
if (!acc[year]) {
|
if (!acc[year]) acc[year] = [];
|
||||||
acc[year] = [];
|
|
||||||
}
|
|
||||||
acc[year].push(event);
|
acc[year].push(event);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<number, typeof events>);
|
}, {} as Record<number, typeof events>);
|
||||||
|
|
||||||
// 按年份降序排序
|
|
||||||
const sortedYears = Object.keys(eventsByYear)
|
const sortedYears = Object.keys(eventsByYear)
|
||||||
.map(Number)
|
.map(Number)
|
||||||
.sort((a, b) => b - a);
|
.sort((a, b) => b - a);
|
||||||
@@ -53,7 +50,6 @@ export default async function TimelinePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background-light dark:bg-background-dark">
|
<div className="min-h-screen bg-background-light dark:bg-background-dark">
|
||||||
{/* Header */}
|
|
||||||
<header className="pt-32 pb-16 px-4 max-w-[1600px] mx-auto">
|
<header className="pt-32 pb-16 px-4 max-w-[1600px] mx-auto">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="font-display font-black text-6xl md:text-8xl tracking-tight leading-none text-black dark:text-white mb-6">
|
<h1 className="font-display font-black text-6xl md:text-8xl tracking-tight leading-none text-black dark:text-white mb-6">
|
||||||
@@ -65,91 +61,14 @@ export default async function TimelinePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Timeline */}
|
|
||||||
<main className="px-4 md:px-12 max-w-[1600px] mx-auto pb-32">
|
<main className="px-4 md:px-12 max-w-[1600px] mx-auto pb-32">
|
||||||
{sortedYears.map((year, index) => (
|
{sortedYears.map((year, index) => (
|
||||||
<section
|
<TimelineSection
|
||||||
key={year}
|
key={year}
|
||||||
className={`relative min-h-[500px] mb-32 flex ${
|
year={year}
|
||||||
index % 2 === 0 ? 'flex-row' : 'flex-row-reverse'
|
events={eventsByYear[year]}
|
||||||
}`}
|
index={index}
|
||||||
>
|
/>
|
||||||
{/* Year Label */}
|
|
||||||
<div
|
|
||||||
className={`absolute ${index % 2 === 0 ? 'left-0' : 'right-0'} -top-8 z-20`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`${
|
|
||||||
index % 2 === 0 ? 'bg-primary' : 'bg-secondary'
|
|
||||||
} border-4 border-black px-6 py-2 ${
|
|
||||||
index % 2 === 0 ? '-rotate-2' : 'rotate-2'
|
|
||||||
} shadow-hard`}
|
|
||||||
>
|
|
||||||
<span className="font-display font-black text-3xl md:text-5xl">
|
|
||||||
{year}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Events Container */}
|
|
||||||
<div
|
|
||||||
className={`w-full ${
|
|
||||||
index % 2 === 0 ? 'pl-0 md:pl-8 pr-0 md:pr-32' : 'pl-0 md:pl-32 pr-0 md:pr-8'
|
|
||||||
} pt-20`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-nowrap overflow-x-visible items-center justify-start">
|
|
||||||
{eventsByYear[year].map((event, eventIndex) => (
|
|
||||||
<div
|
|
||||||
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 hover:z-50 hover:-translate-y-5 hover:scale-105 transition-all duration-300"
|
|
||||||
style={{
|
|
||||||
zIndex: Math.max(1, 50 - eventIndex * 10),
|
|
||||||
transform: `rotate(${(Math.random() - 0.5) * 6}deg)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Tape decoration */}
|
|
||||||
<div
|
|
||||||
className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Image */}
|
|
||||||
<div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={event.imageUrl}
|
|
||||||
alt={event.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<h3 className="font-display font-bold text-xl leading-none mb-2 uppercase">
|
|
||||||
{event.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs leading-snug opacity-80 line-clamp-4 mb-4">
|
|
||||||
{event.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2">
|
|
||||||
{new Date(event.eventDate).toLocaleDateString('zh-CN')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Source Link */}
|
|
||||||
{event.sourceUrl && (
|
|
||||||
<a
|
|
||||||
href={event.sourceUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="absolute bottom-4 right-4 text-[10px] font-bold underline"
|
|
||||||
>
|
|
||||||
来源 →
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
))}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AIEvent } from '@prisma/client';
|
||||||
|
|
||||||
|
interface EventCardProps {
|
||||||
|
event: AIEvent;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventCard({ event, index }: EventCardProps) {
|
||||||
|
const rotation = (Math.random() - 0.5) * 6;
|
||||||
|
const zIndex = Math.max(1, 50 - index * 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
style={{
|
||||||
|
zIndex,
|
||||||
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Tape decoration */}
|
||||||
|
<div className="tape absolute -top-3 left-1/2 -translate-x-1/2 w-20 h-6" />
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<div className="h-40 bg-primary border-2 border-black dark:border-white mb-4 flex items-center justify-center overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={event.imageUrl}
|
||||||
|
alt={event.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<h3 className="font-display font-bold text-xl leading-none mb-2 uppercase">
|
||||||
|
{event.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs leading-snug opacity-80 line-clamp-4 mb-4">
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div className="absolute bottom-4 left-4 text-[10px] font-bold bg-black text-white px-2">
|
||||||
|
{new Date(event.eventDate).toLocaleDateString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Link */}
|
||||||
|
{event.sourceUrl && (
|
||||||
|
<a
|
||||||
|
href={event.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="absolute bottom-4 right-4 text-[10px] font-bold underline"
|
||||||
|
>
|
||||||
|
来源 →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { AIEvent } from '@prisma/client';
|
||||||
|
import { EventCard } from './EventCard';
|
||||||
|
|
||||||
|
interface TimelineSectionProps {
|
||||||
|
year: number;
|
||||||
|
events: AIEvent[];
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineSection({ year, events, index }: TimelineSectionProps) {
|
||||||
|
const isEven = index % 2 === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`relative min-h-[500px] mb-32 flex ${isEven ? 'flex-row' : 'flex-row-reverse'}`}>
|
||||||
|
{/* Year Label */}
|
||||||
|
<div className={`absolute ${isEven ? 'left-0' : 'right-0'} -top-8 z-20`}>
|
||||||
|
<div
|
||||||
|
className={`${isEven ? 'bg-primary' : 'bg-secondary'} border-4 border-black px-6 py-2 ${isEven ? '-rotate-2' : 'rotate-2'} shadow-hard`}
|
||||||
|
>
|
||||||
|
<span className="font-display font-black text-3xl md:text-5xl">
|
||||||
|
{year}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events Container */}
|
||||||
|
<div
|
||||||
|
className={`w-full ${isEven ? 'pl-0 md:pl-8 pr-0 md:pr-32' : 'pl-0 md:pl-32 pr-0 md:pr-8'} pt-20`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-nowrap overflow-x-visible items-center justify-start">
|
||||||
|
{events.map((event, eventIndex) => (
|
||||||
|
<EventCard
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
index={eventIndex}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user