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:
parent
61e603b5d7
commit
d352a7d496
13 changed files with 540 additions and 177 deletions
39
app/[lang]/layout.tsx
Normal file
39
app/[lang]/layout.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ lang: "nl" }, { lang: "en" }];
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const isEn = lang === "en";
|
||||
return {
|
||||
title: "Janpeter Visser — Software Engineer",
|
||||
description: isEn
|
||||
? "Personal website of Janpeter Visser. Allround software engineer with 30 years of experience in full-stack development, from C++ to Angular and .NET."
|
||||
: "Persoonlijke website van Janpeter Visser. Allround software engineer met 30 jaar ervaring in full-stack development, van C++ tot Angular en .NET.",
|
||||
metadataBase: new URL("https://jp-visser.nl"),
|
||||
openGraph: {
|
||||
title: "Janpeter Visser — Software Engineer",
|
||||
description: isEn
|
||||
? "Allround software engineer with 30 years of experience in full-stack development."
|
||||
: "Allround software engineer met 30 jaar ervaring in full-stack development.",
|
||||
url: `https://jp-visser.nl/${lang}`,
|
||||
siteName: "Janpeter Visser",
|
||||
locale: isEn ? "en_GB" : "nl_NL",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function LangLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
39
app/[lang]/page.tsx
Normal file
39
app/[lang]/page.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import type { Lang } from "@/lib/cv-data";
|
||||
import { Nav } from "@/components/nav";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { MotivationSection } from "@/components/motivation";
|
||||
import { ExperienceSection } from "@/components/experience";
|
||||
import { SkillsSection } from "@/components/skills";
|
||||
import { AppsSection } from "@/components/apps";
|
||||
import { ContactSection } from "@/components/contact";
|
||||
import { Footer } from "@/components/footer";
|
||||
|
||||
const VALID_LANGS: Lang[] = ["nl", "en"];
|
||||
|
||||
export default async function LangPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}) {
|
||||
const { lang } = await params;
|
||||
|
||||
if (!VALID_LANGS.includes(lang as Lang)) {
|
||||
redirect("/nl");
|
||||
}
|
||||
|
||||
const currentLang = lang as Lang;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav lang={currentLang} />
|
||||
<Hero lang={currentLang} />
|
||||
<MotivationSection lang={currentLang} />
|
||||
<ExperienceSection lang={currentLang} />
|
||||
<SkillsSection lang={currentLang} />
|
||||
<AppsSection lang={currentLang} />
|
||||
<ContactSection lang={currentLang} />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import { DM_Sans } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
|
|
@ -9,28 +10,19 @@ const dmSans = DM_Sans({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Janpeter Visser — Software Engineer",
|
||||
description:
|
||||
"Persoonlijke website van Janpeter Visser. Allround software engineer met 30 jaar ervaring in full-stack development, van C++ tot Angular en .NET.",
|
||||
metadataBase: new URL("https://jp-visser.nl"),
|
||||
openGraph: {
|
||||
title: "Janpeter Visser — Software Engineer",
|
||||
description:
|
||||
"Allround software engineer met 30 jaar ervaring in full-stack development.",
|
||||
url: "https://jp-visser.nl",
|
||||
siteName: "Janpeter Visser",
|
||||
locale: "nl_NL",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const headersList = await headers();
|
||||
const lang = headersList.get("x-lang") ?? "nl";
|
||||
|
||||
return (
|
||||
<html lang="nl" className={dmSans.variable}>
|
||||
<html lang={lang} className={dmSans.variable}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
|
|
|
|||
24
app/page.tsx
24
app/page.tsx
|
|
@ -1,23 +1,5 @@
|
|||
import { Nav } from "@/components/nav";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { MotivationSection } from "@/components/motivation";
|
||||
import { ExperienceSection } from "@/components/experience";
|
||||
import { SkillsSection } from "@/components/skills";
|
||||
import { AppsSection } from "@/components/apps";
|
||||
import { ContactSection } from "@/components/contact";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<Hero />
|
||||
<MotivationSection />
|
||||
<ExperienceSection />
|
||||
<SkillsSection />
|
||||
<AppsSection />
|
||||
<ContactSection />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
export default function RootPage() {
|
||||
redirect("/nl");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,16 @@
|
|||
import { FadeIn } from "./fade-in";
|
||||
import { CV_DATA } from "@/lib/cv-data";
|
||||
import { getCvData, type Lang } from "@/lib/cv-data";
|
||||
|
||||
export function ContactSection({ lang }: { lang: Lang }) {
|
||||
const data = getCvData(lang);
|
||||
const { label, heading, emailLabel, locationLabel, websiteLabel } = data.ui.contact;
|
||||
|
||||
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",
|
||||
},
|
||||
{ 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
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)" }}>{label}</span>
|
||||
<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 }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function SkillsSection({ lang }: { lang: Lang }) {
|
||||
const data = getCvData(lang);
|
||||
const { label, groupTitles, interestsHeading } = data.ui.skills;
|
||||
|
||||
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 },
|
||||
{ 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]"
|
||||
|
|
|
|||
303
lib/cv-data.ts
303
lib/cv-data.ts
|
|
@ -1,4 +1,30 @@
|
|||
export const CV_DATA = {
|
||||
export type Lang = "nl" | "en";
|
||||
|
||||
export type Experience = {
|
||||
company: string;
|
||||
location: string;
|
||||
period: string;
|
||||
role: string;
|
||||
description: string;
|
||||
highlights: { title: string; text: string }[];
|
||||
};
|
||||
|
||||
export type SpokenLanguage = {
|
||||
label: string;
|
||||
flag: string;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
export type AppEntry = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
screenshot: string;
|
||||
screenshotMobile?: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const cvDataNl = {
|
||||
name: "Janpeter Visser",
|
||||
tagline: "Software Engineer · Full Stack Developer",
|
||||
intro:
|
||||
|
|
@ -73,7 +99,7 @@ export const CV_DATA = {
|
|||
"Verantwoordelijk voor de bouw van systemen voor de logistieke sector. Primaire bedrijfsprocessen gemodelleerd met grafische interfaces voor efficiënt vrachttransport, inclusief facturatiesystemen.",
|
||||
highlights: [],
|
||||
},
|
||||
],
|
||||
] as Experience[],
|
||||
education: {
|
||||
university: "TU Delft",
|
||||
degree: "Technische Informatica",
|
||||
|
|
@ -123,14 +149,279 @@ export const CV_DATA = {
|
|||
"NotebookLM",
|
||||
"GitHub",
|
||||
],
|
||||
tools: ["Claude", "ChatGPT", "Git", "Supabase", "Vercel"],
|
||||
spoken: ["Nederlands", "Engels", "Duits"],
|
||||
spoken: [
|
||||
{ label: "Nederlands", flag: "nl", progress: 100 },
|
||||
{ label: "Engels", flag: "gb", progress: 85 },
|
||||
{ label: "Duits", flag: "de", progress: 55 },
|
||||
] as SpokenLanguage[],
|
||||
},
|
||||
interests: [
|
||||
"Reizen door Azië",
|
||||
"Yoga",
|
||||
"Vrijwilligerswerk digitale ondersteuning: Centrale Bibliotheek Rotterdam & Wijkcentrum Schiedam Oost",
|
||||
],
|
||||
} as 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",
|
||||
},
|
||||
] as AppEntry[],
|
||||
ui: {
|
||||
nav: [
|
||||
{ label: "Over", id: "over" },
|
||||
{ label: "Ervaring", id: "ervaring" },
|
||||
{ label: "Skills", id: "skills" },
|
||||
{ label: "Apps", id: "apps" },
|
||||
{ label: "Contact", id: "contact" },
|
||||
],
|
||||
hero: { viewCV: "Bekijk CV" },
|
||||
motivation: { label: "Motivatie", heading: "Waar ik naar zoek" },
|
||||
experience: {
|
||||
label: "Loopbaan",
|
||||
heading: "Werkervaring",
|
||||
educationLabel: "Opleiding",
|
||||
showMore: "▼ Meer details",
|
||||
showLess: "▲ Minder tonen",
|
||||
},
|
||||
skills: {
|
||||
label: "Technologie",
|
||||
groupTitles: {
|
||||
languages: "Programmeertalen",
|
||||
frameworks: "Frameworks",
|
||||
databases: "Databases",
|
||||
aiTools: "AI / AI-tools",
|
||||
spoken: "Talen",
|
||||
},
|
||||
interestsHeading: "Interesses",
|
||||
},
|
||||
contact: {
|
||||
label: "Neem contact op",
|
||||
heading: "Contact",
|
||||
emailLabel: "E-mail",
|
||||
locationLabel: "Locatie",
|
||||
websiteLabel: "Website",
|
||||
},
|
||||
apps: {
|
||||
label: "Portfolio",
|
||||
heading: "Apps & Projecten",
|
||||
subtext: "Hier komen links naar mijn apps die ik op Vercel host.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Experience = (typeof CV_DATA.experience)[number];
|
||||
const cvDataEn = {
|
||||
name: "Janpeter Visser",
|
||||
tagline: "Software Engineer · Full Stack Developer",
|
||||
intro:
|
||||
"Software engineer and full stack developer with over thirty years of experience building, maintaining, and improving software. Strong at bringing structure, understanding existing systems, and developing practical web applications. Works with TypeScript, React, Next.js, C#, databases and AI tools such as Claude Code and ChatGPT. Available for sustainable part-time work.",
|
||||
motivation: [
|
||||
"With my background as a software engineer and full stack developer, I am looking for a role where I can apply my broad experience in a focused way. I have over thirty years of experience designing, building and improving software — from business-critical web applications and customer portals to modern full stack applications with TypeScript, React, Next.js, C# and databases.",
|
||||
"What defines me is that I quickly bring structure to complex systems. I enjoy making existing software more understandable, stable and usable, but also building new tools that deliver direct value. Lately I have been working a lot with AI tools such as Claude Code and ChatGPT, including in my own projects such as Scrum4Me and the Inspannings Monitor. This allows me to combine years of technical experience with a modern, practical approach to development.",
|
||||
"I prefer a part-time role where I can contribute in a sustainable and focused way, at approximately 50% capacity. This is where I perform best: with clear priorities, clear communication and work where quality, continuity and reliability matter.",
|
||||
"I bring calm, experience and technical overview, and enjoy working with people who want to build software that is actually used.",
|
||||
],
|
||||
contact: {
|
||||
email: "janpetervisser2@gmail.com",
|
||||
location: "Rotterdam",
|
||||
},
|
||||
experience: [
|
||||
{
|
||||
company: "QPIT BV",
|
||||
location: "Rotterdam",
|
||||
period: "January 2004 – February 2024",
|
||||
role: "Software Engineer",
|
||||
description:
|
||||
"At QPIT I held multiple roles in a multidisciplinary capacity. I contributed to developing the proprietary software system Quism (Service desk software) and configuring it for clients.",
|
||||
highlights: [
|
||||
{
|
||||
title: "Software Engineer Quism",
|
||||
text: "Made a web application for service management (ITIL) cross-browser compatible. Developed tools to analyse and modify ASP, JavaScript and HTML code. Built supporting tools for email ticket integration via IMAP, MAPI and POP3.",
|
||||
},
|
||||
{
|
||||
title: "Research & Development",
|
||||
text: "Researched new development environments for mobile devices. This led to the development of full stack PWA applications with Angular, TypeScript and C#.",
|
||||
},
|
||||
{
|
||||
title: "Product Development",
|
||||
text: "Developed various customer portals for mobile devices. Web application for scrum/agile project development with web-based Agile dashboards. Migrated a Port of Rotterdam application to a full stack PWA.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
company: "TNO Bouw",
|
||||
location: "Delft",
|
||||
period: "January 2001 – January 2003",
|
||||
role: "Research Associate",
|
||||
description:
|
||||
"Worked at the Building Informatics department as a research associate, at the intersection of universities (architecture & computer science) and the construction industry.",
|
||||
highlights: [
|
||||
{
|
||||
title: "HSL Project",
|
||||
text: "Defined and implemented a configuration management system for designing the HSL high-speed rail route. Formalisation documented in UML.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
company: "Logica",
|
||||
location: "Woerden",
|
||||
period: "October 1998 – January 2001",
|
||||
role: "Software Engineer",
|
||||
description:
|
||||
"Seconded to the Dutch Tax Authority in Apeldoorn. Worked on the digital tax return system. Co-responsible for testing, acceptance and distribution of software.",
|
||||
highlights: [
|
||||
{
|
||||
title: "Product Specialist",
|
||||
text: "Responsible for integrating new products within the Tax Authority. Coordinating role between different departments.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
company: "Europe Transport Automation",
|
||||
location: "Rotterdam",
|
||||
period: "November 1994 – October 1998",
|
||||
role: "Systems Designer and Developer",
|
||||
description:
|
||||
"Responsible for building systems for the logistics sector. Modelled primary business processes with graphical interfaces for efficient freight transport, including invoicing systems.",
|
||||
highlights: [],
|
||||
},
|
||||
] as Experience[],
|
||||
education: {
|
||||
university: "TU Delft",
|
||||
degree: "Computer Science",
|
||||
specialization:
|
||||
"Software Engineering, Programming Languages and Compiler Design",
|
||||
period: "1987 – 1994",
|
||||
secondary:
|
||||
"Pre-university education (VWO Atheneum-B), OSG Ring van Putten in Spijkenisse (1981–1987)",
|
||||
},
|
||||
skills: {
|
||||
languages: [
|
||||
"Python",
|
||||
"TypeScript",
|
||||
"JavaScript",
|
||||
"C#",
|
||||
"C",
|
||||
"C++",
|
||||
"HTML",
|
||||
"CSS",
|
||||
"Visual Basic",
|
||||
"Assembler",
|
||||
],
|
||||
frameworks: [
|
||||
"Next.js",
|
||||
"React",
|
||||
"Tailwind CSS",
|
||||
"Angular",
|
||||
"Angular Material",
|
||||
"Redux",
|
||||
"ASP.NET",
|
||||
"ASP.NET Core",
|
||||
],
|
||||
databases: [
|
||||
"Supabase",
|
||||
"Microsoft SQL Server",
|
||||
"Oracle",
|
||||
"MySQL",
|
||||
"MariaDB",
|
||||
],
|
||||
aiTools: [
|
||||
"ChatGPT",
|
||||
"Claude Code",
|
||||
"Supabase",
|
||||
"Neon",
|
||||
"Vercel",
|
||||
"Obsidian",
|
||||
"NotebookLM",
|
||||
"GitHub",
|
||||
],
|
||||
spoken: [
|
||||
{ label: "Dutch", flag: "nl", progress: 100 },
|
||||
{ label: "English", flag: "gb", progress: 85 },
|
||||
{ label: "German", flag: "de", progress: 55 },
|
||||
] as SpokenLanguage[],
|
||||
},
|
||||
interests: [
|
||||
"Travelling in Asia",
|
||||
"Yoga",
|
||||
"Volunteer digital support: Central Library Rotterdam & Neighbourhood Centre Schiedam East",
|
||||
],
|
||||
apps: [
|
||||
{
|
||||
title: "Inspannings Monitor",
|
||||
subtitle: "Wellness-first daily flow",
|
||||
description:
|
||||
"A lightweight app that helps with pacing and provides insight without long forms or overstimulation.",
|
||||
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 for small teams",
|
||||
description:
|
||||
"A desktop-first fullstack web application for solo developers and small Scrum teams managing multiple software projects in parallel. The app organises work hierarchically (product → PBI → story → task), offers split planning screens with drag-and-drop, and integrates with Claude Code via a REST API.",
|
||||
screenshot: "/images/app-scrum4me.svg",
|
||||
href: "https://scrum4me.jp-visser.nl",
|
||||
},
|
||||
] as AppEntry[],
|
||||
ui: {
|
||||
nav: [
|
||||
{ label: "About", id: "over" },
|
||||
{ label: "Experience", id: "ervaring" },
|
||||
{ label: "Skills", id: "skills" },
|
||||
{ label: "Apps", id: "apps" },
|
||||
{ label: "Contact", id: "contact" },
|
||||
],
|
||||
hero: { viewCV: "View CV" },
|
||||
motivation: { label: "Motivation", heading: "What I'm looking for" },
|
||||
experience: {
|
||||
label: "Career",
|
||||
heading: "Work Experience",
|
||||
educationLabel: "Education",
|
||||
showMore: "▼ Show more",
|
||||
showLess: "▲ Show less",
|
||||
},
|
||||
skills: {
|
||||
label: "Technology",
|
||||
groupTitles: {
|
||||
languages: "Programming Languages",
|
||||
frameworks: "Frameworks",
|
||||
databases: "Databases",
|
||||
aiTools: "AI / AI Tools",
|
||||
spoken: "Languages",
|
||||
},
|
||||
interestsHeading: "Interests",
|
||||
},
|
||||
contact: {
|
||||
label: "Get in touch",
|
||||
heading: "Contact",
|
||||
emailLabel: "Email",
|
||||
locationLabel: "Location",
|
||||
websiteLabel: "Website",
|
||||
},
|
||||
apps: {
|
||||
label: "Portfolio",
|
||||
heading: "Apps & Projects",
|
||||
subtext: "Links to my apps hosted on Vercel.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getCvData(lang: Lang) {
|
||||
return lang === "en" ? cvDataEn : cvDataNl;
|
||||
}
|
||||
|
||||
export type CvData = ReturnType<typeof getCvData>;
|
||||
|
|
|
|||
27
middleware.ts
Normal file
27
middleware.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Detect lang from path for the x-lang header
|
||||
const lang = pathname.startsWith("/en") ? "en" : "nl";
|
||||
|
||||
// On root path: redirect based on Accept-Language
|
||||
if (pathname === "/") {
|
||||
const acceptLanguage = request.headers.get("accept-language") ?? "";
|
||||
const prefersEnglish =
|
||||
acceptLanguage.includes("en") && !acceptLanguage.startsWith("nl");
|
||||
const redirectLang = prefersEnglish ? "en" : "nl";
|
||||
return NextResponse.redirect(new URL(`/${redirectLang}`, request.url));
|
||||
}
|
||||
|
||||
// Pass x-lang header so root layout can set <html lang>
|
||||
const response = NextResponse.next();
|
||||
response.headers.set("x-lang", lang);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next|images|favicon.ico).*)"],
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue