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.
- package/LICENSE +21 -0
- package/README.md +377 -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/.gitignore +20 -0
- package/templates/default/data.ts +227 -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.svg +10 -0
- package/templates/default/public/footer-heart.png +0 -0
- package/templates/default/public/love-always-wins.png +0 -0
- package/templates/default/public/project-placeholder.svg +10 -0
- package/templates/default/public/robots.txt +2 -0
- package/templates/default/public/stars-foreground-landscape.svg +202 -0
- package/templates/default/public/stars-foreground-portrait.svg +202 -0
- package/templates/default/public/stars-landscape.svg +322 -0
- package/templates/default/public/stars-portrait.svg +322 -0
- package/templates/default/scripts/generate-seo.mjs +34 -0
- package/templates/default/src/App.tsx +552 -0
- package/templates/default/src/BrandIcon.tsx +36 -0
- package/templates/default/src/ProjectMagazineGallery.css +90 -0
- package/templates/default/src/ProjectMagazineGallery.tsx +210 -0
- package/templates/default/src/main.tsx +10 -0
- package/templates/default/src/styles.css +536 -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/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 & 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">"{config.profile.quote}"</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
|