create-foliokit 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +377 -2
  3. package/bin/index.js +6 -0
  4. package/package.json +50 -4
  5. package/src/cli.js +111 -0
  6. package/src/generator.js +133 -0
  7. package/src/prompts.js +44 -0
  8. package/src/utils/errors.js +7 -0
  9. package/src/utils/paths.js +12 -0
  10. package/src/utils/progress.js +5 -0
  11. package/src/utils/project-name.js +30 -0
  12. package/templates/default/.gitignore +20 -0
  13. package/templates/default/data.ts +227 -0
  14. package/templates/default/index.html +23 -0
  15. package/templates/default/package-lock.json +1280 -0
  16. package/templates/default/package.json +28 -0
  17. package/templates/default/public/avatar-placeholder.svg +10 -0
  18. package/templates/default/public/footer-heart.png +0 -0
  19. package/templates/default/public/love-always-wins.png +0 -0
  20. package/templates/default/public/project-placeholder.svg +10 -0
  21. package/templates/default/public/robots.txt +2 -0
  22. package/templates/default/public/stars-foreground-landscape.svg +202 -0
  23. package/templates/default/public/stars-foreground-portrait.svg +202 -0
  24. package/templates/default/public/stars-landscape.svg +322 -0
  25. package/templates/default/public/stars-portrait.svg +322 -0
  26. package/templates/default/scripts/generate-seo.mjs +34 -0
  27. package/templates/default/src/App.tsx +552 -0
  28. package/templates/default/src/BrandIcon.tsx +36 -0
  29. package/templates/default/src/ProjectMagazineGallery.css +90 -0
  30. package/templates/default/src/ProjectMagazineGallery.tsx +210 -0
  31. package/templates/default/src/main.tsx +10 -0
  32. package/templates/default/src/styles.css +536 -0
  33. package/templates/default/src/utils.ts +2 -0
  34. package/templates/default/src/vite-env.d.ts +1 -0
  35. package/templates/default/tests/portfolio.test.js +96 -0
  36. package/templates/default/tsconfig.app.json +21 -0
  37. package/templates/default/tsconfig.json +7 -0
  38. package/templates/default/tsconfig.node.json +16 -0
  39. package/templates/default/vite.config.ts +38 -0
