From 42a1d3cbbe864d01ad3f758079e66eba69e730ff Mon Sep 17 00:00:00 2001 From: gbanyan Date: Fri, 13 Feb 2026 22:44:24 +0800 Subject: [PATCH] feat: add Matrix rain + terminal hero with typing effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/page.tsx | 16 +-- components/hero-section.tsx | 117 +++++++++++++++++ components/matrix-rain.tsx | 118 +++++++++++++++++ components/terminal-window.tsx | 225 +++++++++++++++++++++++++++++++++ 4 files changed, 468 insertions(+), 8 deletions(-) create mode 100644 components/hero-section.tsx create mode 100644 components/matrix-rain.tsx create mode 100644 components/terminal-window.tsx diff --git a/app/page.tsx b/app/page.tsx index 520c497..6b1a767 100644 --- a/app/page.tsx +++ b/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() {
-
-

- {siteConfig.name} 的最新動態 -

-

- {siteConfig.tagline} -

-
+

+ {siteConfig.name} 的最新動態 — {siteConfig.tagline} +

+
diff --git a/components/hero-section.tsx b/components/hero-section.tsx new file mode 100644 index 0000000..736fca6 --- /dev/null +++ b/components/hero-section.tsx @@ -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('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; + let minTimerId: ReturnType; + + 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 ( +
+ {/* Matrix rain - full area, fades out */} + {!reducedMotion && ( + + )} + + {/* Terminal - fades in over Matrix */} +
+ +
+
+ ); +} diff --git a/components/matrix-rain.tsx b/components/matrix-rain.tsx new file mode 100644 index 0000000..7267851 --- /dev/null +++ b/components/matrix-rain.tsx @@ -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(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 ( +