/* global React, ReactDOM, DEFAULT_CONTENT, RT, CMSPanel */ const { useState, useEffect, useRef, useCallback } = React; /* ---------------- scroll reveal (rAF based — robust everywhere) ---------------- */ function useReveal(dep) { useEffect(() => { const els = [...document.querySelectorAll(".reveal")]; const show = (el) => el.classList.add("in"); let raf = 0; const check = () => { raf = 0; const vh = window.innerHeight || document.documentElement.clientHeight; for (const el of els) { if (el.classList.contains("in")) continue; const r = el.getBoundingClientRect(); if (r.top < vh * 0.92 && r.bottom > 0) show(el); } }; const onScroll = () => { if (!raf) raf = requestAnimationFrame(check); }; check(); window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll); const failsafe = setTimeout(() => { document.body.classList.add("reveal-done"); els.forEach(show); }, 1800); return () => { window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); clearTimeout(failsafe); }; }, [dep]); } /* ---------------- nav ---------------- */ function Nav({ brand, nav }) { const [scrolled, setScrolled] = useState(false); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 24); onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); return (
{brand.initials}{brand.suffix}
); } /* ---------------- parallax hook ---------------- */ function useParallax(items) { useEffect(() => { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; let raf = 0; const update = () => { raf = 0; const sy = window.scrollY; for (const { ref, speed } of items) { if (!ref.current) continue; ref.current.style.transform = `translateY(${(sy * (1 - speed)).toFixed(2)}px)`; } }; const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); }; update(); window.addEventListener("scroll", onScroll, { passive: true }); return () => { window.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); }; }, []); } /* ---------------- hero ---------------- */ function Hero({ hero, projectCount, name }) { const asideRef = useRef(null); useParallax([{ ref: asideRef, speed: 0.88 }]); return (
{hero.masthead.map((m, i) => )} {projectCount} projecten
{hero.status}

{hero.titleLine1} {hero.titleLine2}

{hero.facts.map((f, i) => (
))}
{hero.ctaPrimary} {hero.ctaGhost}
); } /* ---------------- about ---------------- */ function About({ about }) { return (
{about.index}

{about.approachHeading}
    {about.approach.map((step, i) => (
  1. {String(i + 1).padStart(2, "0")}{step}
  2. ))}
{about.sign}
{about.paragraphs.map((p, i) =>

)}
{about.facts.map(([k, v]) => (
{k}{v}
))}
); } /* ---------------- site screenshot ---------------- */ function SiteScreenshot({ url }) { const [src, setSrc] = useState(null); const [failed, setFailed] = useState(false); useEffect(() => { setSrc(null); setFailed(false); fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}&screenshot=true&meta=false`) .then((r) => r.json()) .then((data) => { const imgUrl = data?.data?.screenshot?.url; if (imgUrl) setSrc(imgUrl); else setFailed(true); }) .catch(() => setFailed(true)); }, [url]); if (failed) return (
{new URL(url).hostname}
); if (!src) return
; return Website preview; } /* ---------------- project modal ---------------- */ function ProjectModal({ project: p, onClose }) { useEffect(() => { document.body.style.overflow = "hidden"; const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = ""; window.removeEventListener("keydown", onKey); }; }, [onClose]); let hostname = null; try { hostname = p.url ? new URL(p.url).hostname : null; } catch (e) { hostname = p.url; } return (
e.stopPropagation()}>
{p.n}

{p.title}

{p.year}
{p.image && {p.title}}
Over dit project

{p.description}

Rol

{p.role}

Stack
{(p.stack || []).map((t) => {t})}
Links
{p.github && ( GitHub )} {p.url && ( Website )} {!p.github && !p.url && Binnenkort beschikbaar}
{p.url && (
{hostname}
)}
); } /* ---------------- projecten: pinned horizontale scroll ---------------- */ // De sectie 'pint' op het scherm zodra je hem bereikt; verticaal scrollen // beweegt dan horizontaal door de projecten. Pas als alle projecten voorbij // zijn gekomen, scrollt de pagina verder naar beneden. Werkt met native scroll // (geen preventDefault), dus ook soepel op touch. function Work({ projects, work, onSelect }) { const spacerRef = useRef(null); const viewportRef = useRef(null); const trackRef = useRef(null); const distRef = useRef(0); const [dist, setDist] = useState(0); const [progress, setProgress] = useState(0); const measure = useCallback(() => { const track = trackRef.current, vp = viewportRef.current; if (!track || !vp) return; const H = Math.max(0, track.scrollWidth - vp.clientWidth); distRef.current = H; setDist(H); }, []); // Hermeet bij resize, contentwijziging en nadat beelden/fonts geladen zijn. useEffect(() => { measure(); const t1 = setTimeout(measure, 250); const t2 = setTimeout(measure, 1200); window.addEventListener("resize", measure); if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure); return () => { clearTimeout(t1); clearTimeout(t2); window.removeEventListener("resize", measure); }; }, [measure, projects]); // Verticale scrollpositie binnen de 'spacer' → horizontale verplaatsing. useEffect(() => { const onScroll = () => { const spacer = spacerRef.current, track = trackRef.current; if (!spacer || !track) return; const H = distRef.current; const scrolled = Math.min(Math.max(-spacer.getBoundingClientRect().top, 0), H); const p = H > 0 ? scrolled / H : 0; track.style.transform = `translate3d(${(-p * H).toFixed(1)}px,0,0)`; setProgress(p); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [dist]); // Pijlknoppen: scroll de pagina één kaart vooruit/achteruit (= horizontaal). const step = (dir) => { const track = trackRef.current; const card = track && track.querySelector(".card"); const gap = track ? parseFloat(getComputedStyle(track).columnGap || "24") : 24; const amt = card ? card.getBoundingClientRect().width + gap : window.innerHeight * 0.8; window.scrollBy({ top: dir * amt, behavior: "smooth" }); }; const pinned = dist > 0; return (
{work.index}

{work.title}

{projects.length} projecten
{projects.map((p) => (
onSelect(p)}>
{p.image && {p.title}} {p.n} {p.featured && ★ Featured}

{p.title}

{p.year}

{p.blurb}

{(p.tags || []).map((t) => {t})}
))}
); } /* ---------------- skills ---------------- */ function Skills({ skills, meta }) { return (
{meta.index}

{meta.title}

{skills.map((c, i) => (

{c.heading}

    {(c.items || []).map(([name]) =>
  • {name}
  • )}
))}
); } /* ---------------- contact ---------------- */ function useLocalClock(tz) { const [now, setNow] = useState(""); useEffect(() => { const fmt = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit", timeZone: tz }); const tick = () => setNow(fmt.format(new Date())); tick(); const id = setInterval(tick, 1000 * 20); return () => clearInterval(id); }, [tz]); return now; } function Contact({ contact }) { const time = useLocalClock("Europe/Amsterdam"); const mailHref = contact.email.startsWith("mailto:") ? contact.email : "mailto:" + contact.email; return (
{contact.index} {time || "—:—"} in {contact.clockCity}

{contact.intro}

{contact.email.replace(/^mailto:/, "")}
{contact.meta.map(([k, v]) => (
{k}{v}
))}

{contact.note}

{contact.socials.map(([label, href]) => ( {label} ))}
); } /* ---------------- content merge ---------------- */ // Vul ontbrekende velden aan met de defaults zodat de UI nooit op undefined stuit. function withDefaults(data) { const d = DEFAULT_CONTENT; const c = data && typeof data === "object" ? data : {}; return { brand: { ...d.brand, ...(c.brand || {}) }, nav: { ...d.nav, ...(c.nav || {}) }, hero: { ...d.hero, ...(c.hero || {}) }, about: { ...d.about, ...(c.about || {}) }, work: { ...d.work, ...(c.work || {}) }, skillsMeta: { ...d.skillsMeta, ...(c.skillsMeta || {}) }, contact: { ...d.contact, ...(c.contact || {}) }, projects: Array.isArray(c.projects) ? c.projects : d.projects, skills: Array.isArray(c.skills) ? c.skills : d.skills, }; } function App() { const [content, setContent] = useState(() => withDefaults(DEFAULT_CONTENT)); const [selected, setSelected] = useState(null); const [loaded, setLoaded] = useState(false); useReveal(loaded); // Content uit de database laden (valt terug op defaults als de API niet bereikbaar is). useEffect(() => { fetch("api/content.php", { credentials: "same-origin" }) .then((r) => r.json()) .then((data) => { if (data && !data.error) setContent(withDefaults(data)); }) .catch(() => {}) .finally(() => setLoaded(true)); }, []); // Projecten verrijken met weergavenummer. const projects = content.projects.map((p, i) => ({ ...p, n: String(i + 1).padStart(2, "0") })); return ( <>