/* 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 (
);
}
/* ---------------- 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) => (
↳
))}
);
}
/* ---------------- about ---------------- */
function About({ about }) {
return (
{about.index}
{about.approachHeading}
{about.approach.map((step, i) => (
- {String(i + 1).padStart(2, "0")}{step}
))}
{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
;
}
/* ---------------- 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 &&

}
Over dit project
{p.description}
Stack
{(p.stack || []).map((t) => {t})}
);
}
/* ---------------- 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.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 (
);
}
/* ---------------- 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 (
<>
{selected && setSelected(null)} />}
setContent(withDefaults(data))} />
>
);
}
ReactDOM.createRoot(document.getElementById("root")).render();