feat: add Matrix rain + terminal hero with typing effect
- Matrix rain animation on homepage load (duration tied to page load) - Sequential transition to terminal window with typing effect - cat welcome.txt → title & tagline - fastfetch → dual eagle-eye ASCII art (霍德爾之目) - prefers-reduced-motion support - SEO: sr-only h1 for accessibility Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
14
app/page.tsx
14
app/page.tsx
@@ -5,6 +5,7 @@ import { PostListItem } from '@/components/post-list-item';
|
||||
import { TimelineWrapper } from '@/components/timeline-wrapper';
|
||||
import { SidebarLayout } from '@/components/sidebar-layout';
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { HeroSection } from '@/components/hero-section';
|
||||
|
||||
export default function HomePage() {
|
||||
const posts = getAllPostsSorted().slice(0, siteConfig.postsPerPage);
|
||||
@@ -34,14 +35,13 @@ export default function HomePage() {
|
||||
<JsonLd data={collectionPageSchema} />
|
||||
<section className="space-y-6">
|
||||
<SidebarLayout>
|
||||
<header className="space-y-1 text-center">
|
||||
<h1 className="type-title font-bold text-slate-900 dark:text-slate-50">
|
||||
{siteConfig.name} 的最新動態
|
||||
<h1 className="sr-only">
|
||||
{siteConfig.name} 的最新動態 — {siteConfig.tagline}
|
||||
</h1>
|
||||
<p className="type-small text-slate-600 dark:text-slate-300">
|
||||
{siteConfig.tagline}
|
||||
</p>
|
||||
</header>
|
||||
<HeroSection
|
||||
title={`${siteConfig.name} 的最新動態`}
|
||||
tagline={siteConfig.tagline}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
|
||||
117
components/hero-section.tsx
Normal file
117
components/hero-section.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MatrixRain } from './matrix-rain';
|
||||
import { TerminalWindow } from './terminal-window';
|
||||
|
||||
interface HeroSectionProps {
|
||||
title: string;
|
||||
tagline: string;
|
||||
}
|
||||
|
||||
type Phase = 'matrix' | 'transition' | 'terminal';
|
||||
|
||||
const MIN_MATRIX_DURATION = 1500;
|
||||
const MAX_MATRIX_DURATION = 6000;
|
||||
const TRANSITION_DURATION = 600;
|
||||
|
||||
export function HeroSection({ title, tagline }: HeroSectionProps) {
|
||||
const [phase, setPhase] = useState<Phase>('matrix');
|
||||
const [matrixOpacity, setMatrixOpacity] = useState(1);
|
||||
const [terminalOpacity, setTerminalOpacity] = useState(0);
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
setReducedMotion(mq.matches);
|
||||
}, []);
|
||||
|
||||
const handleMatrixComplete = () => {
|
||||
setPhase('transition');
|
||||
setMatrixOpacity(0);
|
||||
setTerminalOpacity(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'matrix') return;
|
||||
|
||||
const startTime = Date.now();
|
||||
let maxTimerId: ReturnType<typeof setTimeout>;
|
||||
let minTimerId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const scheduleTransition = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, MIN_MATRIX_DURATION - elapsed);
|
||||
if (remaining > 0) {
|
||||
minTimerId = setTimeout(handleMatrixComplete, remaining);
|
||||
} else {
|
||||
handleMatrixComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const onLoad = () => {
|
||||
window.removeEventListener('load', onLoad);
|
||||
clearTimeout(maxTimerId);
|
||||
scheduleTransition();
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
scheduleTransition();
|
||||
} else {
|
||||
window.addEventListener('load', onLoad);
|
||||
maxTimerId = setTimeout(() => {
|
||||
window.removeEventListener('load', onLoad);
|
||||
handleMatrixComplete();
|
||||
}, MAX_MATRIX_DURATION);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('load', onLoad);
|
||||
clearTimeout(maxTimerId);
|
||||
clearTimeout(minTimerId);
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'transition') {
|
||||
const id = setTimeout(() => setPhase('terminal'), TRANSITION_DURATION);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
// Skip Matrix entirely if user prefers reduced motion
|
||||
useEffect(() => {
|
||||
if (reducedMotion) {
|
||||
setPhase('terminal');
|
||||
setMatrixOpacity(0);
|
||||
setTerminalOpacity(1);
|
||||
}
|
||||
}, [reducedMotion]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[280px] w-full overflow-hidden rounded-2xl">
|
||||
{/* Matrix rain - full area, fades out */}
|
||||
{!reducedMotion && (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-[600ms] ease-out"
|
||||
style={{ opacity: matrixOpacity }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<MatrixRain className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal - fades in over Matrix */}
|
||||
<div
|
||||
className="relative z-10 mx-auto max-w-2xl px-4 py-6 transition-opacity duration-[600ms] ease-out"
|
||||
style={{ opacity: reducedMotion ? 1 : terminalOpacity }}
|
||||
>
|
||||
<TerminalWindow
|
||||
title={title}
|
||||
tagline={tagline}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
components/matrix-rain.tsx
Normal file
118
components/matrix-rain.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
// Matrix-style characters: katakana, numbers, Latin
|
||||
const CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
interface MatrixRainProps {
|
||||
/** Opacity 0-1 for fade out control */
|
||||
opacity?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Drop {
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
chars: string[];
|
||||
charIndex: number;
|
||||
}
|
||||
|
||||
export function MatrixRain({
|
||||
opacity = 1,
|
||||
className = '',
|
||||
}: MatrixRainProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const resize = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio ?? 1, 2);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const fontSize = 14;
|
||||
const columns = Math.floor(canvas.getBoundingClientRect().width / fontSize);
|
||||
const drops: Drop[] = Array.from({ length: columns }, (_, i) => ({
|
||||
x: i * fontSize,
|
||||
y: Math.random() * -100,
|
||||
speed: 0.15 + Math.random() * 0.4,
|
||||
chars: Array.from({ length: 20 }, () =>
|
||||
CHARS[Math.floor(Math.random() * CHARS.length)]
|
||||
),
|
||||
charIndex: Math.floor(Math.random() * 20),
|
||||
}));
|
||||
|
||||
let animationId: number;
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.05)';
|
||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||
|
||||
ctx.font = `${fontSize}px "JetBrains Mono", "SF Mono", "Fira Code", monospace`;
|
||||
|
||||
drops.forEach((drop) => {
|
||||
// Bright green for leading char
|
||||
ctx.fillStyle = 'rgba(34, 197, 94, 1)';
|
||||
ctx.fillText(drop.chars[drop.charIndex], drop.x, drop.y);
|
||||
|
||||
// Dimmer trailing chars
|
||||
for (let i = 1; i < 8; i++) {
|
||||
const idx = (drop.charIndex - i + 20) % 20;
|
||||
const alpha = 1 - i * 0.12;
|
||||
ctx.fillStyle = `rgba(34, 197, 94, ${alpha * 0.4})`;
|
||||
ctx.fillText(
|
||||
drop.chars[idx],
|
||||
drop.x,
|
||||
drop.y - i * fontSize
|
||||
);
|
||||
}
|
||||
|
||||
drop.y += drop.speed * fontSize;
|
||||
if (drop.y > rect.height + 100) {
|
||||
drop.y = -50;
|
||||
drop.charIndex = (drop.charIndex + 1) % 20;
|
||||
} else {
|
||||
drop.charIndex = (drop.charIndex + 1) % 20;
|
||||
}
|
||||
});
|
||||
|
||||
animationId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationId);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={className}
|
||||
style={{
|
||||
opacity,
|
||||
transition: 'opacity 0.6s ease-out',
|
||||
background: 'rgb(15, 23, 42)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
225
components/terminal-window.tsx
Normal file
225
components/terminal-window.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// 眼睛 (霍德爾之目) - 雙鷹勾眼
|
||||
const ASCII_ART = [
|
||||
' /\\ /\\',
|
||||
' / \\ / \\',
|
||||
' | > | | > |',
|
||||
' \\ / \\ /',
|
||||
' \\/ \\/',
|
||||
];
|
||||
|
||||
interface TerminalWindowProps {
|
||||
title: string;
|
||||
tagline: string;
|
||||
/** Skip typing animation, show all at once */
|
||||
reducedMotion?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type Phase =
|
||||
| 'prompt'
|
||||
| 'typing-line1'
|
||||
| 'typing-line2'
|
||||
| 'prompt2'
|
||||
| 'typing-ascii'
|
||||
| 'done';
|
||||
|
||||
export function TerminalWindow({
|
||||
title,
|
||||
tagline,
|
||||
reducedMotion = false,
|
||||
className = '',
|
||||
}: TerminalWindowProps) {
|
||||
const [phase, setPhase] = useState<Phase>('prompt');
|
||||
const [displayedPrompt, setDisplayedPrompt] = useState('');
|
||||
const [displayedLine1, setDisplayedLine1] = useState('');
|
||||
const [displayedLine2, setDisplayedLine2] = useState('');
|
||||
const [displayedPrompt2, setDisplayedPrompt2] = useState('');
|
||||
const [displayedAscii, setDisplayedAscii] = useState<string[]>([]);
|
||||
const [showCursor, setShowCursor] = useState(true);
|
||||
|
||||
const prompt = 'cat ~/welcome.txt';
|
||||
const prompt2 = 'fastfetch';
|
||||
const line1 = `${title}`;
|
||||
const line2 = tagline;
|
||||
|
||||
const charDelay = reducedMotion ? 0 : 50;
|
||||
const lineDelay = reducedMotion ? 0 : 400;
|
||||
const asciiLineDelay = reducedMotion ? 0 : 80;
|
||||
|
||||
const typeString = useCallback(
|
||||
(
|
||||
str: string,
|
||||
setter: (s: string) => void,
|
||||
onComplete?: () => void
|
||||
) => {
|
||||
if (reducedMotion) {
|
||||
setter(str);
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
let i = 0;
|
||||
const id = setInterval(() => {
|
||||
if (i <= str.length) {
|
||||
setter(str.slice(0, i));
|
||||
i++;
|
||||
} else {
|
||||
clearInterval(id);
|
||||
onComplete?.();
|
||||
}
|
||||
}, charDelay);
|
||||
return () => clearInterval(id);
|
||||
},
|
||||
[charDelay, reducedMotion]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'prompt') {
|
||||
const cleanup = typeString(prompt, setDisplayedPrompt, () => {
|
||||
setTimeout(() => setPhase('typing-line1'), lineDelay);
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [phase, prompt, typeString, lineDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'typing-line1') {
|
||||
const cleanup = typeString(line1, setDisplayedLine1, () => {
|
||||
setTimeout(() => setPhase('typing-line2'), lineDelay);
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [phase, line1, typeString, lineDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'typing-line2') {
|
||||
const cleanup = typeString(line2, setDisplayedLine2, () => {
|
||||
setTimeout(() => setPhase('prompt2'), lineDelay);
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [phase, line2, typeString, lineDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'prompt2') {
|
||||
setDisplayedPrompt2('');
|
||||
const cleanup = typeString(prompt2, setDisplayedPrompt2, () => {
|
||||
setTimeout(() => setPhase('typing-ascii'), lineDelay);
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [phase, prompt2, typeString, lineDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'typing-ascii') {
|
||||
if (reducedMotion) {
|
||||
setDisplayedAscii(ASCII_ART);
|
||||
setTimeout(() => setPhase('done'), lineDelay);
|
||||
return;
|
||||
}
|
||||
let lineIndex = 0;
|
||||
const id = setInterval(() => {
|
||||
if (lineIndex < ASCII_ART.length) {
|
||||
setDisplayedAscii((prev) => [...prev, ASCII_ART[lineIndex]]);
|
||||
lineIndex++;
|
||||
} else {
|
||||
clearInterval(id);
|
||||
setTimeout(() => setPhase('done'), lineDelay);
|
||||
}
|
||||
}, asciiLineDelay);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
}, [phase, asciiLineDelay, lineDelay, reducedMotion]);
|
||||
|
||||
// Blinking cursor
|
||||
useEffect(() => {
|
||||
if (!reducedMotion && phase !== 'done') {
|
||||
const id = setInterval(() => setShowCursor((c) => !c), 530);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
setShowCursor(true);
|
||||
}, [phase, reducedMotion]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-xl border border-slate-700/50 bg-slate-900 shadow-xl ${className}`}
|
||||
role="img"
|
||||
aria-label={`終端機:${title} - ${tagline}`}
|
||||
>
|
||||
{/* macOS-style title bar */}
|
||||
<div className="flex items-center gap-2 border-b border-slate-700/50 px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-3 w-3 rounded-full bg-red-500/90" />
|
||||
<span className="h-3 w-3 rounded-full bg-amber-500/90" />
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500/90" />
|
||||
</div>
|
||||
<span className="ml-4 font-mono text-xs text-slate-400">
|
||||
gbanyan@blog — zsh
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Terminal content */}
|
||||
<div className="px-4 py-4 font-mono text-sm">
|
||||
<div className="text-slate-300">
|
||||
<span className="text-emerald-400">~</span>
|
||||
<span className="text-slate-500"> $ </span>
|
||||
<span>{displayedPrompt}</span>
|
||||
{phase === 'prompt' && showCursor && (
|
||||
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayedLine1 && (
|
||||
<div className="mt-2 text-slate-100">
|
||||
{displayedLine1}
|
||||
{phase === 'typing-line1' && showCursor && (
|
||||
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayedLine2 && (
|
||||
<div className="mt-1 text-slate-300">
|
||||
{displayedLine2}
|
||||
{phase === 'typing-line2' && showCursor && (
|
||||
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phase === 'prompt2' || phase === 'typing-ascii' || displayedPrompt2 || displayedAscii.length > 0) && (
|
||||
<div className="mt-2 text-slate-300">
|
||||
<span className="text-emerald-400">~</span>
|
||||
<span className="text-slate-500"> $ </span>
|
||||
<span>{displayedPrompt2}</span>
|
||||
{phase === 'prompt2' && showCursor && (
|
||||
<span className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayedAscii.length > 0 && (
|
||||
<div className="mt-2 text-emerald-400/90 whitespace-pre">
|
||||
{displayedAscii.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
{phase === 'typing-ascii' && showCursor && (
|
||||
<span className="inline-block h-4 w-0.5 animate-pulse bg-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<div className="mt-2 text-slate-300">
|
||||
<span className="text-emerald-400">~</span>
|
||||
<span className="text-slate-500"> $ </span>
|
||||
<span className="inline-block h-4 w-4 animate-pulse border-l-2 border-emerald-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user