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.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/bin/index.js +6 -0
- package/package.json +50 -4
- package/src/cli.js +111 -0
- package/src/generator.js +133 -0
- package/src/prompts.js +44 -0
- package/src/utils/errors.js +7 -0
- package/src/utils/paths.js +12 -0
- package/src/utils/progress.js +5 -0
- package/src/utils/project-name.js +30 -0
- package/templates/default/data.ts +169 -0
- package/templates/default/index.html +23 -0
- package/templates/default/package-lock.json +1280 -0
- package/templates/default/package.json +28 -0
- package/templates/default/public/avatar-placeholder.png +0 -0
- package/templates/default/public/experience-logo-placeholder.png +0 -0
- package/templates/default/public/project-placeholder.png +0 -0
- package/templates/default/public/robots.txt +2 -0
- package/templates/default/scripts/generate-seo.mjs +34 -0
- package/templates/default/src/App.tsx +582 -0
- package/templates/default/src/BrandIcon.tsx +36 -0
- package/templates/default/src/ProjectMagazineGallery.css +90 -0
- package/templates/default/src/ProjectMagazineGallery.tsx +218 -0
- package/templates/default/src/main.tsx +28 -0
- package/templates/default/src/styles.css +501 -0
- package/templates/default/src/utils.ts +2 -0
- package/templates/default/src/vite-env.d.ts +1 -0
- package/templates/default/tests/portfolio.test.js +96 -0
- package/templates/default/tsconfig.app.json +21 -0
- package/templates/default/tsconfig.json +7 -0
- package/templates/default/tsconfig.node.json +16 -0
- package/templates/default/types.ts +73 -0
- 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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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">"{config.profile.quote}"</p>}
|
|
576
|
+
</footer>
|
|
577
|
+
<HoverTooltipLayer />
|
|
578
|
+
</>
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export default App
|