1+ import { useState , useEffect , useRef , type ReactNode } from 'react'
2+
13type Hero = {
24 eyebrow : string
35 title : string
@@ -20,38 +22,198 @@ type Hero = {
2022type Snapshot = { title : string ; items : string [ ] ; subtitle ?: string | null }
2123type HeroSectionProps = { hero : Hero ; snapshot : Snapshot ; theme : 'dark' | 'light' }
2224
23- function Prompt ( { cmd } : { cmd : string } ) {
25+ type Block = { cmd : string ; output : ReactNode }
26+
27+ const TYPING_SPEED = 48 // ms per character
28+ const OUTPUT_DELAY = 220 // ms after cmd finishes before output appears
29+ const NEXT_DELAY = 550 // ms after output before next cmd starts
30+
31+ function PromptLine ( { cmd, partial } : { cmd : string ; partial ?: boolean } ) {
2432 return (
2533 < p className = "leading-6" >
2634 < span className = "select-none text-green-700" > ~/profile </ span >
2735 < span className = "select-none text-green-500" > ❯ </ span >
2836 < span className = "text-green-300" > { cmd } </ span >
37+ { partial && (
38+ < span className = "ml-px inline-block h-[14px] w-2 translate-y-[2px] animate-pulse bg-green-400" />
39+ ) }
2940 </ p >
3041 )
3142}
3243
33- function Output ( { children } : { children : React . ReactNode } ) {
44+ function Output ( { children } : { children : ReactNode } ) {
3445 return < div className = "pl-4 text-green-500" > { children } </ div >
3546}
3647
3748export default function HeroSection ( { hero, snapshot } : HeroSectionProps ) {
3849 const desc = hero . minorInfo ?. trim ( ) || hero . description
39- const websiteRaw = hero . contact ?. website ?? null
40- let websiteDisplay = websiteRaw
41- if ( websiteRaw ) {
50+
51+ let websiteDisplay = hero . contact ?. website ?? null
52+ if ( websiteDisplay ) {
4253 try {
4354 websiteDisplay = new URL (
44- websiteRaw . startsWith ( 'http' ) ? websiteRaw : `https://${ websiteRaw } ` ,
55+ websiteDisplay . startsWith ( 'http' ) ? websiteDisplay : `https://${ websiteDisplay } ` ,
4556 ) . hostname
4657 } catch {
47- websiteDisplay = websiteRaw
58+ /* keep raw */
59+ }
60+ }
61+
62+ // Build blocks once — data is static after mount
63+ const blocksRef = useRef < Block [ ] | null > ( null )
64+ if ( ! blocksRef . current ) {
65+ const b : Block [ ] = [ ]
66+
67+ b . push ( {
68+ cmd : 'whoami' ,
69+ output : (
70+ < Output >
71+ < p id = "hero-title" className = "text-lg font-bold text-green-200" > { hero . title } </ p >
72+ { hero . eyebrow && < p className = "mt-0.5 text-xs text-green-700" > { hero . eyebrow } </ p > }
73+ </ Output >
74+ ) ,
75+ } )
76+
77+ if ( desc ) {
78+ b . push ( {
79+ cmd : 'cat README.md' ,
80+ output : (
81+ < Output >
82+ < p className = "max-w-xl leading-relaxed text-green-500" > { desc } </ p >
83+ </ Output >
84+ ) ,
85+ } )
86+ }
87+
88+ b . push ( {
89+ cmd : `cat ${ snapshot . title . toLowerCase ( ) . replace ( / \s + / g, '-' ) } .txt` ,
90+ output : (
91+ < Output >
92+ < div className = "flex flex-wrap gap-x-4 gap-y-1" >
93+ { snapshot . items . map ( ( item ) => (
94+ < span key = { item } >
95+ < span className = "text-green-800" > → </ span >
96+ < span className = "text-green-400" > { item } </ span >
97+ </ span >
98+ ) ) }
99+ </ div >
100+ </ Output >
101+ ) ,
102+ } )
103+
104+ const hasContact =
105+ hero . contact ?. email ||
106+ hero . contact ?. location ||
107+ hero . contact ?. company ||
108+ websiteDisplay
109+
110+ if ( hasContact ) {
111+ b . push ( {
112+ cmd : 'env | grep USER_INFO' ,
113+ output : (
114+ < Output >
115+ < div className = "space-y-0.5" >
116+ { hero . contact ?. company && (
117+ < p >
118+ < span className = "text-green-800" > COMPANY</ span >
119+ < span className = "text-green-700" > =</ span >
120+ < span className = "text-green-400" > { hero . contact . company } </ span >
121+ </ p >
122+ ) }
123+ { hero . contact ?. location && (
124+ < p >
125+ < span className = "text-green-800" > LOCATION</ span >
126+ < span className = "text-green-700" > =</ span >
127+ < span className = "text-green-400" > { hero . contact . location } </ span >
128+ </ p >
129+ ) }
130+ { websiteDisplay && (
131+ < p >
132+ < span className = "text-green-800" > WEBSITE</ span >
133+ < span className = "text-green-700" > =</ span >
134+ < span className = "text-green-400" > { websiteDisplay } </ span >
135+ </ p >
136+ ) }
137+ { hero . contact ?. email && (
138+ < p >
139+ < span className = "text-green-800" > EMAIL</ span >
140+ < span className = "text-green-700" > =</ span >
141+ < span className = "text-green-400" > { hero . contact . email } </ span >
142+ </ p >
143+ ) }
144+ </ div >
145+ </ Output >
146+ ) ,
147+ } )
48148 }
149+
150+ b . push ( {
151+ cmd : 'open --url github' ,
152+ output : (
153+ < Output >
154+ < a
155+ href = { hero . primaryCtaHref }
156+ target = "_blank"
157+ rel = "noreferrer"
158+ className = "text-green-300 underline underline-offset-2 transition hover:text-white"
159+ >
160+ { hero . primaryCtaHref }
161+ </ a >
162+ { hero . caption && (
163+ < span className = "ml-3 text-xs text-green-800" > # { hero . caption } </ span >
164+ ) }
165+ </ Output >
166+ ) ,
167+ } )
168+
169+ blocksRef . current = b
49170 }
171+ const blocks = blocksRef . current
172+
173+ // Animation state
174+ const [ doneCount , setDoneCount ] = useState ( 0 ) // fully completed blocks
175+ const [ typedChars , setTypedChars ] = useState ( 0 ) // chars typed for current block
176+ const [ showOutput , setShowOutput ] = useState ( false )
177+ const cancelRef = useRef ( false )
178+
179+ useEffect ( ( ) => {
180+ if ( doneCount >= blocks . length ) return
181+
182+ cancelRef . current = false
183+ setTypedChars ( 0 )
184+ setShowOutput ( false )
185+
186+ const cmd = blocks [ doneCount ] . cmd
187+ let i = 0
188+
189+ const tick = setInterval ( ( ) => {
190+ if ( cancelRef . current ) { clearInterval ( tick ) ; return }
191+ i ++
192+ setTypedChars ( i )
193+ if ( i >= cmd . length ) {
194+ clearInterval ( tick )
195+ setTimeout ( ( ) => {
196+ if ( cancelRef . current ) return
197+ setShowOutput ( true )
198+ setTimeout ( ( ) => {
199+ if ( cancelRef . current ) return
200+ setDoneCount ( ( c ) => c + 1 )
201+ } , NEXT_DELAY )
202+ } , OUTPUT_DELAY )
203+ }
204+ } , TYPING_SPEED )
205+
206+ return ( ) => {
207+ cancelRef . current = true
208+ clearInterval ( tick )
209+ }
210+ } , [ doneCount , blocks ] )
211+
212+ const allDone = doneCount >= blocks . length
50213
51214 return (
52215 < section id = "hero" className = "bg-[#030d03] py-10 font-mono" aria-labelledby = "hero-title" >
53216 < div className = "mx-auto max-w-4xl px-6" >
54- { /* Terminal chrome */ }
55217 < div className = "overflow-hidden rounded-none border border-green-900/70 shadow-[0_0_30px_rgba(0,255,65,0.04)]" >
56218 { /* Title bar */ }
57219 < div className = "flex items-center gap-2 border-b border-green-900/70 bg-black px-4 py-2" >
@@ -65,81 +227,37 @@ export default function HeroSection({ hero, snapshot }: HeroSectionProps) {
65227
66228 { /* Terminal body */ }
67229 < div className = "space-y-3 bg-black p-6 text-sm leading-relaxed" >
68- { /* Last login line */ }
69- < p className = "text-green-800 text-xs" >
230+ < p className = "text-xs text-green-800" >
70231 Last login: { new Date ( ) . toDateString ( ) } on ttys000
71232 </ p >
72233
73- < Prompt cmd = "whoami" />
74- < Output >
75- < p id = "hero-title" className = "text-lg font-bold text-green-200" > { hero . title } </ p >
76- { hero . eyebrow && < p className = "text-green-700 text-xs mt-0.5" > { hero . eyebrow } </ p > }
77- </ Output >
78-
79- { desc && (
80- < >
81- < Prompt cmd = "cat README.md" />
82- < Output >
83- < p className = "max-w-xl text-green-500 leading-relaxed" > { desc } </ p >
84- </ Output >
85- </ >
86- ) }
234+ { /* Completed blocks */ }
235+ { blocks . slice ( 0 , doneCount ) . map ( ( block , i ) => (
236+ < div key = { i } >
237+ < PromptLine cmd = { block . cmd } />
238+ { block . output }
239+ </ div >
240+ ) ) }
87241
88- < Prompt cmd = { `cat ${ snapshot . title . toLowerCase ( ) . replace ( / \s + / g, '-' ) } .txt` } />
89- < Output >
90- < div className = "flex flex-wrap gap-x-4 gap-y-1" >
91- { snapshot . items . map ( ( item ) => (
92- < span key = { item } >
93- < span className = "text-green-800" > → </ span >
94- < span className = "text-green-400" > { item } </ span >
95- </ span >
96- ) ) }
242+ { /* Currently typing block */ }
243+ { ! allDone && (
244+ < div >
245+ < PromptLine
246+ cmd = { blocks [ doneCount ] . cmd . slice ( 0 , typedChars ) }
247+ partial
248+ />
249+ { showOutput && blocks [ doneCount ] . output }
97250 </ div >
98- </ Output >
99-
100- { ( hero . contact ?. email || hero . contact ?. location || hero . contact ?. company || websiteDisplay ) && (
101- < >
102- < Prompt cmd = "env | grep USER_INFO" />
103- < Output >
104- < div className = "space-y-0.5" >
105- { hero . contact ?. company && (
106- < p > < span className = "text-green-800" > COMPANY</ span > < span className = "text-green-700" > =</ span > < span className = "text-green-400" > { hero . contact . company } </ span > </ p >
107- ) }
108- { hero . contact ?. location && (
109- < p > < span className = "text-green-800" > LOCATION</ span > < span className = "text-green-700" > =</ span > < span className = "text-green-400" > { hero . contact . location } </ span > </ p >
110- ) }
111- { websiteDisplay && (
112- < p > < span className = "text-green-800" > WEBSITE</ span > < span className = "text-green-700" > =</ span > < span className = "text-green-400" > { websiteDisplay } </ span > </ p >
113- ) }
114- { hero . contact ?. email && (
115- < p > < span className = "text-green-800" > EMAIL</ span > < span className = "text-green-700" > =</ span > < span className = "text-green-400" > { hero . contact . email } </ span > </ p >
116- ) }
117- </ div >
118- </ Output >
119- </ >
120251 ) }
121252
122- < Prompt cmd = "open --url github" />
123- < Output >
124- < a
125- href = { hero . primaryCtaHref }
126- target = "_blank"
127- rel = "noreferrer"
128- className = "text-green-300 underline underline-offset-2 transition hover:text-white"
129- >
130- { hero . primaryCtaHref }
131- </ a >
132- { hero . caption && (
133- < span className = "ml-3 text-green-800 text-xs" > # { hero . caption } </ span >
134- ) }
135- </ Output >
136-
137- { /* Blinking cursor */ }
138- < div className = "flex items-center gap-1 pt-1" >
139- < span className = "select-none text-green-700" > ~/profile </ span >
140- < span className = "select-none text-green-500" > ❯ </ span >
141- < span className = "inline-block h-4 w-2 animate-pulse bg-green-400" />
142- </ div >
253+ { /* Final idle cursor */ }
254+ { allDone && (
255+ < div className = "flex items-center gap-1 pt-1" >
256+ < span className = "select-none text-green-700" > ~/profile </ span >
257+ < span className = "select-none text-green-500" > ❯ </ span >
258+ < span className = "inline-block h-4 w-2 animate-pulse bg-green-400" />
259+ </ div >
260+ ) }
143261 </ div >
144262 </ div >
145263 </ div >
0 commit comments