create-foliokit 0.0.0-stage → 1.0.1

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