create-foliokit 1.0.0 → 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/README.md +59 -325
- package/package.json +1 -1
- package/templates/default/data.ts +58 -116
- 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/src/App.tsx +79 -49
- package/templates/default/src/ProjectMagazineGallery.tsx +13 -5
- package/templates/default/src/main.tsx +18 -0
- package/templates/default/src/styles.css +49 -84
- package/templates/default/tests/portfolio.test.js +2 -2
- package/templates/default/types.ts +73 -0
- package/templates/default/.gitignore +0 -20
- package/templates/default/public/avatar-placeholder.svg +0 -10
- 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 +0 -10
- package/templates/default/public/stars-foreground-landscape.svg +0 -202
- package/templates/default/public/stars-foreground-portrait.svg +0 -202
- package/templates/default/public/stars-landscape.svg +0 -322
- package/templates/default/public/stars-portrait.svg +0 -322
|
@@ -12,82 +12,14 @@
|
|
|
12
12
|
* - Social & Brand Icons: https://simpleicons.org
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
icon: string
|
|
18
|
-
category?: string
|
|
19
|
-
}
|
|
15
|
+
import type { PortfolioConfig } from './types.js'
|
|
16
|
+
export type { Skill, Project, ContentItem, Testimonial, SocialIcon, PortfolioConfig } from './types.js'
|
|
20
17
|
|
|
21
|
-
export
|
|
22
|
-
name: string
|
|
23
|
-
category?: string
|
|
24
|
-
description: string
|
|
25
|
-
image?: string
|
|
26
|
-
imageAlt?: string
|
|
27
|
-
technologies: Skill[]
|
|
28
|
-
liveUrl?: string
|
|
29
|
-
githubUrl?: string
|
|
30
|
-
featured?: boolean
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface ContentItem {
|
|
34
|
-
title: string
|
|
35
|
-
organization?: string
|
|
36
|
-
date?: string
|
|
37
|
-
description?: string
|
|
38
|
-
image?: string
|
|
39
|
-
links?: { label: string; url: string }[]
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface Testimonial {
|
|
43
|
-
name: string
|
|
44
|
-
role: string
|
|
45
|
-
company?: string
|
|
46
|
-
message: string
|
|
47
|
-
image?: string
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface SocialIcon {
|
|
51
|
-
label: string
|
|
52
|
-
icon: string
|
|
53
|
-
url?: string
|
|
54
|
-
color?: string
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface PortfolioConfig {
|
|
58
|
-
profile: {
|
|
59
|
-
name: string
|
|
60
|
-
headline: string
|
|
61
|
-
introduction: string
|
|
62
|
-
about: string
|
|
63
|
-
location?: string
|
|
64
|
-
timeZone?: string
|
|
65
|
-
timeZoneLabel?: string
|
|
66
|
-
profileImage?: string
|
|
67
|
-
resumeUrl?: string
|
|
68
|
-
quote?: string
|
|
69
|
-
}
|
|
70
|
-
seo: {
|
|
71
|
-
title: string
|
|
72
|
-
description: string
|
|
73
|
-
siteUrl?: string
|
|
74
|
-
image?: string
|
|
75
|
-
}
|
|
76
|
-
socialIcons: SocialIcon[]
|
|
77
|
-
skills: Skill[]
|
|
78
|
-
projects: Project[]
|
|
79
|
-
experience: ContentItem[]
|
|
80
|
-
education: ContentItem[]
|
|
81
|
-
testimonials: Testimonial[]
|
|
82
|
-
contact: {
|
|
83
|
-
email?: string
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export const portfolioConfig: PortfolioConfig = {
|
|
18
|
+
export const portfolioConfig: PortfolioConfig = {
|
|
88
19
|
// Personal Information
|
|
89
20
|
profile: {
|
|
90
21
|
name: 'Your Name',
|
|
22
|
+
profileImage: '/avatar-placeholder.png', // Replace with '/my-photo.jpg' after copying your photo into public/.
|
|
91
23
|
headline: 'Full Stack Developer',
|
|
92
24
|
introduction: "Hey, there! I'm a",
|
|
93
25
|
about: `Hey, there! I'm a developer who loves building products that people actually use. Write a couple of sentences here about who you are, what you do, and what you're currently working on.
|
|
@@ -98,16 +30,9 @@ Wrap up with what you're excited about right now — side projects, open source,
|
|
|
98
30
|
location: 'Delhi, India',
|
|
99
31
|
timeZone: 'Asia/Kolkata', // IANA time zone for the live clock
|
|
100
32
|
timeZoneLabel: 'IST',
|
|
101
|
-
profileImage: '/avatar-placeholder.svg', // Path relative to public/ directory
|
|
102
33
|
quote: 'Build with curiosity. Share what you learn.',
|
|
103
|
-
},
|
|
104
|
-
|
|
105
|
-
// SEO & Metadata
|
|
106
|
-
seo: {
|
|
107
|
-
title: '{{name}} — {{headline}}',
|
|
108
|
-
description: '{{name}} is a {{headline}} building useful, thoughtful web experiences.',
|
|
109
|
-
},
|
|
110
|
-
|
|
34
|
+
},
|
|
35
|
+
|
|
111
36
|
// Social Links
|
|
112
37
|
// Add, edit, remove, or reorder your social media profiles.
|
|
113
38
|
// Find brand icons at: https://simpleicons.org (built-in icons: 'mail', 'file-text')
|
|
@@ -116,8 +41,40 @@ Wrap up with what you're excited about right now — side projects, open source,
|
|
|
116
41
|
{ label: 'LinkedIn', icon: 'linkedin', url: 'https://linkedin.com/in/your-username', color: '#0a66c2' },
|
|
117
42
|
{ label: 'Instagram', icon: 'instagram', url: 'https://instagram.com/your-username', color: '#e4405f' },
|
|
118
43
|
{ label: 'X', icon: 'x', url: 'https://x.com/your-username', color: '#f2f0e9' },
|
|
119
|
-
],
|
|
120
|
-
|
|
44
|
+
],
|
|
45
|
+
|
|
46
|
+
// Work Experience
|
|
47
|
+
// Add, edit, remove, or reorder work history. Leave empty ([]) to hide this section.
|
|
48
|
+
// Optional logo: copy an image to public/ and set image: '/company-logo.png'. Omit image to show the Logo placeholder.
|
|
49
|
+
experience: [
|
|
50
|
+
{
|
|
51
|
+
title: 'Your Role',
|
|
52
|
+
organization: 'Company Name',
|
|
53
|
+
organizationUrl: 'https://example.com', // Placeholder: replace with the company website URL.
|
|
54
|
+
image: '', // Set to '/company-logo.png' after copying the image into public/.
|
|
55
|
+
imageAlt: 'Company Name logo',
|
|
56
|
+
imagePlaceholder: '/experience-logo-placeholder.png',
|
|
57
|
+
location: 'City, Country', // Replace with the role's location, or leave '' to hide it.
|
|
58
|
+
date: 'Month YYYY – Present',
|
|
59
|
+
description: 'Replace this sample with your role, company, and dates.',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
|
|
63
|
+
// Education
|
|
64
|
+
// Add, edit, remove, or reorder education. Leave empty ([]) to hide this section.
|
|
65
|
+
education: [
|
|
66
|
+
{
|
|
67
|
+
title: 'Degree or Program',
|
|
68
|
+
organization: 'Your University',
|
|
69
|
+
organizationUrl: 'https://example.com', // Placeholder: replace with the university website URL.
|
|
70
|
+
image: '', // Set to '/school-logo.png' after copying the image into public/.
|
|
71
|
+
imageAlt: 'University logo',
|
|
72
|
+
location: 'City, Country', // Replace with the school's location, or leave '' to hide it.
|
|
73
|
+
date: 'YYYY – YYYY',
|
|
74
|
+
description: 'Add your major, score, or another education detail here.',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
|
|
121
78
|
// Skills
|
|
122
79
|
// Add, edit, remove, or reorder skills.
|
|
123
80
|
// Find skill icon identifiers at: https://skillicons.dev
|
|
@@ -145,8 +102,8 @@ Wrap up with what you're excited about right now — side projects, open source,
|
|
|
145
102
|
{ name: 'GitHub', icon: 'github', category: 'Tools' },
|
|
146
103
|
{ name: 'npm', icon: 'npm', category: 'Tools' },
|
|
147
104
|
{ name: 'GitHub Actions', icon: 'githubactions', category: 'Tools' },
|
|
148
|
-
],
|
|
149
|
-
|
|
105
|
+
],
|
|
106
|
+
|
|
150
107
|
// Projects
|
|
151
108
|
// Add, edit, remove, or reorder projects. Set featured: true to highlight in the 3D gallery.
|
|
152
109
|
// Technology icon identifiers also come from: https://skillicons.dev
|
|
@@ -155,7 +112,7 @@ Wrap up with what you're excited about right now — side projects, open source,
|
|
|
155
112
|
name: 'Project One',
|
|
156
113
|
category: 'PROJECT CATEGORY',
|
|
157
114
|
description: 'Add a short description of this project, the problem it solves, and what you built.',
|
|
158
|
-
image: '/project-placeholder.
|
|
115
|
+
image: '/project-placeholder.png', // Replace with '/my-project.png' after copying it into public/.
|
|
159
116
|
imageAlt: 'Project image placeholder, 1200 by 675 pixels',
|
|
160
117
|
technologies: [{ name: 'React', icon: 'react' }, { name: 'TypeScript', icon: 'ts' }],
|
|
161
118
|
featured: true, // Highlights project in the 3D magazine gallery
|
|
@@ -164,7 +121,7 @@ Wrap up with what you're excited about right now — side projects, open source,
|
|
|
164
121
|
name: 'Project Two',
|
|
165
122
|
category: 'PROJECT CATEGORY',
|
|
166
123
|
description: 'Describe another project here. Replace the title, image, and technology stack in data.ts.',
|
|
167
|
-
image: '/project-placeholder.
|
|
124
|
+
image: '/project-placeholder.png', // Replace with '/my-project.png' after copying it into public/.
|
|
168
125
|
imageAlt: 'Project image placeholder, 1200 by 675 pixels',
|
|
169
126
|
technologies: [{ name: 'Node.js', icon: 'nodejs' }, { name: 'Express.js', icon: 'express' }, { name: 'MongoDB', icon: 'mongodb' }],
|
|
170
127
|
},
|
|
@@ -172,34 +129,12 @@ Wrap up with what you're excited about right now — side projects, open source,
|
|
|
172
129
|
name: 'Project Three',
|
|
173
130
|
category: 'PROJECT CATEGORY',
|
|
174
131
|
description: 'Use this space to explain what you made and the tools or technologies you used.',
|
|
175
|
-
image: '/project-placeholder.
|
|
132
|
+
image: '/project-placeholder.png', // Replace with '/my-project.png' after copying it into public/.
|
|
176
133
|
imageAlt: 'Project image placeholder, 1200 by 675 pixels',
|
|
177
134
|
technologies: [{ name: 'Python', icon: 'python' }, { name: 'C', icon: 'c' }, { name: 'Java', icon: 'java' }],
|
|
178
135
|
},
|
|
179
|
-
],
|
|
180
|
-
|
|
181
|
-
// Work Experience
|
|
182
|
-
// Add, edit, remove, or reorder work history. Leave empty ([]) to hide this section.
|
|
183
|
-
experience: [
|
|
184
|
-
{
|
|
185
|
-
title: 'Your Role',
|
|
186
|
-
organization: 'Company Name',
|
|
187
|
-
date: 'Month YYYY – Present',
|
|
188
|
-
description: 'Replace this sample with your role, company, and dates.',
|
|
189
|
-
},
|
|
190
|
-
],
|
|
191
|
-
|
|
192
|
-
// Education
|
|
193
|
-
// Add, edit, remove, or reorder education. Leave empty ([]) to hide this section.
|
|
194
|
-
education: [
|
|
195
|
-
{
|
|
196
|
-
title: 'Degree or Program',
|
|
197
|
-
organization: 'Your University',
|
|
198
|
-
date: 'YYYY – YYYY',
|
|
199
|
-
description: 'Add your major, score, or another education detail here.',
|
|
200
|
-
},
|
|
201
|
-
],
|
|
202
|
-
|
|
136
|
+
],
|
|
137
|
+
|
|
203
138
|
// Testimonials
|
|
204
139
|
// Add, edit, remove, or reorder testimonials. Leave empty ([]) to hide this section.
|
|
205
140
|
testimonials: [
|
|
@@ -218,10 +153,17 @@ Wrap up with what you're excited about right now — side projects, open source,
|
|
|
218
153
|
role: 'Role or designation',
|
|
219
154
|
message: 'Replace this placeholder with a genuine testimonial before publishing.',
|
|
220
155
|
},
|
|
221
|
-
],
|
|
222
|
-
|
|
156
|
+
],
|
|
157
|
+
|
|
223
158
|
// Contact
|
|
224
159
|
contact: {
|
|
225
160
|
email: 'hello@example.com',
|
|
226
|
-
},
|
|
227
|
-
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
// SEO & Metadata
|
|
164
|
+
seo: {
|
|
165
|
+
title: '{{name}} — {{headline}}',
|
|
166
|
+
description: '{{name}} is a {{headline}} building useful, thoughtful web experiences.',
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -3,7 +3,24 @@ import { type CSSProperties, type FormEvent, useEffect, useState } from 'react'
|
|
|
3
3
|
import ProjectMagazineGallery from './ProjectMagazineGallery'
|
|
4
4
|
import BrandIcon from './BrandIcon'
|
|
5
5
|
import { projectCardId } from './utils'
|
|
6
|
-
import { portfolioConfig as config
|
|
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
|
+
}
|
|
7
24
|
|
|
8
25
|
function safeExternalUrl(value?: string) {
|
|
9
26
|
if (!value?.trim()) return undefined
|
|
@@ -35,11 +52,11 @@ function BioSocialLinks() {
|
|
|
35
52
|
const href = safeExternalUrl(social.url)
|
|
36
53
|
return href ? [{ ...social, href }] : []
|
|
37
54
|
}),
|
|
38
|
-
...(safeResumeHref(config.profile.resumeUrl) ? [{ label:
|
|
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' }] : []),
|
|
39
56
|
]
|
|
40
57
|
if (links.length === 0) return null
|
|
41
58
|
return (
|
|
42
|
-
<div className="about-social-icons social-links" aria-label=
|
|
59
|
+
<div className="about-social-icons social-links" aria-label={ui.accessibility.socialLinks}>
|
|
43
60
|
{links.map((link) => (
|
|
44
61
|
<a
|
|
45
62
|
className="social-link"
|
|
@@ -65,8 +82,8 @@ function Contact() {
|
|
|
65
82
|
const url = safeExternalUrl(social.url)
|
|
66
83
|
return url ? [{ ...social, url }] : []
|
|
67
84
|
}),
|
|
68
|
-
...(config.contact?.email ? [{ label:
|
|
69
|
-
...(safeResumeHref(config.profile.resumeUrl) ? [{ label:
|
|
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' }] : []),
|
|
70
87
|
]
|
|
71
88
|
function openEmailDraft(event: FormEvent<HTMLFormElement>) {
|
|
72
89
|
event.preventDefault()
|
|
@@ -74,7 +91,7 @@ function Contact() {
|
|
|
74
91
|
const name = String(values.get('name') ?? '').trim()
|
|
75
92
|
const email = String(values.get('email') ?? '').trim()
|
|
76
93
|
const message = String(values.get('message') ?? '').trim()
|
|
77
|
-
const subject = encodeURIComponent(
|
|
94
|
+
const subject = encodeURIComponent(ui.contact.emailSubject.replace('{name}', name))
|
|
78
95
|
const body = encodeURIComponent(`${message}\n\nFrom: ${name}\nEmail: ${email}`)
|
|
79
96
|
if (config.contact?.email) window.location.href = `mailto:${config.contact.email}?subject=${subject}&body=${body}`
|
|
80
97
|
}
|
|
@@ -83,36 +100,36 @@ function Contact() {
|
|
|
83
100
|
<section className="contact-section section-wrap" id="contact" aria-labelledby="contact-title">
|
|
84
101
|
<div className="contact-panel dotted-frame">
|
|
85
102
|
<header className="contact-heading">
|
|
86
|
-
<h2 id="contact-title">
|
|
87
|
-
<p>
|
|
103
|
+
<h2 id="contact-title">{ui.contact.title}<span>.</span></h2>
|
|
104
|
+
<p>{ui.contact.intro}</p>
|
|
88
105
|
</header>
|
|
89
106
|
<div className="contact-content">
|
|
90
107
|
{config.contact?.email && <form className="contact-form" onSubmit={openEmailDraft}>
|
|
91
108
|
<div className="contact-subheading">
|
|
92
|
-
<h3>
|
|
93
|
-
<p>
|
|
109
|
+
<h3>{ui.contact.formTitle}</h3>
|
|
110
|
+
<p>{ui.contact.formHelp}</p>
|
|
94
111
|
</div>
|
|
95
112
|
<label className="contact-field">
|
|
96
113
|
<UserRound size={16} aria-hidden="true" />
|
|
97
|
-
<span className="sr-only">
|
|
98
|
-
<input name="name" type="text" placeholder=
|
|
114
|
+
<span className="sr-only">{ui.contact.name}</span>
|
|
115
|
+
<input name="name" type="text" placeholder={ui.contact.name} autoComplete="name" required />
|
|
99
116
|
</label>
|
|
100
117
|
<label className="contact-field">
|
|
101
118
|
<Mail size={16} aria-hidden="true" />
|
|
102
|
-
<span className="sr-only">
|
|
103
|
-
<input name="email" type="email" placeholder=
|
|
119
|
+
<span className="sr-only">{ui.contact.email}</span>
|
|
120
|
+
<input name="email" type="email" placeholder={ui.contact.email} autoComplete="email" required />
|
|
104
121
|
</label>
|
|
105
122
|
<label className="contact-field contact-message-field">
|
|
106
123
|
<MessageCircle size={16} aria-hidden="true" />
|
|
107
|
-
<span className="sr-only">
|
|
108
|
-
<textarea name="message" placeholder=
|
|
124
|
+
<span className="sr-only">{ui.contact.message}</span>
|
|
125
|
+
<textarea name="message" placeholder={ui.contact.messagePlaceholder} rows={3} required />
|
|
109
126
|
</label>
|
|
110
|
-
<button className="contact-submit" type="submit"><span className="text-interaction">
|
|
127
|
+
<button className="contact-submit" type="submit"><span className="text-interaction">{ui.contact.send}</span><ArrowRight size={16} aria-hidden="true" /></button>
|
|
111
128
|
</form>}
|
|
112
129
|
{(contactLinks.length > 0 || config.socialIcons.some((icon) => safeExternalUrl(icon.url))) && <aside className="contact-social-panel" aria-labelledby="contact-social-title">
|
|
113
130
|
<div className="contact-subheading">
|
|
114
|
-
<h3 id="contact-social-title">
|
|
115
|
-
<p>
|
|
131
|
+
<h3 id="contact-social-title">{ui.contact.socialTitle}</h3>
|
|
132
|
+
<p>{ui.contact.socialHelp}</p>
|
|
116
133
|
</div>
|
|
117
134
|
<div className="contact-social-links">
|
|
118
135
|
{contactLinks.map((link) => {
|
|
@@ -130,7 +147,7 @@ function Contact() {
|
|
|
130
147
|
>
|
|
131
148
|
<SocialBrandIcon name={link.label} icon={link.icon} />
|
|
132
149
|
{(downloadName || link.icon === 'mail') && (
|
|
133
|
-
<span className="text-interaction">{downloadName ?
|
|
150
|
+
<span className="text-interaction">{downloadName ? ui.contact.resumeLink : ui.contact.emailLink}</span>
|
|
134
151
|
)}
|
|
135
152
|
</a>
|
|
136
153
|
})}
|
|
@@ -163,7 +180,7 @@ function LocationAndClock() {
|
|
|
163
180
|
|
|
164
181
|
if (!config.profile.location && !config.profile.timeZone) return null
|
|
165
182
|
return (
|
|
166
|
-
<div className="profile-metadata" aria-label=
|
|
183
|
+
<div className="profile-metadata" aria-label={ui.accessibility.locationAndTime}>
|
|
167
184
|
{config.profile.location && <span className="profile-metadata-item">
|
|
168
185
|
<MapPin size={13} aria-hidden="true" />
|
|
169
186
|
<span>{config.profile.location}</span>
|
|
@@ -181,9 +198,9 @@ function Navigation() {
|
|
|
181
198
|
const [activeSection, setActiveSection] = useState('home')
|
|
182
199
|
|
|
183
200
|
const navigation = [
|
|
184
|
-
...(config.skills?.length ? [{ label:
|
|
185
|
-
...(config.projects?.length ? [{ label:
|
|
186
|
-
...(config.contact?.email || config.socialIcons?.length ? [{ label:
|
|
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' }] : []),
|
|
187
204
|
]
|
|
188
205
|
|
|
189
206
|
useEffect(() => {
|
|
@@ -220,14 +237,14 @@ function Navigation() {
|
|
|
220
237
|
<button
|
|
221
238
|
className="menu-toggle"
|
|
222
239
|
type="button"
|
|
223
|
-
aria-label={menuOpen ?
|
|
240
|
+
aria-label={menuOpen ? ui.navigation.closeMenu : ui.navigation.openMenu}
|
|
224
241
|
aria-expanded={menuOpen}
|
|
225
242
|
aria-controls="primary-navigation"
|
|
226
243
|
onClick={() => setMenuOpen((open) => !open)}
|
|
227
244
|
>
|
|
228
245
|
{menuOpen ? <X size={20} /> : <Menu size={20} />}
|
|
229
246
|
</button>
|
|
230
|
-
<nav id="primary-navigation" className={menuOpen ? 'main-nav is-open' : 'main-nav'} aria-label=
|
|
247
|
+
<nav id="primary-navigation" className={menuOpen ? 'main-nav is-open' : 'main-nav'} aria-label={ui.navigation.ariaLabel}>
|
|
231
248
|
{navigation.map((item) => (
|
|
232
249
|
<a
|
|
233
250
|
className={`nav-link text-interaction${item.href.slice(1) === activeSection ? ' is-active' : ''}`}
|
|
@@ -253,7 +270,7 @@ function Navigation() {
|
|
|
253
270
|
function ProfileBio({ embedded = false }: { embedded?: boolean }) {
|
|
254
271
|
if (!config.profile.about?.trim()) return null
|
|
255
272
|
return (
|
|
256
|
-
<section className={embedded ? 'profile-bio profile-bio-embedded' : 'profile-bio section-wrap'} id="about" aria-label=
|
|
273
|
+
<section className={embedded ? 'profile-bio profile-bio-embedded' : 'profile-bio section-wrap'} id="about" aria-label={ui.sections.about}>
|
|
257
274
|
<div className="profile-bio-content">
|
|
258
275
|
<div className="profile-bio-copy">
|
|
259
276
|
{config.profile.about.split(/\n\s*\n/).filter(Boolean).map((paragraph, index) => <p key={index}>{paragraph}</p>)}
|
|
@@ -280,7 +297,7 @@ function Hero({ includeAbout }: { includeAbout: boolean }) {
|
|
|
280
297
|
onError={() => setProfileImageUnavailable(true)}
|
|
281
298
|
/>
|
|
282
299
|
) : (
|
|
283
|
-
<div className="profile-photo profile-photo-placeholder" role="img" aria-label={
|
|
300
|
+
<div className="profile-photo profile-photo-placeholder" role="img" aria-label={ui.accessibility.profilePlaceholder.replace('{name}', config.profile.name)}>
|
|
284
301
|
{config.profile.name.split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase()}
|
|
285
302
|
</div>
|
|
286
303
|
)}
|
|
@@ -288,13 +305,15 @@ function Hero({ includeAbout }: { includeAbout: boolean }) {
|
|
|
288
305
|
<div className="profile-copy">
|
|
289
306
|
<h1 className="profile-heading" id="hero-title">
|
|
290
307
|
<span>{config.profile.name}</span>
|
|
291
|
-
<span className="
|
|
292
|
-
<
|
|
293
|
-
|
|
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>
|
|
294
313
|
</span>
|
|
295
314
|
</h1>
|
|
296
315
|
<p className="profile-intro">
|
|
297
|
-
<span className="waving-hand" role="img" aria-label=
|
|
316
|
+
<span className="waving-hand" role="img" aria-label={ui.accessibility.wavingHand}>👋</span>
|
|
298
317
|
<span>{config.profile.introduction} </span>
|
|
299
318
|
<span className="profile-role">{config.profile.headline}</span>
|
|
300
319
|
</p>
|
|
@@ -313,8 +332,8 @@ function Skills() {
|
|
|
313
332
|
return (
|
|
314
333
|
<section className="skills section-wrap" id="skills" aria-labelledby="skills-title">
|
|
315
334
|
<div className="skills-panel dotted-frame">
|
|
316
|
-
<h2 id="skills-title">
|
|
317
|
-
<div className="skills-grid" aria-label=
|
|
335
|
+
<h2 id="skills-title">{ui.sections.skills}<span>.</span></h2>
|
|
336
|
+
<div className="skills-grid" aria-label={ui.accessibility.skills}>
|
|
318
337
|
{config.skills.map((skill) => (
|
|
319
338
|
<div
|
|
320
339
|
className="skill-tile"
|
|
@@ -349,7 +368,7 @@ function Projects() {
|
|
|
349
368
|
return (
|
|
350
369
|
<section className="projects section-wrap" id="projects" aria-labelledby="projects-title">
|
|
351
370
|
<div className="projects-intro">
|
|
352
|
-
<h2 id="projects-title">
|
|
371
|
+
<h2 id="projects-title">{ui.sections.projects}<span className="projects-period">.</span></h2>
|
|
353
372
|
</div>
|
|
354
373
|
<ProjectMagazineGallery projects={config.projects} />
|
|
355
374
|
<div className="project-grid">
|
|
@@ -374,14 +393,14 @@ function Projects() {
|
|
|
374
393
|
<h3 className="text-interaction">{project.name}</h3>
|
|
375
394
|
<div className="project-actions">
|
|
376
395
|
{safeExternalUrl(project.liveUrl) && (
|
|
377
|
-
<a href={safeExternalUrl(project.liveUrl)} target="_blank" rel="noopener noreferrer" aria-label={
|
|
378
|
-
<ExternalLink size={13} aria-hidden="true" /><span>
|
|
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>
|
|
379
398
|
</a>
|
|
380
399
|
)}
|
|
381
400
|
{safeExternalUrl(project.githubUrl) && (
|
|
382
|
-
<a href={safeExternalUrl(project.githubUrl)} target="_blank" rel="noopener noreferrer" aria-label={
|
|
401
|
+
<a href={safeExternalUrl(project.githubUrl)} target="_blank" rel="noopener noreferrer" aria-label={`${ui.projects.githubTooltip}: ${project.name}`} data-tooltip={ui.projects.githubTooltip}>
|
|
383
402
|
<BrandIcon name="GitHub" className="brand-icon" src="https://api.iconify.design/simple-icons/github.svg?color=%23f5f5f5" width={14} height={14} />
|
|
384
|
-
<span>
|
|
403
|
+
<span>{ui.projects.github}</span>
|
|
385
404
|
</a>
|
|
386
405
|
)}
|
|
387
406
|
</div>
|
|
@@ -389,7 +408,7 @@ function Projects() {
|
|
|
389
408
|
<p>{project.description}</p>
|
|
390
409
|
{project.technologies.length > 0 && (
|
|
391
410
|
<div className="project-stack">
|
|
392
|
-
<h4>
|
|
411
|
+
<h4>{ui.projects.technologies}</h4>
|
|
393
412
|
<div className="project-tech-list" aria-label={`${project.name} technologies`}>
|
|
394
413
|
{project.technologies.map((technology) => (
|
|
395
414
|
<span className="project-tech-pill" key={technology.name}>{technology.name}</span>
|
|
@@ -470,6 +489,14 @@ function HoverTooltipLayer() {
|
|
|
470
489
|
)
|
|
471
490
|
}
|
|
472
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
|
+
|
|
473
500
|
function ContentSection({ id, title, description, items }: { id: string; title: string; description?: string; items: ContentItem[] }) {
|
|
474
501
|
if (!items || items.length === 0) return null
|
|
475
502
|
return (
|
|
@@ -482,14 +509,17 @@ function ContentSection({ id, title, description, items }: { id: string; title:
|
|
|
482
509
|
<div className={id === 'experience' || id === 'education' ? 'portfolio-history-list' : 'portfolio-content-grid'}>
|
|
483
510
|
{items.map((item) => (
|
|
484
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 ?? ''}`}>
|
|
485
|
-
{id === 'experience' &&
|
|
486
|
-
{id === 'education' && item.image && <img className="portfolio-history-logo" src={item.image} alt=
|
|
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 }} />}
|
|
487
514
|
<div className="portfolio-history-main">
|
|
488
515
|
<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>}
|
|
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>}
|
|
490
517
|
{item.description && <p className="portfolio-history-description">{item.description}</p>}
|
|
491
518
|
</div>
|
|
492
|
-
{item.date && <
|
|
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>}
|
|
493
523
|
{id !== 'experience' && id !== 'education' && item.image && <img src={item.image} alt={item.title} loading="lazy" onError={(event) => { event.currentTarget.hidden = true }} />}
|
|
494
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>)}
|
|
495
525
|
</article>
|
|
@@ -505,7 +535,7 @@ function Testimonials() {
|
|
|
505
535
|
return (
|
|
506
536
|
<section className="portfolio-content-section section-wrap" id="testimonials" aria-labelledby="testimonials-title">
|
|
507
537
|
<div className="portfolio-content-panel dotted-frame">
|
|
508
|
-
<header><h2 id="testimonials-title">
|
|
538
|
+
<header><h2 id="testimonials-title">{ui.sections.testimonials}<span>.</span></h2></header>
|
|
509
539
|
<div className="portfolio-content-grid testimonial-grid">
|
|
510
540
|
{config.testimonials.map((testimonial) => (
|
|
511
541
|
<article className="portfolio-content-card testimonial-card" key={`${testimonial.name}-${testimonial.role}`}>
|
|
@@ -532,16 +562,16 @@ function App() {
|
|
|
532
562
|
<Navigation />
|
|
533
563
|
<main>
|
|
534
564
|
<Hero includeAbout={true} />
|
|
535
|
-
{config.experience?.length > 0 && <ContentSection id="experience" title=
|
|
536
|
-
{config.education?.length > 0 && <ContentSection id="education" title=
|
|
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} />}
|
|
537
567
|
{config.skills?.length > 0 && <Skills />}
|
|
538
568
|
{config.projects?.length > 0 && <Projects />}
|
|
539
569
|
{config.testimonials?.length > 0 && <Testimonials />}
|
|
540
570
|
{(config.contact?.email || config.socialIcons?.length > 0) && <Contact />}
|
|
541
571
|
</main>
|
|
542
572
|
<footer className="site-footer">
|
|
543
|
-
<p className="footer-copyright">© {new Date().getFullYear()} {config.profile.name}.
|
|
544
|
-
<p className="footer-credit">
|
|
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>
|
|
545
575
|
{config.profile.quote && <p className="footer-quote">"{config.profile.quote}"</p>}
|
|
546
576
|
</footer>
|
|
547
577
|
<HoverTooltipLayer />
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent, type KeyboardEvent, type WheelEvent } from 'react'
|
|
2
2
|
import { projectCardId } from './utils'
|
|
3
|
-
import type { Project } from '../
|
|
3
|
+
import type { Project } from '../types.js'
|
|
4
4
|
import './ProjectMagazineGallery.css'
|
|
5
5
|
|
|
6
6
|
type Props = { projects: Project[] }
|
|
7
7
|
|
|
8
|
+
// Gallery-specific labels stay beside the gallery component.
|
|
9
|
+
const galleryUi = {
|
|
10
|
+
carouselLabel: 'Featured project carousel',
|
|
11
|
+
galleryHelp: 'Project gallery. Swipe, drag, or use a horizontal trackpad gesture to browse. Use the left and right arrow keys while focused.',
|
|
12
|
+
projectDetails: 'Open {project} project details',
|
|
13
|
+
viewProject: 'VIEW PROJECT',
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
function getOffset(index: number, activeIndex: number, count: number) {
|
|
9
17
|
let offset = index - activeIndex
|
|
10
18
|
if (offset > count / 2) offset -= count
|
|
@@ -117,13 +125,13 @@ function ProjectMagazineGallery({ projects }: Props) {
|
|
|
117
125
|
const viewportStyle = { '--drag-x': `${dragX}px` } as CSSProperties
|
|
118
126
|
|
|
119
127
|
return (
|
|
120
|
-
<section className="project-magazine-gallery" aria-label=
|
|
128
|
+
<section className="project-magazine-gallery" aria-label={galleryUi.carouselLabel}>
|
|
121
129
|
<div
|
|
122
130
|
className={`magazine-viewport${isDragging ? ' is-dragging' : ''}`}
|
|
123
131
|
style={viewportStyle}
|
|
124
132
|
role="region"
|
|
125
133
|
aria-roledescription="project carousel"
|
|
126
|
-
aria-label=
|
|
134
|
+
aria-label={galleryUi.galleryHelp}
|
|
127
135
|
tabIndex={0}
|
|
128
136
|
onKeyDown={handleKeyDown}
|
|
129
137
|
onWheel={handleWheel}
|
|
@@ -171,7 +179,7 @@ function ProjectMagazineGallery({ projects }: Props) {
|
|
|
171
179
|
data-featured={project.featured || undefined}
|
|
172
180
|
type="button"
|
|
173
181
|
style={cardStyle}
|
|
174
|
-
aria-label={
|
|
182
|
+
aria-label={galleryUi.projectDetails.replace('{project}', project.name)}
|
|
175
183
|
aria-hidden={distance > 1}
|
|
176
184
|
tabIndex={distance > 1 ? -1 : 0}
|
|
177
185
|
onPointerEnter={(event) => { if (event.pointerType === 'mouse') setHoverIndex(index) }}
|
|
@@ -196,7 +204,7 @@ function ProjectMagazineGallery({ projects }: Props) {
|
|
|
196
204
|
</span>
|
|
197
205
|
<span className="magazine-cover-title">{project.name}</span>
|
|
198
206
|
<span className="magazine-cover-description">{project.description}</span>
|
|
199
|
-
<span className="magazine-cover-hint">
|
|
207
|
+
<span className="magazine-cover-hint">{galleryUi.viewProject} <span aria-hidden="true">↗</span></span>
|
|
200
208
|
</span>
|
|
201
209
|
</button>
|
|
202
210
|
)
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import ReactDOM from 'react-dom/client'
|
|
3
3
|
import App from './App'
|
|
4
|
+
import { portfolioConfig } from '../data'
|
|
4
5
|
import './styles.css'
|
|
5
6
|
|
|
7
|
+
|
|
8
|
+
const replaceProfileTokens = (value: string) => value
|
|
9
|
+
.replaceAll('{{name}}', portfolioConfig.profile.name)
|
|
10
|
+
.replaceAll('{{headline}}', portfolioConfig.profile.headline)
|
|
11
|
+
const pageTitle = replaceProfileTokens(portfolioConfig.seo.title)
|
|
12
|
+
const pageDescription = replaceProfileTokens(portfolioConfig.seo.description)
|
|
13
|
+
document.title = pageTitle
|
|
14
|
+
document.querySelector('meta[name="description"]')?.setAttribute('content', pageDescription)
|
|
15
|
+
document.querySelector('meta[property="og:title"]')?.setAttribute('content', pageTitle)
|
|
16
|
+
document.querySelector('meta[property="og:description"]')?.setAttribute('content', pageDescription)
|
|
17
|
+
document.querySelector('meta[name="twitter:title"]')?.setAttribute('content', pageTitle)
|
|
18
|
+
document.querySelector('meta[name="twitter:description"]')?.setAttribute('content', pageDescription)
|
|
19
|
+
if (portfolioConfig.seo.image) {
|
|
20
|
+
document.querySelector('meta[property="og:image"]')?.setAttribute('content', portfolioConfig.seo.image)
|
|
21
|
+
document.querySelector('meta[name="twitter:image"]')?.setAttribute('content', portfolioConfig.seo.image)
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
7
25
|
<React.StrictMode>
|
|
8
26
|
<App />
|