Initial commit

This commit is contained in:
Janpeter Visser 2026-04-14 21:39:50 +02:00
commit dc66b66d94
22 changed files with 7556 additions and 0 deletions

3
.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

36
.gitignore vendored Normal file
View file

@ -0,0 +1,36 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

87
README.md Normal file
View file

@ -0,0 +1,87 @@
# jp-visser.nl
Persoonlijke website van Janpeter Visser — Software Engineer.
## Lokaal draaien
```bash
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
## Deployen naar Vercel
### 1. Push naar GitHub
```bash
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin https://github.com/JOUW_USERNAME/jp-visser.git
git push -u origin main
```
### 2. Koppel aan Vercel
1. Ga naar [vercel.com/new](https://vercel.com/new)
2. Importeer je GitHub repository
3. Klik op **Deploy** — Vercel detecteert Next.js automatisch
### 3. Domein instellen
1. Ga naar je project in Vercel → **Settings** → **Domains**
2. Voeg `jp-visser.nl` toe
3. Bij je domeinregistrar, stel in:
- **A-record**: `@``76.76.21.21`
- **CNAME**: `www``cname.vercel-dns.com`
DNS-wijzigingen kunnen tot 48 uur duren, maar zijn meestal binnen een uur actief.
## Apps toevoegen
Om apps toe te voegen aan de portfolio:
**Optie A — Als aparte Vercel projecten (subdomein):**
- Deploy een app als apart project op Vercel
- Voeg een subdomein toe: bijv. `app1.jp-visser.nl`
**Optie B — Als routes binnen dit project:**
- Maak een nieuwe pagina aan in `app/apps/naam/page.tsx`
- De app is dan bereikbaar op `jp-visser.nl/apps/naam`
Pas daarna `components/apps.tsx` aan om de links naar je apps te tonen.
## Projectstructuur
```
jp-visser/
├── app/
│ ├── globals.css # Global styles + Tailwind
│ ├── layout.tsx # Root layout met fonts & metadata
│ └── page.tsx # Homepage
├── components/
│ ├── nav.tsx # Navigatie (sticky)
│ ├── hero.tsx # Hero sectie met portret
│ ├── experience.tsx # Werkervaring
│ ├── skills.tsx # Skills & tools
│ ├── apps.tsx # Apps portfolio
│ ├── contact.tsx # Contact info
│ ├── footer.tsx # Footer
│ └── fade-in.tsx # Scroll-animatie component
├── lib/
│ └── cv-data.ts # Alle CV data
├── public/
│ └── images/
│ └── portrait.jpg # Portretfoto
└── package.json
```
## Tech stack
- **Next.js 15** (App Router)
- **TypeScript**
- **Tailwind CSS**
- Hosted op **Vercel**

79
app/globals.css Normal file
View file

@ -0,0 +1,79 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--accent: #c2339b;
--bg: #0f0f14;
--text: #e8e4df;
--text-muted: rgba(255, 255, 255, 0.5);
--text-dim: rgba(255, 255, 255, 0.35);
--surface: rgba(255, 255, 255, 0.03);
--border: rgba(255, 255, 255, 0.06);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background: rgba(194, 51, 155, 0.35);
color: #fff;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: rgba(194, 51, 155, 0.3);
border-radius: 3px;
}
a {
transition: opacity 0.2s;
}
a:hover {
opacity: 0.85;
}
/* Fade-in animation */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(28px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
opacity: 0;
transform: translateY(28px);
transition: opacity 0.7s ease, transform 0.7s ease;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}

49
app/layout.tsx Normal file
View file

@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { DM_Sans } from "next/font/google";
import "./globals.css";
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
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({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="nl" className={dmSans.variable}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap"
rel="stylesheet"
/>
</head>
<body className="font-sans">{children}</body>
</html>
);
}

21
app/page.tsx Normal file
View file

@ -0,0 +1,21 @@
import { Nav } from "@/components/nav";
import { Hero } from "@/components/hero";
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";
export default function Home() {
return (
<>
<Nav />
<Hero />
<ExperienceSection />
<SkillsSection />
<AppsSection />
<ContactSection />
<Footer />
</>
);
}

72
components/apps.tsx Normal file
View file

@ -0,0 +1,72 @@
import { FadeIn } from "./fade-in";
const PLACEHOLDER_APPS = [
{ icon: "⚡", label: "App 1 — binnenkort" },
{ icon: "🔧", label: "App 2 — binnenkort" },
{ icon: "📱", label: "App 3 — binnenkort" },
];
export function AppsSection() {
return (
<section
id="apps"
className="mx-auto max-w-[900px]"
style={{ padding: "100px clamp(20px, 6vw, 80px)" }}
>
<FadeIn>
<p
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Portfolio
</p>
<h2
className="font-serif font-normal mb-4"
style={{
fontSize: "clamp(32px, 4vw, 48px)",
color: "#e8e4df",
letterSpacing: -1,
}}
>
Apps & Projecten
</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.
</p>
</FadeIn>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
{PLACEHOLDER_APPS.map((app, i) => (
<FadeIn key={i} delay={i * 0.1}>
<div
className="rounded-2xl p-8 flex flex-col items-center justify-center min-h-[200px] text-center"
style={{
background: "rgba(255,255,255,0.03)",
border: "1px dashed rgba(255,255,255,0.1)",
}}
>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center mb-4 text-xl"
style={{
background: "rgba(194,51,155,0.1)",
color: "#c2339b",
}}
>
{app.icon}
</div>
<p
className="text-[14px]"
style={{ color: "rgba(255,255,255,0.3)" }}
>
{app.label}
</p>
</div>
</FadeIn>
))}
</div>
</section>
);
}

86
components/contact.tsx Normal file
View file

@ -0,0 +1,86 @@
import { FadeIn } from "./fade-in";
import { CV_DATA } 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() {
return (
<section
id="contact"
className="mx-auto max-w-[900px]"
style={{ padding: "100px clamp(20px, 6vw, 80px) 60px" }}
>
<FadeIn>
<p
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Neem contact op
</p>
<h2
className="font-serif font-normal mb-12"
style={{
fontSize: "clamp(32px, 4vw, 48px)",
color: "#e8e4df",
letterSpacing: -1,
}}
>
Contact
</h2>
</FadeIn>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
{CONTACT_ITEMS.map((item, i) => (
<FadeIn key={item.label} delay={i * 0.1}>
<div
className="rounded-2xl p-7"
style={{
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<p
className="text-[12px] font-semibold uppercase mb-2"
style={{
color: "rgba(255,255,255,0.35)",
letterSpacing: 2,
}}
>
{item.label}
</p>
{item.href ? (
<a
href={item.href}
className="text-[15px] no-underline"
style={{ color: "#c2339b" }}
>
{item.value}
</a>
) : (
<p className="text-[15px]" style={{ color: "#e8e4df" }}>
{item.value}
</p>
)}
</div>
</FadeIn>
))}
</div>
</section>
);
}

182
components/experience.tsx Normal file
View file

@ -0,0 +1,182 @@
"use client";
import { useState } from "react";
import { FadeIn } from "./fade-in";
import { CV_DATA, type Experience } from "@/lib/cv-data";
function ExperienceCard({
job,
index,
}: {
job: Experience;
index: number;
}) {
const [expanded, setExpanded] = useState(false);
const [hovered, setHovered] = useState(false);
return (
<FadeIn delay={index * 0.1}>
<div
className="rounded-2xl p-7 mb-5 cursor-pointer transition-all duration-300"
style={{
background: hovered
? "rgba(255,255,255,0.04)"
: "rgba(255,255,255,0.03)",
border: `1px solid ${
hovered
? "rgba(194,51,155,0.25)"
: "rgba(255,255,255,0.06)"
}`,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => setExpanded(!expanded)}
>
<div className="flex justify-between items-start flex-wrap gap-2">
<div>
<h3
className="font-serif text-2xl font-normal mb-1"
style={{ color: "#e8e4df" }}
>
{job.role}
</h3>
<p
className="text-sm font-medium"
style={{ color: "#c2339b" }}
>
{job.company}, {job.location}
</p>
</div>
<span
className="text-[13px] whitespace-nowrap"
style={{ color: "rgba(255,255,255,0.35)" }}
>
{job.period}
</span>
</div>
<p
className="text-[15px] leading-[1.7] mt-4"
style={{ color: "rgba(255,255,255,0.5)" }}
>
{job.description}
</p>
{job.highlights.length > 0 && (
<div
className="overflow-hidden transition-all duration-500"
style={{ maxHeight: expanded ? 600 : 0 }}
>
<div
className="pt-4 mt-4"
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
}}
>
{job.highlights.map((h, i) => (
<div key={i} className="mb-3">
<p
className="text-[13px] font-semibold mb-1"
style={{ color: "#c2339b" }}
>
{h.title}
</p>
<p
className="text-[14px] leading-[1.6]"
style={{ color: "rgba(255,255,255,0.45)" }}
>
{h.text}
</p>
</div>
))}
</div>
</div>
)}
{job.highlights.length > 0 && (
<p
className="text-[12px] mt-3"
style={{ color: "rgba(255,255,255,0.25)" }}
>
{expanded ? "▲ Minder tonen" : "▼ Meer details"}
</p>
)}
</div>
</FadeIn>
);
}
export function ExperienceSection() {
return (
<section
id="ervaring"
className="mx-auto max-w-[900px]"
style={{ padding: "100px clamp(20px, 6vw, 80px)" }}
>
<FadeIn>
<p
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Loopbaan
</p>
<h2
className="font-serif font-normal mb-12"
style={{
fontSize: "clamp(32px, 4vw, 48px)",
color: "#e8e4df",
letterSpacing: -1,
}}
>
Werkervaring
</h2>
</FadeIn>
{CV_DATA.experience.map((job, i) => (
<ExperienceCard key={i} job={job} index={i} />
))}
<FadeIn delay={0.2}>
<div
className="rounded-2xl p-7 mt-10"
style={{
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<p
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Opleiding
</p>
<h3
className="font-serif text-2xl font-normal mb-1"
style={{ color: "#e8e4df" }}
>
{CV_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}
</p>
<p
className="text-[13px] mb-4"
style={{ color: "rgba(255,255,255,0.35)" }}
>
{CV_DATA.education.period}
</p>
<p
className="text-[14px]"
style={{ color: "rgba(255,255,255,0.4)" }}
>
{CV_DATA.education.secondary}
</p>
</div>
</FadeIn>
</section>
);
}

46
components/fade-in.tsx Normal file
View file

@ -0,0 +1,46 @@
"use client";
import { useRef, useState, useEffect, ReactNode } from "react";
interface FadeInProps {
children: ReactNode;
delay?: number;
className?: string;
}
export function FadeIn({ children, delay = 0, className = "" }: FadeInProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
obs.unobserve(el);
}
},
{ threshold: 0.15 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(28px)",
transition: `opacity 0.7s ease ${delay}s, transform 0.7s ease ${delay}s`,
}}
>
{children}
</div>
);
}

15
components/footer.tsx Normal file
View file

@ -0,0 +1,15 @@
export function Footer() {
return (
<footer
className="flex justify-between items-center flex-wrap gap-4 text-[13px]"
style={{
padding: "40px clamp(20px, 6vw, 80px)",
borderTop: "1px solid rgba(255,255,255,0.06)",
color: "rgba(255,255,255,0.25)",
}}
>
<span>&copy; {new Date().getFullYear()} Janpeter Visser</span>
<span>jp-visser.nl</span>
</footer>
);
}

150
components/hero.tsx Normal file
View file

@ -0,0 +1,150 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { CV_DATA } from "@/lib/cv-data";
export function Hero() {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setTimeout(() => setLoaded(true), 100);
}, []);
return (
<section
id="over"
className="relative min-h-screen flex items-center overflow-hidden"
style={{ padding: "100px clamp(20px, 6vw, 80px) 60px" }}
>
{/* Gradient orbs */}
<div
className="absolute pointer-events-none"
style={{
top: -120,
right: -80,
width: 500,
height: 500,
background:
"radial-gradient(circle, rgba(194,51,155,0.12) 0%, transparent 70%)",
borderRadius: "50%",
filter: "blur(60px)",
}}
/>
<div
className="absolute pointer-events-none"
style={{
bottom: -100,
left: -60,
width: 400,
height: 400,
background:
"radial-gradient(circle, rgba(80,60,160,0.1) 0%, transparent 70%)",
borderRadius: "50%",
filter: "blur(50px)",
}}
/>
<div
className="flex flex-wrap items-center mx-auto w-full"
style={{
gap: "clamp(40px, 6vw, 80px)",
maxWidth: 1100,
}}
>
{/* Portrait */}
<div
className="flex-shrink-0 rounded-2xl overflow-hidden relative"
style={{
width: "clamp(220px, 28vw, 320px)",
height: "clamp(280px, 36vw, 420px)",
boxShadow:
"0 30px 80px rgba(194,51,155,0.15), 0 0 0 1px rgba(255,255,255,0.05)",
opacity: loaded ? 1 : 0,
transform: loaded ? "scale(1)" : "scale(0.92)",
transition: "all 0.9s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<Image
src="/images/portrait.jpg"
alt="Janpeter Visser"
fill
className="object-cover object-top"
priority
sizes="(max-width: 768px) 220px, 320px"
/>
</div>
{/* Text */}
<div className="flex-1 min-w-[280px]">
<div
style={{
opacity: loaded ? 1 : 0,
transform: loaded ? "translateY(0)" : "translateY(30px)",
transition: "all 0.8s ease 0.2s",
}}
>
<p
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Software Engineer
</p>
<h1
className="font-serif font-normal mb-6"
style={{
fontSize: "clamp(42px, 5.5vw, 68px)",
color: "#e8e4df",
lineHeight: 1.05,
letterSpacing: -1.5,
}}
>
Janpeter
<br />
Visser
</h1>
</div>
<div
style={{
opacity: loaded ? 1 : 0,
transform: loaded ? "translateY(0)" : "translateY(30px)",
transition: "all 0.8s ease 0.4s",
}}
>
<p
className="text-[16px] leading-[1.7] max-w-[480px]"
style={{ color: "rgba(255,255,255,0.55)" }}
>
{CV_DATA.intro}
</p>
<div className="flex gap-4 mt-8 flex-wrap">
<a
href="#contact"
className="px-8 py-3 rounded-lg text-sm font-semibold no-underline text-white"
style={{
background: "#c2339b",
boxShadow: "0 4px 20px rgba(194,51,155,0.3)",
}}
>
Contact
</a>
<a
href="#ervaring"
className="px-8 py-3 rounded-lg text-sm font-semibold no-underline"
style={{
background: "rgba(255,255,255,0.06)",
color: "rgba(255,255,255,0.7)",
border: "1px solid rgba(255,255,255,0.08)",
}}
>
Bekijk CV
</a>
</div>
</div>
</div>
</div>
</section>
);
}

125
components/nav.tsx Normal file
View file

@ -0,0 +1,125 @@
"use client";
import { useState, useEffect } from "react";
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() {
const [active, setActive] = useState("over");
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 20);
const sections = [...NAV_ITEMS].reverse();
for (const { id } of sections) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top < 200) {
setActive(id);
break;
}
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const handleNav = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
};
return (
<nav
className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between transition-all duration-300"
style={{
background: scrolled
? "rgba(15,15,20,0.92)"
: "rgba(15,15,20,0.6)",
backdropFilter: "blur(16px)",
borderBottom: "1px solid rgba(255,255,255,0.06)",
padding: "0 clamp(20px, 4vw, 60px)",
height: 64,
}}
>
<span
className="font-serif text-[22px] tracking-tight"
style={{ color: "#e8e4df" }}
>
JP<span style={{ color: "#c2339b" }}>.</span>
</span>
<div className="hidden sm:flex gap-8">
{NAV_ITEMS.map(({ label, id }) => (
<button
key={id}
onClick={() => handleNav(id)}
className="bg-transparent border-none cursor-pointer text-[13px] font-medium uppercase tracking-[1.2px] transition-colors duration-300 pb-1"
style={{
color:
active === id ? "#c2339b" : "rgba(255,255,255,0.5)",
borderBottom:
active === id
? "1px solid #c2339b"
: "1px solid transparent",
}}
>
{label}
</button>
))}
</div>
{/* Mobile menu button */}
<button
className="sm:hidden bg-transparent border-none cursor-pointer"
style={{ color: "rgba(255,255,255,0.6)", fontSize: 20 }}
onClick={() => {
const menu = document.getElementById("mobile-menu");
if (menu) {
menu.style.display =
menu.style.display === "flex" ? "none" : "flex";
}
}}
>
</button>
{/* Mobile dropdown */}
<div
id="mobile-menu"
className="sm:hidden absolute top-[64px] left-0 right-0 flex-col py-4 gap-1"
style={{
display: "none",
background: "rgba(15,15,20,0.95)",
backdropFilter: "blur(16px)",
borderBottom: "1px solid rgba(255,255,255,0.06)",
}}
>
{NAV_ITEMS.map(({ label, id }) => (
<button
key={id}
onClick={() => {
handleNav(id);
const menu = document.getElementById("mobile-menu");
if (menu) menu.style.display = "none";
}}
className="bg-transparent border-none cursor-pointer text-[14px] font-medium py-3 px-8 text-left transition-colors"
style={{
color:
active === id ? "#c2339b" : "rgba(255,255,255,0.5)",
}}
>
{label}
</button>
))}
</div>
</nav>
);
}

113
components/skills.tsx Normal file
View file

@ -0,0 +1,113 @@
"use client";
import { FadeIn } from "./fade-in";
import { CV_DATA } from "@/lib/cv-data";
function SkillPill({ label }: { label: string }) {
return (
<span
className="inline-block px-4 py-1.5 rounded-full text-[13px] font-medium mr-1.5 mb-1.5 transition-all duration-200 hover:bg-[rgba(194,51,155,0.18)] hover:text-[#e8e4df]"
style={{
background: "rgba(194,51,155,0.08)",
border: "1px solid rgba(194,51,155,0.15)",
color: "rgba(255,255,255,0.65)",
}}
>
{label}
</span>
);
}
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: "Talen", items: CV_DATA.skills.spoken },
];
export function SkillsSection() {
return (
<section
id="skills"
className="mx-auto max-w-[900px]"
style={{ padding: "100px clamp(20px, 6vw, 80px)" }}
>
<FadeIn>
<p
className="text-[13px] font-semibold uppercase mb-3"
style={{ color: "#c2339b", letterSpacing: 3 }}
>
Technologie
</p>
<h2
className="font-serif font-normal mb-12"
style={{
fontSize: "clamp(32px, 4vw, 48px)",
color: "#e8e4df",
letterSpacing: -1,
}}
>
Skills & Tools
</h2>
</FadeIn>
<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}>
<div
className="rounded-2xl p-7"
style={{
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<h3
className="text-[12px] font-semibold uppercase mb-4"
style={{
color: "rgba(255,255,255,0.4)",
letterSpacing: 2,
}}
>
{group.title}
</h3>
<div>
{group.items.map((s) => (
<SkillPill key={s} label={s} />
))}
</div>
</div>
</FadeIn>
))}
</div>
<FadeIn delay={0.3}>
<div className="mt-10">
<h3
className="text-[12px] font-semibold uppercase mb-4"
style={{
color: "rgba(255,255,255,0.4)",
letterSpacing: 2,
}}
>
Interesses
</h3>
<div className="flex gap-2 flex-wrap">
{CV_DATA.interests.map((interest) => (
<span
key={interest}
className="px-5 py-2 rounded-full text-[14px]"
style={{
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.06)",
color: "rgba(255,255,255,0.55)",
}}
>
{interest}
</span>
))}
</div>
</div>
</FadeIn>
</section>
);
}

120
lib/cv-data.ts Normal file
View file

@ -0,0 +1,120 @@
export const CV_DATA = {
name: "Janpeter Visser",
tagline: "Software Engineer · Full Stack Developer",
intro:
"Sinds mijn afstuderen werk ik met enthousiasme en nieuwsgierigheid in de IT. Ik ben een allround developer die graag nieuwe technologieën onderzoekt, evalueert en inzet in nieuwe projecten. De afgelopen jaren heb ik meerdere complexe projecten bij de hand gehad — ik vind het leuk om mij daarin vast te bijten en in een klein team naar oplossingen te zoeken.",
contact: {
email: "janpetervisser2@gmail.com",
location: "Rotterdam",
},
experience: [
{
company: "QPIT BV",
location: "Rotterdam",
period: "januari 2004 februari 2024",
role: "Software Engineer",
description:
"Bij QPIT heb ik meerdere functies gehad in een multidisciplinaire rol. Ik heb bijgedragen aan het ontwikkelen van het proprietary softwaresysteem Quism (Servicedesk software) en het inrichten hiervan voor klanten.",
highlights: [
{
title: "Software Engineer Quism",
text: "Webapplicatie voor service management (ITIL) cross-browser compatible gemaakt. Tools ontwikkeld waarmee ASP, JavaScript en HTML geanalyseerd en aangepast kon worden. Ondersteunende tools gebouwd voor e-mail ticket integratie via IMAP, MAPI en POP3.",
},
{
title: "Research & Development",
text: "Onderzoek naar nieuwe ontwikkelomgevingen voor mobile devices. Dit leidde tot het ontwikkelen van full stack PWA applications met Angular, TypeScript en C#.",
},
{
title: "Product Development",
text: "Diverse klantenportalen gemaakt voor mobile devices. Webapplicatie voor scrum/agile projectontwikkeling met webbased Agile dashboards. Voor het Havenbedrijf een applicatie overgebracht naar een fullstack PWA.",
},
],
},
{
company: "TNO Bouw",
location: "Delft",
period: "januari 2001 januari 2003",
role: "Wetenschappelijk Medewerker",
description:
"Op de afdeling Bouwinformatica gewerkt als wetenschappelijk medewerker, op het raakvlak van universiteiten (bouwkunde & informatica) en de bouwnijverheid.",
highlights: [
{
title: "HSL-traject",
text: "Configuratiemanagementsysteem gedefinieerd en geïmplementeerd voor het ontwerpen van het HSL-traject. Formalisering vastgelegd in UML.",
},
],
},
{
company: "Logica",
location: "Woerden",
period: "oktober 1998 januari 2001",
role: "Software Engineer",
description:
"Gedetacheerd bij de Belastingdienst in Apeldoorn. Gewerkt aan het systeem voor digitale belastingaangifte. Medeverantwoordelijk voor testen, acceptatie en distributie van software.",
highlights: [
{
title: "Productspecialist",
text: "Verantwoordelijk voor integratie van nieuwe producten binnen de Belastingdienst. Coördinerende rol tussen verschillende afdelingen.",
},
],
},
{
company: "Europe Transport Automation",
location: "Rotterdam",
period: "november 1994 oktober 1998",
role: "Systeemontwerper en -ontwikkelaar",
description:
"Verantwoordelijk voor de bouw van systemen voor de logistieke sector. Primaire bedrijfsprocessen gemodelleerd met grafische interfaces voor efficiënt vrachttransport, inclusief facturatiesystemen.",
highlights: [],
},
],
education: {
university: "TU Delft",
degree: "Technische Informatica",
specialization:
"Vakgroep Software Engineering, Programmeertalen en Programmeertaal Compilers",
period: "1987 1994",
secondary:
"VWO Atheneum-B, OSG Ring van Putten te Spijkenisse (19811987)",
},
skills: {
languages: [
"C",
"C++",
"C#",
"JavaScript",
"TypeScript",
"HTML",
"CSS",
"Visual Basic",
"Assembler",
"LISP",
"Prolog",
"Perl",
"Delphi",
],
frameworks: [
"Angular",
"Angular Material",
"Nx",
"ASP.NET",
"ASP.NET Core",
],
databases: [
"Microsoft SQL Server",
"Oracle",
"MySQL",
"MariaDB",
"Microsoft Access",
],
tools: ["Git", "Visual Sourcesafe"],
spoken: ["Nederlands", "Engels", "Duits"],
},
interests: [
"Reizen door Azië",
"Yoga",
"Vrijwilligerswerk digitale ondersteuning: Centrale Bibliotheek Rotterdam & Wijkcentrum Schiedam Oost",
],
} as const;
export type Experience = (typeof CV_DATA.experience)[number];

5
next.config.ts Normal file
View file

@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;

6285
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "jp-visser",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"tailwindcss": "^3.4.16",
"postcss": "^8.4.49",
"autoprefixer": "^10.4.20",
"eslint": "^9.16.0",
"eslint-config-next": "^15.1.0"
}
}

9
postcss.config.mjs Normal file
View file

@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

BIN
public/images/portrait.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

25
tailwind.config.ts Normal file
View file

@ -0,0 +1,25 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
accent: "#c2339b",
bg: "#0f0f14",
surface: "rgba(255,255,255,0.03)",
border: "rgba(255,255,255,0.06)",
},
fontFamily: {
serif: ["Instrument Serif", "Georgia", "serif"],
sans: ["DM Sans", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
export default config;

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}