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.
@@ -12,82 +12,14 @@
12
12
  * - Social & Brand Icons: https://simpleicons.org
13
13
  */
14
14
 
15
- export interface Skill {
16
- name: string
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 interface Project {
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.svg',
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.svg',
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.svg',
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
+
@@ -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, type ContentItem } from '../data'
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: 'Resume', icon: 'file-text', href: safeResumeHref(config.profile.resumeUrl)!, color: '#d7ff62', downloadName: config.profile.resumeUrl!.split('/').pop() || 'Resume.pdf' }] : []),
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="Social profiles and resume">
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: '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' }] : []),
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(`Portfolio message from ${name}`)
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">Let’s Connect<span>.</span></h2>
87
- <p>Have a project in mind? Send a message or find me here.</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>Send a message.</h3>
93
- <p>Your email app will open with the message ready to send.</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">Your name</span>
98
- <input name="name" type="text" placeholder="Your name" autoComplete="name" required />
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">Your email</span>
103
- <input name="email" type="email" placeholder="Your email address" autoComplete="email" required />
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">Your message</span>
108
- <textarea name="message" placeholder="What would you like to discuss?" rows={3} required />
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">Send message</span><ArrowRight size={16} aria-hidden="true" /></button>
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">Follow &amp; connect.</h3>
115
- <p>Find me across these platforms.</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 ? 'Resume' : 'Email'}</span>
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="Location and local time">
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: 'Skills', href: '#skills' }] : []),
185
- ...(config.projects?.length ? [{ label: 'Projects', href: '#projects' }] : []),
186
- ...(config.contact?.email || config.socialIcons?.length ? [{ label: 'Contact', href: '#contact' }] : []),
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 ? 'Close navigation' : 'Open navigation'}
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="Main navigation">
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="About">
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={`${config.profile.name} profile placeholder`}>
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="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>}
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="Waving hand">👋</span>
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">Technical Skills<span>.</span></h2>
317
- <div className="skills-grid" aria-label="Skills">
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">Featured Projects<span className="projects-period">.</span></h2>
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={`Open ${project.name} live demo`} data-tooltip="Live demo">
378
- <ExternalLink size={13} aria-hidden="true" /><span>Live</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={`Open ${project.name} GitHub repository`} data-tooltip="GitHub repository">
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>GitHub</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>Technologies Used:</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' && (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 }} />}
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 && <p className="portfolio-content-date portfolio-history-date">{item.date}</p>}
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">Testimonials<span>.</span></h2></header>
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="Work Experience" items={config.experience} />}
536
- {config.education?.length > 0 && <ContentSection id="education" title="Education" items={config.education} />}
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}. 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>
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">&quot;{config.profile.quote}&quot;</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 '../data'
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="Featured project carousel">
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="Project magazine gallery. Swipe, drag, or use a horizontal trackpad gesture to browse. Use the left and right arrow keys while focused."
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={`Open ${project.name} project details`}
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">VIEW PROJECT <span aria-hidden="true">↗</span></span>
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 />