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

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>
);
}