// @ds-adherence-ignore -- zelfstandige CMS-tool (eigen chrome/kleuren by design) /* global React */ /* cms.jsx — Content-beheer voor de portfolio. - DEFAULT_CONTENT : fallback-inhoud zolang de API laadt (identiek aan api/defaults.php). - RT : rendert *cursief*, **vet** en ~grijs~ als opmaak. - CMSPanel : beveiligd bewerkpaneel (login → secties → opslaan in MySQL via PHP). Alles wordt naar window geëxporteerd zodat app.jsx het kan gebruiken. */ const DEFAULT_CONTENT = { brand: { initials: "JR", suffix: "/dev", name: "Jelle Romijn", role: "Web Developer" }, nav: { work: "Work", about: "About", skills: "Skills", contact: "Contact ↗" }, hero: { portrait: null, masthead: ["**Portfolio** — '26", "Software Developer in opleiding", "Grafisch Lyceum Utrecht"], status: "Open to new projects", titleLine1: "Jelle", titleLine2: "Romijn", lead: "**Software Developer** in opleiding aan het Grafisch Lyceum Utrecht. Ik bouw *werkende producten* voor echte opdrachtgevers, geen oefenprojecten voor school.", facts: ["**Full stack — van database tot productie**", "Grafisch Lyceum Utrecht", "PHP · Node.js · MySQL · Stripe · PWA"], ctaPrimary: "Bekijk werk", ctaGhost: "Neem contact op", currently: "**Nu:** ik werk aan de webshop voor Via Mercato en zet ik mijn projecten netjes in dit portfolio.", }, about: { index: "01 — About", big: "Ik bouw producten die *echt werken,* ~van de database tot de livegang.~", approachHeading: "Zo werk ik", approach: [ "Eerst het probleem snappen, dan pas bouwen.", "Elke keuze kan ik uitleggen: welk probleem, welke techniek en waarom.", "Ik maak het af en zet het live.", ], sign: "— Jelle", paragraphs: [ "Ik ben **Jelle**, Software Developer in opleiding aan het Grafisch Lyceum Utrecht. Ik bouw werkende producten voor **echte opdrachtgevers**, geen oefenprojecten voor school.", "Ik werk full stack: de frontend, de backend in PHP, MySQL en Node.js, en ik zet alles live op Hostinger met HTTPS. Betalingen regel ik met **Stripe, iDEAL en Wero**. Naast de techniek vind ik het belangrijk om uit te leggen waarom ik bepaalde keuzes maak.", ], facts: [ ["Opleiding", "Software Developer — Grafisch Lyceum Utrecht"], ["Stack", "Full stack — front-end, back-end, database"], ["Deployments", "Live op Hostinger via HTTPS"], ["Beschikbaar", "Voor echte opdrachten"], ], }, work: { index: "02 — Selected work", title: "Projecten" }, skillsMeta: { index: "03 — Capabilities", title: "Tech & tooling" }, contact: { index: "04 — Contact", clockCity: "Rotterdam", intro: "Een mailtje is het snelst:", email: "hello@jelleromijn.dev", meta: [ ["Beschikbaar", "Vanaf Q3 2026"], ["Reactietijd", "Meestal < 24 uur"], ["Werkwijze", "Remote · af en toe op locatie"], ], note: "Heb je een idee en wil je even sparren? Stuur gerust een bericht, ook als het nog maar een ruwe schets is.", socials: [ ["Email", "mailto:hello@jelleromijn.dev"], ["GitHub", "#top"], ["LinkedIn", "#top"], ["Read.cv", "#top"], ], footerLeft: "© 2026 Jelle Romijn", footerRight: "Ontworpen & gebouwd in Rotterdam", }, projects: [ { title: "Via Mercato", year: "2025", blurb: "Webshop voor importeur van Italiaanse olijfolie — database, productfoto's, logo & teksten", tags: ["PHP", "MySQL", "Stripe", "iDEAL", "Wero"], description: "Webshop voor Via Mercato, een importeur van Italiaanse olijfolie. Ik bouwde de database, bewerkte de productfoto's, ontwierp het logo en schreef de teksten. Betalingen lopen via Stripe met iDEAL en Wero.", role: "Database, productfoto's, logo, teksten, frontend & backend", stack: ["PHP", "MySQL", "Stripe", "iDEAL", "Wero", "Adobe tools"], github: null, url: "https://viamercato.nl/", image: null, featured: true, }, { title: "❤️U Festival-app", year: "2025", blurb: "PWA met Service Workers, push notificaties, offline werking en QR-code installatie", tags: ["PWA", "Next.js", "Service Workers", "Push API"], description: "PWA voor het ❤️U festival, een tweedaags studentenevenement op Grasweide Strijkviertel in Utrecht. De app werkt offline, stuurt pushmeldingen en installeer je via een QR-code. Bezoekers schakelen tussen Nederlands en Engels.", role: "Full stack — frontend, backend, kaart, push-integratie", stack: ["Next.js", "App Router", "next-pwa", "JavaScript", "CSS", "PHP", "MySQL", "Notifications API", "Leaflet.js", "Hostinger"], github: null, url: "https://festival.jelleromijn.com/", image: null, featured: false, }, { title: "Pesten (multiplayer)", year: "2024", blurb: "Real-time spel via WebSockets — telefoons als spelershand, laptop als gedeeld scherm", tags: ["WebSockets", "Node.js", "JavaScript", "Real-time"], description: "Multiplayer kaartspel Pesten. Spelers gebruiken hun telefoon als controller en de laptop als speeltafel. De verbinding loopt realtime.", role: "Full stack — game logic, real-time verbinding", stack: ["Node.js", "WebSockets", "JavaScript"], github: null, url: "https://u240903.gluwebsite.nl/remotecontroller/", image: null, featured: false, }, { title: "HungryHerbivore", year: "2024", blurb: "Kiosk-bestelsysteem met touchbediening voor grote schermen", tags: ["PHP", "JavaScript", "Kiosk", "Touch UI"], description: "Kiosk-bestelsysteem met touchbediening. Klanten stellen hun bestelling zelf samen op een groot scherm.", role: "Full stack — frontend, backend, bestelflow", stack: ["PHP", "JavaScript"], github: null, url: "https://u240903.gluwebsite.nl/kiosk", image: null, featured: false, }, ], skills: [ { heading: "Frontend", items: [["HTML / CSS", "expert"], ["JavaScript", "expert"], ["React", "gevorderd"], ["PWA / Service Workers", "gevorderd"], ["WebSockets", "gevorderd"]] }, { heading: "Backend & Data", items: [["PHP", "gevorderd"], ["Node.js", "gevorderd"], ["MySQL", "gevorderd"], ["REST APIs", "gevorderd"], ["Hostinger / HTTPS", "gevorderd"]] }, { heading: "Integraties & Tools", items: [["Stripe + iDEAL + Wero", "gevorderd"], ["Git / GitHub", "gevorderd"], ["Figma", "gevorderd"], ["Push Notifications API", "gevorderd"], ["QR-code flows", "gevorderd"]] }, ], }; const __clone = (o) => (typeof structuredClone === "function" ? structuredClone(o) : JSON.parse(JSON.stringify(o))); /* ---------------- rich text: *cursief* **vet** ~grijs~ ---------------- */ function RT({ text }) { if (text == null) return null; const str = String(text); const out = []; const re = /(\*\*[^*]+\*\*|\*[^*]+\*|~[^~]+~)/g; let last = 0, m, k = 0; while ((m = re.exec(str)) !== null) { if (m.index > last) out.push(str.slice(last, m.index)); const tok = m[0]; if (tok.startsWith("**")) out.push({tok.slice(2, -2)}); else if (tok.startsWith("*")) out.push({tok.slice(1, -1)}); else out.push({tok.slice(1, -1)}); last = m.index + tok.length; } if (last < str.length) out.push(str.slice(last)); return <>{out}>; } /* ============================ CMS PANEL ============================ */ const { useState, useEffect, useRef, useCallback } = React; const CMS_STYLE = ` .cms-scrim{position:fixed;inset:0;z-index:2147483644;background:rgba(10,9,12,.32); -webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);animation:cmsfade .2s ease} @keyframes cmsfade{from{opacity:0}to{opacity:1}} .cms{position:fixed;top:0;right:0;bottom:0;z-index:2147483645;width:min(460px,100vw); display:flex;flex-direction:column;background:#1b1a20;color:#ecebef; font:13px/1.45 ui-sans-serif,system-ui,-apple-system,sans-serif; box-shadow:-16px 0 50px rgba(0,0,0,.42);animation:cmsslide .26s cubic-bezier(.22,.7,.3,1)} @keyframes cmsslide{from{transform:translateX(100%)}to{transform:translateX(0)}} .cms-hd{display:flex;align-items:center;justify-content:space-between;gap:10px; padding:16px 18px;border-bottom:1px solid rgba(255,255,255,.08)} .cms-hd h2{margin:0;font-size:15px;font-weight:700;letter-spacing:.01em} .cms-hd p{margin:2px 0 0;font-size:11.5px;color:#9a98a4} .cms-x{appearance:none;border:0;background:rgba(255,255,255,.07);color:#cfcdd6; width:30px;height:30px;border-radius:8px;cursor:pointer;font-size:15px} .cms-x:hover{background:rgba(255,255,255,.14)} .cms-tabs{display:flex;gap:4px;padding:10px 12px;overflow-x:auto; border-bottom:1px solid rgba(255,255,255,.08);scrollbar-width:none} .cms-tabs::-webkit-scrollbar{display:none} .cms-tab{appearance:none;border:0;background:transparent;color:#a6a4b0; padding:7px 12px;border-radius:8px;cursor:pointer;font:600 12px/1 inherit;white-space:nowrap} .cms-tab:hover{background:rgba(255,255,255,.06);color:#ecebef} .cms-tab[data-on="1"]{background:#ecebef;color:#1b1a20} .cms-body{flex:1;min-height:0;overflow-y:auto;padding:16px 18px 28px; display:flex;flex-direction:column;gap:14px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.18) transparent} .cms-body::-webkit-scrollbar{width:9px} .cms-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.16);border-radius:5px;border:2px solid transparent;background-clip:content-box} .cms-fld{display:flex;flex-direction:column;gap:5px} .cms-fld>label{font-size:11px;font-weight:600;letter-spacing:.02em;color:#b9b7c2;text-transform:uppercase} .cms-fld .hint{font-size:10.5px;color:#827f8c;text-transform:none;letter-spacing:0;font-weight:500} .cms-input,.cms-area{width:100%;box-sizing:border-box;background:#26242d;color:#f2f1f5; border:1px solid rgba(255,255,255,.1);border-radius:9px;padding:9px 11px;font:inherit;outline:none} .cms-input:focus,.cms-area:focus{border-color:#7b78ff;background:#2b2934} .cms-area{resize:vertical;min-height:64px;line-height:1.5} .cms-card{background:#211f28;border:1px solid rgba(255,255,255,.08);border-radius:13px; padding:14px;display:flex;flex-direction:column;gap:11px} .cms-card-hd{display:flex;align-items:center;justify-content:space-between;gap:8px} .cms-card-hd b{font-size:12.5px} .cms-mini{display:flex;gap:5px} .cms-iconbtn{appearance:none;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.05); color:#cfcdd6;width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:13px;line-height:1} .cms-iconbtn:hover{background:rgba(255,255,255,.12)} .cms-iconbtn.danger:hover{background:#c4413a;border-color:#c4413a;color:#fff} .cms-iconbtn:disabled{opacity:.35;cursor:default} .cms-row{display:flex;gap:8px;align-items:center} .cms-row .cms-input{flex:1;min-width:0} .cms-grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px} .cms-add{appearance:none;border:1px dashed rgba(255,255,255,.22);background:transparent; color:#bcbac6;padding:9px;border-radius:9px;cursor:pointer;font:600 12px/1 inherit} .cms-add:hover{background:rgba(255,255,255,.05);color:#fff} .cms-toggle{display:flex;align-items:center;gap:9px;cursor:pointer;user-select:none} .cms-toggle .sw{position:relative;width:36px;height:20px;border-radius:999px;background:rgba(255,255,255,.16);transition:background .15s;flex:none} .cms-toggle .sw i{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;transition:transform .15s} .cms-toggle[data-on="1"] .sw{background:#41c971} .cms-toggle[data-on="1"] .sw i{transform:translateX(16px)} .cms-toggle span{font-size:12.5px;color:#cfcdd6} .cms-img{display:flex;gap:12px;align-items:center} .cms-img .prev{width:84px;height:84px;border-radius:10px;object-fit:cover;background:#15141a; border:1px solid rgba(255,255,255,.1);flex:none} .cms-img .prev.empty{display:grid;place-items:center;color:#6f6c79;font-size:11px;text-align:center;padding:6px} .cms-img .acts{display:flex;flex-direction:column;gap:6px} .cms-btn{appearance:none;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06); color:#ecebef;padding:7px 12px;border-radius:8px;cursor:pointer;font:600 12px/1 inherit;text-align:center} .cms-btn:hover{background:rgba(255,255,255,.13)} .cms-btn.link{border:none;background:none;color:#8e8bff;padding:4px 0} .cms-ft{display:flex;align-items:center;gap:8px;padding:12px 16px; border-top:1px solid rgba(255,255,255,.08);background:#19181e} .cms-save{flex:1;appearance:none;border:0;border-radius:10px;cursor:pointer; padding:11px;font:700 13px/1 inherit;color:#0c0b10;background:#c8ff5a} .cms-save:hover{background:#d4ff7a} .cms-save:disabled{opacity:.55;cursor:default} .cms-ghost{appearance:none;border:1px solid rgba(255,255,255,.16);background:transparent; color:#cfcdd6;border-radius:10px;padding:11px 14px;cursor:pointer;font:600 12px/1 inherit} .cms-ghost:hover{background:rgba(255,255,255,.08)} .cms-status{padding:0 16px 12px;font-size:11.5px;color:#9a98a4;background:#19181e} .cms-status[data-kind="ok"]{color:#7ee08f} .cms-status[data-kind="err"]{color:#ff8a82} .cms-login{flex:1;display:flex;flex-direction:column;justify-content:center;gap:14px;padding:0 30px} .cms-login h3{margin:0;font-size:17px} .cms-login p{margin:0;color:#9a98a4;font-size:12.5px} .cms-sec-note{font-size:11px;color:#827f8c;background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.07);border-radius:9px;padding:9px 11px} .cms-sec-note code{color:#c8ff5a;font-family:ui-monospace,monospace} `; const API = { status: () => fetch("api/auth.php", { credentials: "same-origin" }).then((r) => r.json()), login: (password) => fetch("api/auth.php", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "login", password }), }).then((r) => r.json()), logout: () => fetch("api/auth.php", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "logout" }), }).then((r) => r.json()), save: (content) => fetch("api/content.php", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(content), }), load: () => fetch("api/content.php", { credentials: "same-origin" }).then((r) => r.json()), upload: (file) => { const fd = new FormData(); fd.append("image", file); return fetch("api/upload.php", { method: "POST", credentials: "same-origin", body: fd }).then((r) => r.json()); }, }; /* ---- kleine veld-helpers ---- */ function Field({ label, hint, children }) { return (