'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('prompt'); const [displayedPrompt, setDisplayedPrompt] = useState(''); const [displayedLine1, setDisplayedLine1] = useState(''); const [displayedLine2, setDisplayedLine2] = useState(''); const [displayedPrompt2, setDisplayedPrompt2] = useState(''); const [displayedAscii, setDisplayedAscii] = useState([]); 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 (
{/* macOS-style title bar */}
gbanyan@blog — zsh
{/* Terminal content */}
~ $ {displayedPrompt} {phase === 'prompt' && showCursor && ( )}
{displayedLine1 && (
{displayedLine1} {phase === 'typing-line1' && showCursor && ( )}
)} {displayedLine2 && (
{displayedLine2} {phase === 'typing-line2' && showCursor && ( )}
)} {(phase === 'prompt2' || phase === 'typing-ascii' || displayedPrompt2 || displayedAscii.length > 0) && (
~ $ {displayedPrompt2} {phase === 'prompt2' && showCursor && ( )}
)} {displayedAscii.length > 0 && (
{displayedAscii.map((line, i) => (
{line}
))} {phase === 'typing-ascii' && showCursor && ( )}
)} {phase === 'done' && (
~ $
)}
); }