Add i18n support with NL/EN language switching (ST-001)

- Add English translations to cv-data.ts with getCvData(lang) helper
- Create app/[lang]/ routing with static generation for nl and en
- Add language switcher in nav (NL / EN toggle)
- Add middleware for Accept-Language auto-redirect on root path
- Root layout reads x-lang header to set <html lang> correctly
- All components accept lang prop and render translated content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-29 15:31:13 +02:00
parent 61e603b5d7
commit d352a7d496
13 changed files with 540 additions and 177 deletions

View file

@ -1,27 +1,11 @@
import Image from "next/image";
import { FadeIn } from "./fade-in";
import { getCvData, type Lang } from "@/lib/cv-data";
const APPS = [
{
title: "Inspannings Monitor",
subtitle: "Wellness-first dagflow",
description:
"Een lichte app die helpt doseren en inzicht geeft zonder lange formulieren of overprikkeling.",
screenshot: "/images/app-inspannings-monitor.png",
screenshotMobile: "/images/app-inspannings-monitor-mobile.png",
href: "https://inspannings-monitor.jp-visser.nl/dashboard",
},
{
title: "Scrum4Me",
subtitle: "DevPlanner voor kleine teams",
description:
"Een desktop-first fullstack webapplicatie voor solo developers en kleine Scrum Teams die meerdere softwareprojecten parallel beheren. De app organiseert werk hierarchisch (product -> PBI -> story -> taak), biedt gesplitste planningsschermen met drag-and-drop, en integreert met Claude Code via een REST API.",
screenshot: "/images/app-scrum4me.svg",
href: "https://scrum4me.jp-visser.nl",
},
];
export function AppsSection({ lang }: { lang: Lang }) {
const data = getCvData(lang);
const { label, heading, subtext } = data.ui.apps;
export function AppsSection() {
return (
<section
id="apps"
@ -33,7 +17,7 @@ export function AppsSection() {
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Portfolio
{label}
</p>
<h2
className="font-serif font-normal mb-4"
@ -43,18 +27,18 @@ export function AppsSection() {
letterSpacing: -1,
}}
>
Apps & Projecten
{heading}
</h2>
<p
className="text-[15px] leading-[1.7] mb-12 max-w-[500px]"
style={{ color: "rgba(255,255,255,0.45)" }}
>
Hier komen links naar mijn apps die ik op Vercel host.
{subtext}
</p>
</FadeIn>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{APPS.map((app, i) => (
{data.apps.map((app, i) => (
<FadeIn key={i} delay={i * 0.1}>
<a
href={app.href}
@ -91,7 +75,7 @@ export function AppsSection() {
>
<Image
src={app.screenshotMobile}
alt={`${app.title} mobiel`}
alt={`${app.title} mobile`}
fill
className="object-cover object-top"
sizes="72px"
@ -124,7 +108,6 @@ export function AppsSection() {
</a>
</FadeIn>
))}
</div>
</section>
);

View file

@ -1,25 +1,16 @@
import { FadeIn } from "./fade-in";
import { CV_DATA } from "@/lib/cv-data";
import { getCvData, type Lang } from "@/lib/cv-data";
const CONTACT_ITEMS = [
{
label: "E-mail",
value: CV_DATA.contact.email,
href: `mailto:${CV_DATA.contact.email}`,
},
{
label: "Locatie",
value: CV_DATA.contact.location,
href: null,
},
{
label: "Website",
value: "jp-visser.nl",
href: "https://jp-visser.nl",
},
];
export function ContactSection({ lang }: { lang: Lang }) {
const data = getCvData(lang);
const { label, heading, emailLabel, locationLabel, websiteLabel } = data.ui.contact;
const CONTACT_ITEMS = [
{ label: emailLabel, value: data.contact.email, href: `mailto:${data.contact.email}` },
{ label: locationLabel, value: data.contact.location, href: null },
{ label: websiteLabel, value: "jp-visser.nl", href: "https://jp-visser.nl" },
];
export function ContactSection() {
return (
<section
id="contact"
@ -31,7 +22,7 @@ export function ContactSection() {
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Neem contact op
{label}
</p>
<h2
className="font-serif font-normal mb-12"
@ -41,7 +32,7 @@ export function ContactSection() {
letterSpacing: -1,
}}
>
Contact
{heading}
</h2>
</FadeIn>

View file

@ -2,14 +2,18 @@
import { useState } from "react";
import { FadeIn } from "./fade-in";
import { CV_DATA, type Experience } from "@/lib/cv-data";
import { getCvData, type Lang, type Experience } from "@/lib/cv-data";
function ExperienceCard({
job,
index,
showMore,
showLess,
}: {
job: Experience;
index: number;
showMore: string;
showLess: string;
}) {
const [expanded, setExpanded] = useState(false);
const [hovered, setHovered] = useState(false);
@ -98,7 +102,7 @@ function ExperienceCard({
className="text-[12px] mt-3"
style={{ color: "rgba(255,255,255,0.25)" }}
>
{expanded ? "▲ Minder tonen" : "▼ Meer details"}
{expanded ? showLess : showMore}
</p>
)}
</div>
@ -106,7 +110,10 @@ function ExperienceCard({
);
}
export function ExperienceSection() {
export function ExperienceSection({ lang }: { lang: Lang }) {
const data = getCvData(lang);
const { label, heading, educationLabel, showMore, showLess } = data.ui.experience;
return (
<section
id="ervaring"
@ -118,7 +125,7 @@ export function ExperienceSection() {
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Loopbaan
{label}
</p>
<h2
className="font-serif font-normal mb-12"
@ -128,12 +135,12 @@ export function ExperienceSection() {
letterSpacing: -1,
}}
>
Werkervaring
{heading}
</h2>
</FadeIn>
{CV_DATA.experience.map((job, i) => (
<ExperienceCard key={i} job={job} index={i} />
{data.experience.map((job, i) => (
<ExperienceCard key={i} job={job} index={i} showMore={showMore} showLess={showLess} />
))}
<FadeIn delay={0.2}>
@ -148,32 +155,32 @@ export function ExperienceSection() {
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Opleiding
{educationLabel}
</p>
<h3
className="font-serif text-2xl font-normal mb-1"
style={{ color: "#e8e4df" }}
>
{CV_DATA.education.university}
{data.education.university}
</h3>
<p
className="text-[15px] mb-1"
style={{ color: "rgba(255,255,255,0.5)" }}
>
{CV_DATA.education.degree} {" "}
{CV_DATA.education.specialization}
{data.education.degree} {" "}
{data.education.specialization}
</p>
<p
className="text-[13px] mb-4"
style={{ color: "rgba(255,255,255,0.35)" }}
>
{CV_DATA.education.period}
{data.education.period}
</p>
<p
className="text-[14px]"
style={{ color: "rgba(255,255,255,0.4)" }}
>
{CV_DATA.education.secondary}
{data.education.secondary}
</p>
</div>
</FadeIn>

View file

@ -2,10 +2,11 @@
import { useState, useEffect } from "react";
import Image from "next/image";
import { CV_DATA } from "@/lib/cv-data";
import { getCvData, type Lang } from "@/lib/cv-data";
export function Hero() {
export function Hero({ lang }: { lang: Lang }) {
const [loaded, setLoaded] = useState(false);
const data = getCvData(lang);
useEffect(() => {
setTimeout(() => setLoaded(true), 100);
@ -116,7 +117,7 @@ export function Hero() {
className="text-[16px] leading-[1.7] max-w-[480px]"
style={{ color: "rgba(255,255,255,0.55)" }}
>
{CV_DATA.intro}
{data.intro}
</p>
<div className="flex gap-4 mt-8 flex-wrap">
@ -139,7 +140,7 @@ export function Hero() {
border: "1px solid rgba(255,255,255,0.08)",
}}
>
Bekijk CV
{data.ui.hero.viewCV}
</a>
<a
href="#apps"

View file

@ -1,7 +1,9 @@
import { FadeIn } from "./fade-in";
import { CV_DATA } from "@/lib/cv-data";
import { getCvData, type Lang } from "@/lib/cv-data";
export function MotivationSection({ lang }: { lang: Lang }) {
const data = getCvData(lang);
export function MotivationSection() {
return (
<section
id="motivatie"
@ -17,7 +19,7 @@ export function MotivationSection() {
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Motivatie
{data.ui.motivation.label}
</p>
<h2
className="font-serif font-normal mb-7"
@ -27,11 +29,11 @@ export function MotivationSection() {
letterSpacing: -1,
}}
>
Waar ik naar zoek
{data.ui.motivation.heading}
</h2>
<div className="max-w-[760px]">
{CV_DATA.motivation.map((paragraph) => (
{data.motivation.map((paragraph) => (
<p
key={paragraph}
className="text-[15px] leading-[1.8] mb-5 last:mb-0"

View file

@ -1,24 +1,21 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getCvData, type Lang } from "@/lib/cv-data";
const NAV_ITEMS = [
{ label: "Over", id: "over" },
{ label: "Ervaring", id: "ervaring" },
{ label: "Skills", id: "skills" },
{ label: "Apps", id: "apps" },
{ label: "Contact", id: "contact" },
];
export function Nav() {
export function Nav({ lang }: { lang: Lang }) {
const [active, setActive] = useState("over");
const [scrolled, setScrolled] = useState(false);
const data = getCvData(lang);
const navItems = data.ui.nav;
const otherLang: Lang = lang === "nl" ? "en" : "nl";
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 20);
const sections = [...NAV_ITEMS].reverse();
const sections = [...navItems].reverse();
for (const { id } of sections) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top < 200) {
@ -30,7 +27,7 @@ export function Nav() {
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
}, [navItems]);
const handleNav = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
@ -56,8 +53,8 @@ export function Nav() {
JP<span style={{ color: "#c2339b" }}>.</span>
</span>
<div className="hidden sm:flex gap-8">
{NAV_ITEMS.map(({ label, id }) => (
<div className="hidden sm:flex items-center gap-8">
{navItems.map(({ label, id }) => (
<button
key={id}
onClick={() => handleNav(id)}
@ -74,6 +71,22 @@ export function Nav() {
{label}
</button>
))}
{/* Language switcher */}
<div
className="flex items-center gap-1.5 text-[12px] font-semibold uppercase tracking-[1px] ml-2"
style={{ borderLeft: "1px solid rgba(255,255,255,0.1)", paddingLeft: "1.5rem" }}
>
<span style={{ color: "#c2339b" }}>{lang.toUpperCase()}</span>
<span style={{ color: "rgba(255,255,255,0.2)" }}>/</span>
<Link
href={`/${otherLang}`}
style={{ color: "rgba(255,255,255,0.4)" }}
className="no-underline hover:text-white transition-colors duration-200"
>
{otherLang.toUpperCase()}
</Link>
</div>
</div>
{/* Mobile menu button */}
@ -102,7 +115,7 @@ export function Nav() {
borderBottom: "1px solid rgba(255,255,255,0.06)",
}}
>
{NAV_ITEMS.map(({ label, id }) => (
{navItems.map(({ label, id }) => (
<button
key={id}
onClick={() => {
@ -119,6 +132,17 @@ export function Nav() {
{label}
</button>
))}
<div className="flex items-center gap-2 px-8 pt-3 mt-2" style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
<span className="text-[12px] font-semibold uppercase" style={{ color: "#c2339b" }}>{lang.toUpperCase()}</span>
<span className="text-[12px]" style={{ color: "rgba(255,255,255,0.2)" }}>/</span>
<Link
href={`/${otherLang}`}
className="text-[12px] font-semibold uppercase no-underline"
style={{ color: "rgba(255,255,255,0.4)" }}
>
{otherLang.toUpperCase()}
</Link>
</div>
</div>
</nav>
);

View file

@ -2,7 +2,7 @@
import Image from "next/image";
import { FadeIn } from "./fade-in";
import { CV_DATA } from "@/lib/cv-data";
import { getCvData, type Lang, type SpokenLanguage } from "@/lib/cv-data";
const DEVICONS = "https://cdn.jsdelivr.net/gh/devicons/devicon/icons";
const SIMPLE = "https://cdn.simpleicons.org";
@ -48,22 +48,9 @@ const BADGE_ICONS: Record<string, string> = {
Vercel: "▲",
};
const LANGUAGE_ICONS: Record<string, string> = {
Nederlands: `${FLAGS}/nl.svg`,
Engels: `${FLAGS}/gb.svg`,
Duits: `${FLAGS}/de.svg`,
};
const LANGUAGE_PROGRESS: Record<string, number> = {
Nederlands: 100,
Engels: 85,
Duits: 55,
};
function SkillPill({ label }: { label: string }) {
const icon = SKILL_ICONS[label] ?? LANGUAGE_ICONS[label];
const icon = SKILL_ICONS[label];
const badge = BADGE_ICONS[label];
const isLanguage = label in LANGUAGE_ICONS;
return (
<span
@ -78,14 +65,10 @@ function SkillPill({ label }: { label: string }) {
<Image
src={icon}
alt={label}
width={isLanguage ? 16 : 16}
height={isLanguage ? 12 : 16}
className={isLanguage ? "flex-shrink-0 rounded-[2px]" : "flex-shrink-0"}
style={{
height: isLanguage ? 12 : 16,
objectFit: "contain",
width: 16,
}}
width={16}
height={16}
className="flex-shrink-0"
style={{ height: 16, objectFit: "contain", width: 16 }}
unoptimized
/>
)}
@ -105,25 +88,22 @@ function SkillPill({ label }: { label: string }) {
);
}
function LanguageProgress({ label }: { label: string }) {
const icon = LANGUAGE_ICONS[label];
const progress = LANGUAGE_PROGRESS[label] ?? 0;
function LanguageProgress({ spoken }: { spoken: SpokenLanguage }) {
const flagSrc = `${FLAGS}/${spoken.flag}.svg`;
return (
<div className="mb-4 last:mb-0">
<div className="mb-2 flex items-center gap-2 text-[13px] font-medium">
{icon && (
<Image
src={icon}
alt={label}
width={16}
height={12}
className="flex-shrink-0 rounded-[2px]"
style={{ height: 12, objectFit: "contain", width: 16 }}
unoptimized
/>
)}
<span style={{ color: "rgba(255,255,255,0.65)" }}>{label}</span>
<Image
src={flagSrc}
alt={spoken.label}
width={16}
height={12}
className="flex-shrink-0 rounded-[2px]"
style={{ height: 12, objectFit: "contain", width: 16 }}
unoptimized
/>
<span style={{ color: "rgba(255,255,255,0.65)" }}>{spoken.label}</span>
</div>
<div
className="h-1.5 overflow-hidden rounded-full"
@ -132,7 +112,7 @@ function LanguageProgress({ label }: { label: string }) {
<div
className="h-full rounded-full"
style={{
width: `${progress}%`,
width: `${spoken.progress}%`,
background: "linear-gradient(90deg, #c2339b, #e8e4df)",
}}
/>
@ -141,15 +121,18 @@ function LanguageProgress({ label }: { label: string }) {
);
}
const SKILL_GROUPS = [
{ title: "Programmeertalen", items: CV_DATA.skills.languages },
{ title: "Frameworks", items: CV_DATA.skills.frameworks },
{ title: "Databases", items: CV_DATA.skills.databases },
{ title: "AI / AI-tools", items: CV_DATA.skills.aiTools },
{ title: "Talen", items: CV_DATA.skills.spoken },
];
export function SkillsSection({ lang }: { lang: Lang }) {
const data = getCvData(lang);
const { label, groupTitles, interestsHeading } = data.ui.skills;
const SKILL_GROUPS = [
{ id: "languages", title: groupTitles.languages, items: data.skills.languages },
{ id: "frameworks", title: groupTitles.frameworks, items: data.skills.frameworks },
{ id: "databases", title: groupTitles.databases, items: data.skills.databases },
{ id: "aiTools", title: groupTitles.aiTools, items: data.skills.aiTools },
{ id: "spoken", title: groupTitles.spoken, items: [] },
];
export function SkillsSection() {
return (
<section
id="skills"
@ -161,7 +144,7 @@ export function SkillsSection() {
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Technologie
{label}
</p>
<h2
className="font-serif font-normal mb-12"
@ -177,7 +160,7 @@ export function SkillsSection() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{SKILL_GROUPS.map((group, i) => (
<FadeIn key={group.title} delay={i * 0.1}>
<FadeIn key={group.id} delay={i * 0.1}>
<div
className="rounded-2xl p-7"
style={{
@ -195,8 +178,10 @@ export function SkillsSection() {
{group.title}
</h3>
<div>
{group.title === "Talen"
? group.items.map((s) => <LanguageProgress key={s} label={s} />)
{group.id === "spoken"
? data.skills.spoken.map((s) => (
<LanguageProgress key={s.label} spoken={s} />
))
: group.items.map((s) => <SkillPill key={s} label={s} />)}
</div>
</div>
@ -213,10 +198,10 @@ export function SkillsSection() {
letterSpacing: 2,
}}
>
Interesses
{interestsHeading}
</h3>
<div className="flex gap-2 flex-wrap">
{CV_DATA.interests.map((interest) => (
{data.interests.map((interest) => (
<span
key={interest}
className="px-5 py-2 rounded-full text-[14px]"