@@ -0,0 +1,552 @@
1
+ import { ArrowRight, Clock3, ExternalLink, FileText, Mail, MapPin, Menu, MessageCircle, UserRound, X } from 'lucide-react'
2
+ import { type CSSProperties, type FormEvent, useEffect, useState } from 'react'
3
+ import ProjectMagazineGallery from './ProjectMagazineGallery'
4
+ import BrandIcon from './BrandIcon'
5
+ import { projectCardId } from './utils'
6
+ import { portfolioConfig as config, type ContentItem } from '../data'
7
+
8
+ function safeExternalUrl(value?: string) {
9
+ if (!value?.trim()) return undefined
10
+ try {
11
+ const url = new URL(value)
12
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined
13
+ } catch {
14
+ return undefined
15
+ }
16
+ }
17
+
18
+ function safeResumeHref(value?: string) {
19
+ const path = value?.trim()
20
+ if (!path) return undefined
21
+ const externalUrl = safeExternalUrl(path)
22
+ if (externalUrl) return externalUrl
23
+ return path.startsWith('/') && !path.startsWith('//') ? path : undefined
24
+ }
25
+
26
+ function SocialBrandIcon({ name, icon, className = 'brand-icon' }: { name: string; icon?: string; className?: string }) {
27
+ if (icon === 'file-text') return <FileText className={className} size={19} aria-hidden="true" />
28
+ if (icon === 'mail') return <Mail className={className} size={19} aria-hidden="true" />
29
+ return <BrandIcon name={name} className={className} src={`https://api.iconify.design/simple-icons/${icon ?? 'link'}.svg?color=%23f5f5f5`} width={19} height={19} />
30
+ }
31
+
32
+ function BioSocialLinks() {
33
+ const links = [
34
+ ...config.socialIcons.flatMap((social) => {
35
+ const href = safeExternalUrl(social.url)
36
+ return href ? [{ ...social, href }] : []
37
+ }),
38
+ ...(safeResumeHref(config.profile.resumeUrl) ? [{ label: 'Resume', icon: 'file-text', href: safeResumeHref(config.profile.resumeUrl)!, color: '#d7ff62', downloadName: config.profile.resumeUrl!.split('/').pop() || 'Resume.pdf' }] : []),
39
+ ]
40
+ if (links.length === 0) return null
41
+ return (
42
+ <div className="about-social-icons social-links" aria-label="Social profiles and resume">
43
+ {links.map((link) => (
44
+ <a
45
+ className="social-link"
46
+ href={link.href}
47
+ key={link.label}
48
+ style={{ '--social-color': link.color } as CSSProperties}
49
+ aria-label={link.label}
50
+ data-tooltip={link.label}
51
+ target={link.href.startsWith('http') ? '_blank' : undefined}
52
+ rel={link.href.startsWith('http') ? 'noopener noreferrer' : undefined}
53
+ download={'downloadName' in link ? link.downloadName : undefined}
54
+ >
55
+ <SocialBrandIcon name={link.label} icon={link.icon} className="social-icon brand-icon" />
56
+ </a>
57
+ ))}
58
+ </div>
59
+ )
60
+ }
61
+
62
+ function Contact() {
63
+ const contactLinks = [
64
+ ...config.socialIcons.flatMap((social) => {
65
+ const url = safeExternalUrl(social.url)
66
+ return url ? [{ ...social, url }] : []
67
+ }),
68
+ ...(config.contact?.email ? [{ label: 'Email', icon: 'mail', url: `mailto:${config.contact.email}`, color: '#a4a5a8' }] : []),
69
+ ...(safeResumeHref(config.profile.resumeUrl) ? [{ label: 'Resume (PDF)', icon: 'file-text', url: safeResumeHref(config.profile.resumeUrl)!, color: '#d7ff62', downloadName: config.profile.resumeUrl!.split('/').pop() || 'Resume.pdf' }] : []),
70
+ ]
71
+ function openEmailDraft(event: FormEvent<HTMLFormElement>) {
72
+ event.preventDefault()
73
+ const values = new FormData(event.currentTarget)
74
+ const name = String(values.get('name') ?? '').trim()
75
+ const email = String(values.get('email') ?? '').trim()
76
+ const message = String(values.get('message') ?? '').trim()
77
+ const subject = encodeURIComponent(`Portfolio message from ${name}`)
78
+ const body = encodeURIComponent(`${message}\n\nFrom: ${name}\nEmail: ${email}`)
79
+ if (config.contact?.email) window.location.href = `mailto:${config.contact.email}?subject=${subject}&body=${body}`
80
+ }
81
+
82
+ return (
83
+ <section className="contact-section section-wrap" id="contact" aria-labelledby="contact-title">
84
+ <div className="contact-panel dotted-frame">
85
+ <header className="contact-heading">
86
+ <h2 id="contact-title">Let’s Connect<span>.</span></h2>
87
+ <p>Have a project in mind? Send a message or find me here.</p>
88
+ </header>
89
+ <div className="contact-content">
90
+ {config.contact?.email && <form className="contact-form" onSubmit={openEmailDraft}>
91
+ <div className="contact-subheading">
92
+ <h3>Send a message.</h3>
93
+ <p>Your email app will open with the message ready to send.</p>
94
+ </div>
95
+ <label className="contact-field">
96
+ <UserRound size={16} aria-hidden="true" />
97
+ <span className="sr-only">Your name</span>
98
+ <input name="name" type="text" placeholder="Your name" autoComplete="name" required />
99
+ </label>
100
+ <label className="contact-field">
101
+ <Mail size={16} aria-hidden="true" />
102
+ <span className="sr-only">Your email</span>
103
+ <input name="email" type="email" placeholder="Your email address" autoComplete="email" required />
104
+ </label>
105
+ <label className="contact-field contact-message-field">
106
+ <MessageCircle size={16} aria-hidden="true" />
107
+ <span className="sr-only">Your message</span>
108
+ <textarea name="message" placeholder="What would you like to discuss?" rows={3} required />
109
+ </label>
110
+ <button className="contact-submit" type="submit"><span className="text-interaction">Send message</span><ArrowRight size={16} aria-hidden="true" /></button>
111
+ </form>}
112
+ {(contactLinks.length > 0 || config.socialIcons.some((icon) => safeExternalUrl(icon.url))) && <aside className="contact-social-panel" aria-labelledby="contact-social-title">
113
+ <div className="contact-subheading">
114
+ <h3 id="contact-social-title">Follow &amp; connect.</h3>
115
+ <p>Find me across these platforms.</p>
116
+ </div>
117
+ <div className="contact-social-links">
118
+ {contactLinks.map((link) => {
119
+ const downloadName = 'downloadName' in link ? link.downloadName : undefined
120
+ return <a
121
+ className="contact-social-link"
122
+ href={link.url}
123
+ key={link.label}
124
+ style={{ '--social-color': link.color } as CSSProperties}
125
+ aria-label={link.label}
126
+ data-tooltip={link.label}
127
+ target={link.icon === 'mail' ? undefined : '_blank'}
128
+ rel={link.icon === 'mail' ? undefined : 'noopener noreferrer'}
129
+ download={downloadName}
130
+ >
131
+ <SocialBrandIcon name={link.label} icon={link.icon} />
132
+ {(downloadName || link.icon === 'mail') && (
133
+ <span className="text-interaction">{downloadName ? 'Resume' : 'Email'}</span>
134
+ )}
135
+ </a>
136
+ })}
137
+ </div>
138
+ </aside>}
139
+ </div>
140
+ </div>
141
+ </section>
142
+ )
143
+ }
144
+
145
+ const timeFormatter = new Intl.DateTimeFormat('en-US', {
146
+ timeZone: config.profile.timeZone || 'UTC',
147
+ hour: '2-digit',
148
+ minute: '2-digit',
149
+ second: '2-digit',
150
+ hour12: true,
151
+ })
152
+
153
+ function LocationAndClock() {
154
+ const [time, setTime] = useState(() => timeFormatter.format(new Date()))
155
+
156
+ useEffect(() => {
157
+ const intervalId = window.setInterval(() => {
158
+ setTime(timeFormatter.format(new Date()))
159
+ }, 1_000)
160
+
161
+ return () => window.clearInterval(intervalId)
162
+ }, [])
163
+
164
+ if (!config.profile.location && !config.profile.timeZone) return null
165
+ return (
166
+ <div className="profile-metadata" aria-label="Location and local time">
167
+ {config.profile.location && <span className="profile-metadata-item">
168
+ <MapPin size={13} aria-hidden="true" />
169
+ <span>{config.profile.location}</span>
170
+ </span>}
171
+ {config.profile.timeZone && <span className="profile-metadata-item profile-time-item">
172
+ <Clock3 size={13} aria-hidden="true" />
173
+ <time>{time} {config.profile.timeZoneLabel ?? config.profile.timeZone}</time>
174
+ </span>}
175
+ </div>
176
+ )
177
+ }
178
+
179
+ function Navigation() {
180
+ const [menuOpen, setMenuOpen] = useState(false)
181
+ const [activeSection, setActiveSection] = useState('home')
182
+
183
+ const navigation = [
184
+ ...(config.skills?.length ? [{ label: 'Skills', href: '#skills' }] : []),
185
+ ...(config.projects?.length ? [{ label: 'Projects', href: '#projects' }] : []),
186
+ ...(config.contact?.email || config.socialIcons?.length ? [{ label: 'Contact', href: '#contact' }] : []),
187
+ ]
188
+
189
+ useEffect(() => {
190
+ const desktopViewport = window.matchMedia('(min-width: 601px)')
191
+ const closeMenuOnDesktop = () => {
192
+ if (desktopViewport.matches) setMenuOpen(false)
193
+ }
194
+ desktopViewport.addEventListener('change', closeMenuOnDesktop)
195
+ return () => desktopViewport.removeEventListener('change', closeMenuOnDesktop)
196
+ }, [])
197
+
198
+ useEffect(() => {
199
+ const sections = ['home', 'skills', 'projects', 'experience', 'education', 'testimonials', 'contact']
200
+ .map((id) => document.getElementById(id))
201
+ .filter((section): section is HTMLElement => section !== null)
202
+ if (!('IntersectionObserver' in window)) return
203
+
204
+ const observer = new IntersectionObserver((entries) => {
205
+ const visibleSections = entries
206
+ .filter((entry) => entry.isIntersecting)
207
+ .sort((first, second) => second.intersectionRatio - first.intersectionRatio)
208
+ if (visibleSections[0]) setActiveSection(visibleSections[0].target.id)
209
+ }, { rootMargin: '-20% 0px -65% 0px', threshold: [0, 0.2, 0.5] })
210
+
211
+ sections.forEach((section) => observer.observe(section))
212
+ return () => observer.disconnect()
213
+ }, [])
214
+
215
+ return (
216
+ <header className="site-header" onKeyDown={(event) => { if (event.key === 'Escape') setMenuOpen(false) }}>
217
+ <a className="wordmark" href="#home" aria-label={`${config.profile.name}, home`} onClick={() => { setActiveSection('home'); setMenuOpen(false) }}>
218
+ {config.profile.name}
219
+ </a>
220
+ <button
221
+ className="menu-toggle"
222
+ type="button"
223
+ aria-label={menuOpen ? 'Close navigation' : 'Open navigation'}
224
+ aria-expanded={menuOpen}
225
+ aria-controls="primary-navigation"
226
+ onClick={() => setMenuOpen((open) => !open)}
227
+ >
228
+ {menuOpen ? <X size={20} /> : <Menu size={20} />}
229
+ </button>
230
+ <nav id="primary-navigation" className={menuOpen ? 'main-nav is-open' : 'main-nav'} aria-label="Main navigation">
231
+ {navigation.map((item) => (
232
+ <a
233
+ className={`nav-link text-interaction${item.href.slice(1) === activeSection ? ' is-active' : ''}`}
234
+ key={item.label}
235
+ href={item.href}
236
+ aria-current={item.href.slice(1) === activeSection ? 'location' : undefined}
237
+ onClick={() => {
238
+ setActiveSection(item.href.slice(1))
239
+ setMenuOpen(false)
240
+ }}
241
+ >
242
+ <span className="nav-label-swap" data-label={item.label}>
243
+ <span className="nav-label-primary">{item.label}</span>
244
+ <span className="nav-label-copy" aria-hidden="true">{item.label}</span>
245
+ </span>
246
+ </a>
247
+ ))}
248
+ </nav>
249
+ </header>
250
+ )
251
+ }
252
+
253
+ function ProfileBio({ embedded = false }: { embedded?: boolean }) {
254
+ if (!config.profile.about?.trim()) return null
255
+ return (
256
+ <section className={embedded ? 'profile-bio profile-bio-embedded' : 'profile-bio section-wrap'} id="about" aria-label="About">
257
+ <div className="profile-bio-content">
258
+ <div className="profile-bio-copy">
259
+ {config.profile.about.split(/\n\s*\n/).filter(Boolean).map((paragraph, index) => <p key={index}>{paragraph}</p>)}
260
+ </div>
261
+ <BioSocialLinks />
262
+ </div>
263
+ </section>
264
+ )
265
+ }
266
+
267
+ function Hero({ includeAbout }: { includeAbout: boolean }) {
268
+ const [profileImageUnavailable, setProfileImageUnavailable] = useState(false)
269
+
270
+ return (
271
+ <section className="hero section-wrap" id="home" aria-labelledby="hero-title">
272
+ <div className="profile-header">
273
+ <div className="profile-identity">
274
+ <div className="profile-photo-stage">
275
+ {config.profile.profileImage && !profileImageUnavailable ? (
276
+ <img
277
+ className="profile-photo"
278
+ src={config.profile.profileImage}
279
+ alt={`${config.profile.name} profile`}
280
+ onError={() => setProfileImageUnavailable(true)}
281
+ />
282
+ ) : (
283
+ <div className="profile-photo profile-photo-placeholder" role="img" aria-label={`${config.profile.name} profile placeholder`}>
284
+ {config.profile.name.split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase()}
285
+ </div>
286
+ )}
287
+ </div>
288
+ <div className="profile-copy">
289
+ <h1 className="profile-heading" id="hero-title">
290
+ <span>{config.profile.name}</span>
291
+ <span className="name-heart-wrap">
292
+ <img className="name-heart-icon" src="/love-always-wins.png" alt="Heart" width="20" height="20" />
293
+ {config.profile.quote && <span className="heart-message" role="tooltip">{config.profile.quote}</span>}
294
+ </span>
295
+ </h1>
296
+ <p className="profile-intro">
297
+ <span className="waving-hand" role="img" aria-label="Waving hand">👋</span>
298
+ <span>{config.profile.introduction} </span>
299
+ <span className="profile-role">{config.profile.headline}</span>
300
+ </p>
301
+ </div>
302
+ </div>
303
+ <LocationAndClock />
304
+ </div>
305
+ <div className="profile-divider" aria-hidden="true" />
306
+ {includeAbout && <ProfileBio embedded />}
307
+ </section>
308
+ )
309
+ }
310
+
311
+ function Skills() {
312
+ if (!config.skills || config.skills.length === 0) return null
313
+ return (
314
+ <section className="skills section-wrap" id="skills" aria-labelledby="skills-title">
315
+ <div className="skills-panel dotted-frame">
316
+ <h2 id="skills-title">Technical Skills<span>.</span></h2>
317
+ <div className="skills-grid" aria-label="Skills">
318
+ {config.skills.map((skill) => (
319
+ <div
320
+ className="skill-tile"
321
+ key={skill.name}
322
+ role="img"
323
+ aria-label={skill.category ? `${skill.name}, ${skill.category}` : skill.name}
324
+ data-category={skill.category}
325
+ >
326
+ <img
327
+ className="skill-logo"
328
+ src={`https://skillicons.dev/icons?i=${skill.icon}&theme=dark`}
329
+ alt=""
330
+ loading="lazy"
331
+ onError={(event) => {
332
+ event.currentTarget.hidden = true
333
+ const fallback = event.currentTarget.nextElementSibling
334
+ if (fallback instanceof HTMLElement) fallback.hidden = false
335
+ }}
336
+ />
337
+ <span className="skill-logo-fallback" hidden aria-hidden="true">{skill.icon.slice(0, 2).toUpperCase()}</span>
338
+ <span className="skill-name" aria-hidden="true">{skill.name}</span>
339
+ </div>
340
+ ))}
341
+ </div>
342
+ </div>
343
+ </section>
344
+ )
345
+ }
346
+
347
+ function Projects() {
348
+ if (!config.projects || config.projects.length === 0) return null
349
+ return (
350
+ <section className="projects section-wrap" id="projects" aria-labelledby="projects-title">
351
+ <div className="projects-intro">
352
+ <h2 id="projects-title">Featured Projects<span className="projects-period">.</span></h2>
353
+ </div>
354
+ <ProjectMagazineGallery projects={config.projects} />
355
+ <div className="project-grid">
356
+ {config.projects.map((project) => (
357
+ <article className={`project-card${project.featured ? ' is-featured' : ''}`} id={projectCardId(project.name)} key={project.name} data-featured={project.featured || undefined}>
358
+ {project.image ? (
359
+ <>
360
+ <img
361
+ className="project-thumbnail"
362
+ src={project.image}
363
+ alt={project.imageAlt ?? `${project.name} project thumbnail`}
364
+ loading="lazy"
365
+ onError={(event) => { event.currentTarget.hidden = true; const fallback = event.currentTarget.nextElementSibling; if (fallback instanceof HTMLElement) fallback.hidden = false }}
366
+ />
367
+ <div className="project-thumbnail project-thumbnail-placeholder" aria-hidden="true" hidden>{project.name.slice(0, 2).toUpperCase()}</div>
368
+ </>
369
+ ) : (
370
+ <div className="project-thumbnail project-thumbnail-placeholder" aria-hidden="true">{project.name.slice(0, 2).toUpperCase()}</div>
371
+ )}
372
+ <div className="project-info">
373
+ <div className="project-heading-row">
374
+ <h3 className="text-interaction">{project.name}</h3>
375
+ <div className="project-actions">
376
+ {safeExternalUrl(project.liveUrl) && (
377
+ <a href={safeExternalUrl(project.liveUrl)} target="_blank" rel="noopener noreferrer" aria-label={`Open ${project.name} live demo`} data-tooltip="Live demo">
378
+ <ExternalLink size={13} aria-hidden="true" /><span>Live</span>
379
+ </a>
380
+ )}
381
+ {safeExternalUrl(project.githubUrl) && (
382
+ <a href={safeExternalUrl(project.githubUrl)} target="_blank" rel="noopener noreferrer" aria-label={`Open ${project.name} GitHub repository`} data-tooltip="GitHub repository">
383
+ <BrandIcon name="GitHub" className="brand-icon" src="https://api.iconify.design/simple-icons/github.svg?color=%23f5f5f5" width={14} height={14} />
384
+ <span>GitHub</span>
385
+ </a>
386
+ )}
387
+ </div>
388
+ </div>
389
+ <p>{project.description}</p>
390
+ {project.technologies.length > 0 && (
391
+ <div className="project-stack">
392
+ <h4>Technologies Used:</h4>
393
+ <div className="project-tech-list" aria-label={`${project.name} technologies`}>
394
+ {project.technologies.map((technology) => (
395
+ <span className="project-tech-pill" key={technology.name}>{technology.name}</span>
396
+ ))}
397
+ </div>
398
+ </div>
399
+ )}
400
+ </div>
401
+ </article>
402
+ ))}
403
+ </div>
404
+ </section>
405
+ )
406
+ }
407
+
408
+ function HoverTooltipLayer() {
409
+ const [tooltip, setTooltip] = useState<{ text: string; left: number; top: number; below: boolean } | null>(null)
410
+
411
+ useEffect(() => {
412
+ function clearTooltip() {
413
+ setTooltip(null)
414
+ }
415
+
416
+ function showTooltip(event: Event) {
417
+ if (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(hover: none)').matches) return
418
+ const trigger = (event.target as Element | null)?.closest<HTMLElement>('[data-tooltip]')
419
+ const text = trigger?.dataset.tooltip
420
+ if (!trigger || !text) return
421
+
422
+ const rect = trigger.getBoundingClientRect()
423
+ const below = rect.top < 44
424
+ setTooltip({
425
+ text,
426
+ left: Math.max(112, Math.min(window.innerWidth - 112, rect.left + rect.width / 2)),
427
+ top: below ? rect.bottom + 8 : rect.top - 7,
428
+ below,
429
+ })
430
+ }
431
+
432
+ function hideTooltip(event: Event) {
433
+ const related = (event as PointerEvent | FocusEvent).relatedTarget
434
+ const target = event.target as Element | null
435
+ if (related instanceof Node && target?.closest('[data-tooltip]')?.contains(related)) return
436
+ setTooltip(null)
437
+ }
438
+
439
+ function hideOnEscape(event: KeyboardEvent) {
440
+ if (event.key === 'Escape') setTooltip(null)
441
+ }
442
+
443
+ document.addEventListener('pointerover', showTooltip)
444
+ document.addEventListener('pointerout', hideTooltip)
445
+ document.addEventListener('focusin', showTooltip)
446
+ document.addEventListener('focusout', hideTooltip)
447
+ document.addEventListener('keydown', hideOnEscape)
448
+ window.addEventListener('resize', clearTooltip)
449
+ window.addEventListener('scroll', clearTooltip, true)
450
+ return () => {
451
+ document.removeEventListener('pointerover', showTooltip)
452
+ document.removeEventListener('pointerout', hideTooltip)
453
+ document.removeEventListener('focusin', showTooltip)
454
+ document.removeEventListener('focusout', hideTooltip)
455
+ document.removeEventListener('keydown', hideOnEscape)
456
+ window.removeEventListener('resize', clearTooltip)
457
+ window.removeEventListener('scroll', clearTooltip, true)
458
+ }
459
+ }, [])
460
+
461
+ if (!tooltip) return null
462
+ return (
463
+ <div
464
+ className={`site-tooltip${tooltip.below ? ' is-below' : ''}`}
465
+ role="tooltip"
466
+ style={{ left: tooltip.left, top: tooltip.top }}
467
+ >
468
+ {tooltip.text}
469
+ </div>
470
+ )
471
+ }
472
+
473
+ function ContentSection({ id, title, description, items }: { id: string; title: string; description?: string; items: ContentItem[] }) {
474
+ if (!items || items.length === 0) return null
475
+ return (
476
+ <section className="portfolio-content-section section-wrap" id={id} aria-labelledby={`${id}-title`}>
477
+ <div className={`portfolio-content-panel dotted-frame${id === 'experience' || id === 'education' ? ' portfolio-history-panel' : ''}`}>
478
+ <header>
479
+ <h2 id={`${id}-title`}>{title}<span>.</span></h2>
480
+ {description && <p>{description}</p>}
481
+ </header>
482
+ <div className={id === 'experience' || id === 'education' ? 'portfolio-history-list' : 'portfolio-content-grid'}>
483
+ {items.map((item) => (
484
+ <article className={id === 'experience' || id === 'education' ? `portfolio-history-row${id === 'education' ? ' is-education-row' : ''}${item.links?.some((link) => link.url.trim()) ? ' is-linked' : ''}` : 'portfolio-content-card'} key={`${item.title}-${item.organization ?? ''}`}>
485
+ {id === 'experience' && (item.image ? <img className="portfolio-history-logo" src={item.image} alt="" loading="lazy" onError={(event) => { event.currentTarget.hidden = true }} /> : <span className="portfolio-history-logo portfolio-history-logo-placeholder" aria-hidden="true">Logo</span>)}
486
+ {id === 'education' && item.image && <img className="portfolio-history-logo" src={item.image} alt="" loading="lazy" onError={(event) => { event.currentTarget.hidden = true }} />}
487
+ <div className="portfolio-history-main">
488
+ <h3 className={id === 'experience' || id === 'education' ? 'text-interaction' : undefined}>{item.title}</h3>
489
+ {item.organization && <p className="portfolio-content-organization">{item.organization}{item.links?.filter((link) => link.url.trim()).map((link) => <a className="portfolio-history-link" key={link.url} href={link.url} target="_blank" rel="noopener noreferrer" aria-label={`${link.label}: ${item.organization}`}><ExternalLink size={14} aria-hidden="true" /></a>)}</p>}
490
+ {item.description && <p className="portfolio-history-description">{item.description}</p>}
491
+ </div>
492
+ {item.date && <p className="portfolio-content-date portfolio-history-date">{item.date}</p>}
493
+ {id !== 'experience' && id !== 'education' && item.image && <img src={item.image} alt={item.title} loading="lazy" onError={(event) => { event.currentTarget.hidden = true }} />}
494
+ {id !== 'experience' && id !== 'education' && item.links?.filter((link) => link.url.trim()).map((link) => <a key={link.url} href={link.url} target="_blank" rel="noopener noreferrer">{link.label}</a>)}
495
+ </article>
496
+ ))}
497
+ </div>
498
+ </div>
499
+ </section>
500
+ )
501
+ }
502
+
503
+ function Testimonials() {
504
+ if (!config.testimonials || config.testimonials.length === 0) return null
505
+ return (
506
+ <section className="portfolio-content-section section-wrap" id="testimonials" aria-labelledby="testimonials-title">
507
+ <div className="portfolio-content-panel dotted-frame">
508
+ <header><h2 id="testimonials-title">Testimonials<span>.</span></h2></header>
509
+ <div className="portfolio-content-grid testimonial-grid">
510
+ {config.testimonials.map((testimonial) => (
511
+ <article className="portfolio-content-card testimonial-card" key={`${testimonial.name}-${testimonial.role}`}>
512
+ {testimonial.image && (
513
+ <>
514
+ <img src={testimonial.image} alt={`${testimonial.name} profile`} loading="lazy" onError={(event) => { event.currentTarget.hidden = true; const fallback = event.currentTarget.nextElementSibling; if (fallback instanceof HTMLElement) fallback.hidden = false }} />
515
+ <span className="testimonial-avatar-fallback" aria-hidden="true" hidden>{testimonial.name.split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase()}</span>
516
+ </>
517
+ )}
518
+ <blockquote>{testimonial.message}</blockquote>
519
+ <h3>{testimonial.name}</h3>
520
+ <p className="portfolio-content-organization">{testimonial.role}{testimonial.company ? ` · ${testimonial.company}` : ''}</p>
521
+ </article>
522
+ ))}
523
+ </div>
524
+ </div>
525
+ </section>
526
+ )
527
+ }
528
+
529
+ function App() {
530
+ return (
531
+ <>
532
+ <Navigation />
533
+ <main>
534
+ <Hero includeAbout={true} />
535
+ {config.experience?.length > 0 && <ContentSection id="experience" title="Work Experience" items={config.experience} />}
536
+ {config.education?.length > 0 && <ContentSection id="education" title="Education" items={config.education} />}
537
+ {config.skills?.length > 0 && <Skills />}
538
+ {config.projects?.length > 0 && <Projects />}
539
+ {config.testimonials?.length > 0 && <Testimonials />}
540
+ {(config.contact?.email || config.socialIcons?.length > 0) && <Contact />}
541
+ </main>
542
+ <footer className="site-footer">
543
+ <p className="footer-copyright">© {new Date().getFullYear()} {config.profile.name}. All rights reserved.</p>
544
+ <p className="footer-credit">Made with <img className="footer-heart" src="/footer-heart.png" alt="" aria-hidden="true" width="14" height="14" /> by {config.profile.name}</p>
545
+ {config.profile.quote && <p className="footer-quote">&quot;{config.profile.quote}&quot;</p>}
546
+ </footer>
547
+ <HoverTooltipLayer />
548
+ </>
549
+ )
550
+ }
551
+
552
+ export default App
@@ -0,0 +1,36 @@
1
+ import { useState } from 'react'
2
+
3
+ type Props = {
4
+ name: string
5
+ src: string
6
+ width: number
7
+ height: number
8
+ className?: string
9
+ loading?: 'eager' | 'lazy'
10
+ }
11
+
12
+ function shortLabel(name: string) {
13
+ const labels: Record<string, string> = {
14
+ GitHub: 'GH',
15
+ }
16
+ if (labels[name]) return labels[name]
17
+ return name.split(/[^a-z0-9]+/i).filter(Boolean).map((part) => part[0]).join('').slice(0, 2).toUpperCase()
18
+ }
19
+
20
+ function BrandIcon({ name, src, width, height, className, loading = 'lazy' }: Props) {
21
+ const [unavailable, setUnavailable] = useState(false)
22
+ if (unavailable) {
23
+ return (
24
+ <span
25
+ className={`brand-icon-fallback${className ? ` ${className}` : ''}`}
26
+ style={{ width, height, flexBasis: width }}
27
+ aria-hidden="true"
28
+ >
29
+ {shortLabel(name)}
30
+ </span>
31
+ )
32
+ }
33
+ return <img className={className} src={src} alt="" width={width} height={height} loading={loading} onError={() => setUnavailable(true)} />
34
+ }
35
+
36
+ export default BrandIcon