Read-only mirror of the client's existing system as it stood before our work began. Taken from matthijsexpand@149.210.159.152 (TransIP VPS, Lelystad NL) with the client's written permission. Contents: - home-weddingcaketopper/ Next.js app, proxy.py, supabase schema - var-www/ static webroots for all three domains - etc-nginx/ vhost configs (previously unversioned, server-only) Excluded: node_modules (704M, regenerable), .next (17M, regenerable), and env file contents (present on local disk, gitignored — see README). Not our IP. Not covered by the proposal's assignment clause.
563 lines
36 KiB
HTML
563 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="nl">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
<title>Birthday Cake Topper — Ontwerptool</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,600;0,700;1,600&family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
:root{
|
|
--pink:#E8477A;--pink-dark:#C9346A;--pink-light:#F9D0E0;--pink-pale:#FFF0F5;
|
|
--gold:#C9A96E;--dark:#1A0A10;--text:#3D1A26;--muted:#9B7A85;
|
|
--white:#fff;--bg:#FFFBFD;--soft:#FAF5F7;--ok:#26A269;--warn:#D99A21;
|
|
}
|
|
body{font-family:Inter,sans-serif;background:var(--bg);color:var(--text);overflow-x:hidden}
|
|
.header{text-align:center;padding:1.2rem 1rem;background:#fff;border-bottom:1px solid var(--pink-light)}
|
|
.logo{height:96px;object-fit:contain}
|
|
.hero{background:linear-gradient(160deg,#FFF0F5 0%,#FFFBF0 58%,#FFF0F5 100%);padding:2.1rem 1rem 1.3rem;text-align:center}
|
|
.tag{display:inline-block;background:var(--pink);color:#fff;font-size:10px;font-weight:900;letter-spacing:2.3px;text-transform:uppercase;padding:.38rem 1rem;border-radius:999px;margin-bottom:1rem}
|
|
h1{font-family:"Playfair Display",Georgia,serif;font-size:clamp(30px,5vw,48px);line-height:1.08;color:var(--dark);margin-bottom:.6rem}
|
|
h1 em{color:var(--pink)}
|
|
.hero p{color:var(--muted);font-size:15px;line-height:1.65;max-width:650px;margin:auto}
|
|
.divider{width:44px;height:2px;background:linear-gradient(90deg,var(--gold),var(--pink));border-radius:2px;margin:1.1rem auto 0}
|
|
.shell{max-width:1200px;margin:auto;padding:2rem 1rem 4rem}
|
|
.progress{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;padding:1rem;box-shadow:0 14px 40px rgba(232,71,122,.08);position:sticky;top:0;z-index:20}
|
|
.progress-top{display:flex;justify-content:space-between;gap:1rem;align-items:center;margin-bottom:.8rem}
|
|
.progress-title{font-weight:900;color:var(--dark)}
|
|
.progress-step{font-size:12px;color:var(--muted)}
|
|
.bar{height:8px;background:var(--pink-pale);border-radius:999px;overflow:hidden}
|
|
.fill{height:100%;width:12.5%;background:linear-gradient(90deg,var(--pink),var(--gold));border-radius:999px;transition:.25s}
|
|
.tabs{display:grid;grid-template-columns:repeat(8,1fr);gap:6px;margin-top:1rem}
|
|
.tab{border:1px solid var(--pink-light);background:#fff;border-radius:14px;padding:.62rem .3rem;text-align:center;font-size:10px;font-weight:900;color:var(--muted)}
|
|
.tab.active{background:var(--pink);border-color:var(--pink);color:#fff;box-shadow:0 10px 24px rgba(232,71,122,.23)}
|
|
.tab.done{background:var(--pink-pale);color:var(--pink)}
|
|
.panel{display:none;margin-top:1.8rem}
|
|
.panel.active{display:block}
|
|
.panel-head{text-align:center;margin-bottom:1.4rem}
|
|
.label{font-size:10px;font-weight:900;letter-spacing:2.5px;text-transform:uppercase;color:var(--gold);margin-bottom:.35rem}
|
|
h2{font-family:"Playfair Display",Georgia,serif;font-size:clamp(26px,3.5vw,38px);color:var(--dark)}
|
|
.desc{font-size:14px;color:var(--muted);line-height:1.6;margin:.45rem auto 0;max-width:680px}
|
|
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}
|
|
.card{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;overflow:hidden;cursor:pointer;transition:.2s;box-shadow:0 8px 30px rgba(232,71,122,.06);position:relative}
|
|
.card:hover{transform:translateY(-3px);border-color:var(--pink);box-shadow:0 18px 42px rgba(232,71,122,.14)}
|
|
.card.selected{border:3px solid var(--pink);box-shadow:0 18px 44px rgba(232,71,122,.22)}
|
|
.imgbox{height:285px;background:linear-gradient(180deg,#fff,#FAF5F7);display:flex;align-items:flex-end;justify-content:center;padding:12px 12px 0}
|
|
.imgbox img{max-width:100%;max-height:270px;object-fit:contain;object-position:center bottom}
|
|
.card-body{padding:1rem;text-align:center}
|
|
.card h3{font-family:"Playfair Display",Georgia,serif;font-size:22px;color:var(--dark);margin-bottom:.25rem}
|
|
.card p{font-size:12px;color:var(--muted);line-height:1.45;min-height:34px}
|
|
.pill{display:inline-flex;margin-top:.8rem;background:var(--pink-pale);color:var(--pink);border:1px solid var(--pink-light);border-radius:999px;padding:.35rem .7rem;font-size:11px;font-weight:900}
|
|
.check{position:absolute;top:12px;right:12px;width:30px;height:30px;border-radius:50%;background:#fff;border:2px solid var(--pink-light);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:900}
|
|
.selected .check{background:var(--pink);border-color:var(--pink)}
|
|
.toolbar{display:flex;justify-content:space-between;align-items:center;gap:1rem;background:#fff;border:1.5px solid var(--pink-light);border-radius:18px;padding:.85rem 1rem;margin-bottom:1rem}
|
|
.backlink{border:0;background:transparent;color:var(--pink);font-weight:900;cursor:pointer;font-size:14px}
|
|
.design-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}
|
|
.design-img{height:320px;background:linear-gradient(180deg,#fff,#FAF5F7);display:flex;align-items:flex-end;justify-content:center;padding:12px 12px 0}
|
|
.design-img img{max-width:100%;max-height:305px;object-fit:contain;object-position:center bottom}
|
|
.design-name{padding:.85rem;text-align:center;font-weight:900;color:var(--dark);font-size:14px;border-top:1px solid var(--pink-light)}
|
|
.form{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;padding:1.5rem;box-shadow:0 12px 36px rgba(232,71,122,.08);max-width:850px;margin:auto}
|
|
.colors{display:grid;grid-template-columns:repeat(6,1fr);gap:12px}
|
|
.color{border:1.5px solid var(--pink-light);background:#fff;border-radius:18px;padding:.9rem;text-align:center;cursor:pointer;font-weight:900;color:var(--text);font-size:14px}
|
|
.dot{width:34px;height:34px;border-radius:50%;margin:0 auto .55rem;border:2px solid rgba(0,0,0,.08)}
|
|
.color.selected{border:3px solid var(--pink);background:var(--pink-pale)}
|
|
input,textarea{width:100%;border:1.5px solid var(--pink-light);border-radius:14px;background:#fff;padding:1rem;font:15px Inter,sans-serif;color:var(--text);outline:none}
|
|
input:focus,textarea:focus{border-color:var(--pink);box-shadow:0 0 0 4px rgba(232,71,122,.1)}
|
|
label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:1.4px;font-weight:900;color:var(--gold);margin-bottom:.45rem}
|
|
.two{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
|
|
.upload{background:var(--pink-pale);border:2px dashed var(--pink);border-radius:22px;padding:2rem;text-align:center;position:relative}
|
|
.upload input{position:absolute;inset:0;opacity:0;cursor:pointer}
|
|
.upload-icon{font-size:42px;margin-bottom:.7rem}
|
|
.upload h3{font-family:"Playfair Display",Georgia,serif;color:var(--dark);font-size:24px}
|
|
.upload p{color:var(--muted);font-size:13px;line-height:1.6;margin:.5rem auto 0;max-width:540px}
|
|
.note{font-size:12px;color:var(--muted);margin-top:.85rem;line-height:1.5}
|
|
.summary{display:grid;grid-template-columns:330px 1fr;gap:1.4rem;align-items:start}
|
|
.summary-img{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;min-height:380px;display:flex;align-items:flex-end;justify-content:center;padding:16px}
|
|
.summary-img img{max-width:100%;max-height:350px;object-fit:contain}
|
|
.summary-list{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;padding:1.4rem}
|
|
.row{display:flex;justify-content:space-between;gap:1rem;padding:.85rem 0;border-bottom:1px solid var(--pink-light)}
|
|
.row:last-child{border-bottom:0}
|
|
.row span:first-child{font-size:12px;font-weight:900;color:var(--gold);text-transform:uppercase;letter-spacing:1.2px}
|
|
.row span:last-child{text-align:right;font-weight:800;color:var(--dark)}
|
|
.ai-card{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;padding:1.4rem;margin-top:1.25rem;box-shadow:0 12px 36px rgba(232,71,122,.08)}
|
|
.ai-card h3{font-family:"Playfair Display",Georgia,serif;color:var(--dark);font-size:25px;margin-bottom:.45rem}
|
|
.ai-card p{font-size:13px;color:var(--muted);line-height:1.6;margin-bottom:1rem}
|
|
.ai-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
|
.status{font-size:13px;font-weight:800;color:var(--muted)}
|
|
.result{margin-top:1rem;background:var(--soft);border:1px solid var(--pink-light);border-radius:18px;min-height:220px;display:none;align-items:center;justify-content:center;padding:22px;text-align:center;overflow:hidden}
|
|
.result img{max-width:100%;max-height:760px;object-fit:contain;border-radius:12px;display:block;margin:auto}
|
|
/* Master goedkeuring */
|
|
.approve-box{background:#fff;border:2px solid var(--ok);border-radius:24px;padding:1.4rem;margin-top:1rem}
|
|
.approve-box h3{font-family:"Playfair Display",Georgia,serif;color:var(--ok);font-size:20px;margin-bottom:.4rem}
|
|
.approve-box p{font-size:13px;color:var(--muted);line-height:1.6;margin-bottom:1rem}
|
|
.approve-actions{display:flex;gap:10px;flex-wrap:wrap}
|
|
.btn-approve{background:var(--ok);color:#fff;border:0;border-radius:999px;padding:.85rem 1.6rem;font-weight:900;font-size:14px;cursor:pointer}
|
|
.btn-retry{background:#fff;color:var(--muted);border:1.5px solid var(--pink-light);border-radius:999px;padding:.85rem 1.6rem;font-weight:900;font-size:14px;cursor:pointer}
|
|
/* Upload foto stap 7 */
|
|
.foto-block{background:var(--pink-pale);border:2px dashed var(--pink);border-radius:22px;padding:2rem;text-align:center;position:relative;max-width:560px;margin:1rem auto}
|
|
.foto-block input{position:absolute;inset:0;opacity:0;cursor:pointer}
|
|
.foto-block .upload-icon{font-size:42px;margin-bottom:.7rem}
|
|
/* Eindpreview */
|
|
.final-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:1rem}
|
|
.final-card{background:#fff;border:1.5px solid var(--pink-light);border-radius:20px;padding:1rem;text-align:center}
|
|
.final-card h4{font-size:11px;font-weight:900;letter-spacing:1.5px;text-transform:uppercase;color:var(--gold);margin-bottom:.6rem}
|
|
.final-card img{max-width:100%;max-height:520px;object-fit:contain;border-radius:12px}
|
|
.missing{color:var(--muted);font-size:13px;padding:2rem;text-align:center}
|
|
.nav{display:flex;justify-content:space-between;gap:1rem;margin-top:1.4rem}
|
|
.btn{border:0;border-radius:999px;padding:1rem 1.6rem;font-weight:900;font-size:15px;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:.45rem;transition:.2s}
|
|
.btn-primary{background:linear-gradient(135deg,var(--pink),var(--pink-dark));color:#fff;box-shadow:0 8px 28px rgba(232,71,122,.35)}
|
|
.btn-secondary{background:#fff;color:var(--pink);border:1.5px solid var(--pink-light)}
|
|
.btn:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}
|
|
.footer{background:var(--dark);padding:1.25rem 1rem;text-align:center;font-size:12px;color:rgba(255,255,255,.28)}
|
|
.footer a{color:var(--gold);text-decoration:none}
|
|
@media(max-width:980px){.grid,.design-grid{grid-template-columns:repeat(3,1fr)}.summary{grid-template-columns:1fr}.tabs{grid-template-columns:repeat(4,1fr)}.final-grid{grid-template-columns:1fr}}
|
|
@media(max-width:680px){.logo{height:78px}.grid,.design-grid{grid-template-columns:repeat(2,1fr);gap:12px}.imgbox{height:220px}.imgbox img{max-height:205px}.design-img{height:260px}.design-img img{max-height:245px}.colors,.two{grid-template-columns:1fr}.progress{position:relative}.nav{flex-direction:column-reverse}.btn{width:100%}.tabs{grid-template-columns:repeat(4,1fr)}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="header"><img src="/verjaardag/images/Logo.png" class="logo" alt="Birthday Cake Topper"></header>
|
|
<section class="hero">
|
|
<div class="tag">🎂 Ontwerptool</div>
|
|
<h1>Maak jouw eigen<br><em>Birthday Cake Topper</em></h1>
|
|
<p>Kies je ontwerp, kleur, leeftijd en naam — de AI genereert eerst een master zonder gezicht. Na goedkeuring upload je de foto.</p>
|
|
<div class="divider"></div>
|
|
</section>
|
|
|
|
<main class="shell">
|
|
<div class="progress">
|
|
<div class="progress-top"><div class="progress-title" id="progressTitle">Kies je ontwerp</div><div class="progress-step" id="progressStep">Stap 1 van 8</div></div>
|
|
<div class="bar"><div class="fill" id="progressFill"></div></div>
|
|
<div class="tabs" id="tabs"></div>
|
|
</div>
|
|
|
|
<!-- STAP 1: Ontwerp -->
|
|
<section class="panel active" data-panel="1">
|
|
<div class="panel-head"><p class="label">Stap 1</p><h2>Kies het type jarige</h2><p class="desc">Begin met de categorie. Daarna kies je het specifieke ontwerp.</p></div>
|
|
<div id="categoryGrid" class="grid"></div>
|
|
<div id="designArea" style="display:none;margin-top:1.4rem">
|
|
<div class="toolbar"><button class="backlink" id="backToCategories">← Terug naar categorieën</button><div id="chosenCat" style="font-size:13px;color:var(--muted)"></div></div>
|
|
<div id="designGrid" class="design-grid"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- STAP 2: Kleur -->
|
|
<section class="panel" data-panel="2">
|
|
<div class="panel-head"><p class="label">Stap 2</p><h2>Kies de kleur</h2><p class="desc">De AI past de kledingkleur aan in de master-topper.</p></div>
|
|
<div class="form"><div class="colors" id="colorGrid"></div><div class="note" id="selectedColorNote" style="text-align:center;font-weight:900;color:var(--pink)"></div></div>
|
|
</section>
|
|
|
|
<!-- STAP 3: Leeftijd -->
|
|
<section class="panel" data-panel="3">
|
|
<div class="panel-head"><p class="label">Stap 3</p><h2>Kies de leeftijd</h2><p class="desc">Alle zichtbare nummers worden naar deze leeftijd aangepast.</p></div>
|
|
<div class="form"><label for="age">Leeftijd</label><input id="age" type="number" min="1" max="120" placeholder="Bijv. 16, 30, 45 of 50"></div>
|
|
</section>
|
|
|
|
<!-- STAP 4: Naam -->
|
|
<section class="panel" data-panel="4">
|
|
<div class="panel-head"><p class="label">Stap 4</p><h2>Naam op de sokkel</h2><p class="desc">De naam komt op de voorkant van de bestaande sokkel.</p></div>
|
|
<div class="form"><div><label for="name">Naam jarige</label><input id="name" placeholder="Bijv. Marjolein"></div></div>
|
|
</section>
|
|
|
|
<!-- STAP 5: Controle voor generatie -->
|
|
<section class="panel" data-panel="5">
|
|
<div class="panel-head"><p class="label">Stap 5</p><h2>Controle</h2><p class="desc">Klopt alles? Dan genereert de AI een master-topper zonder gezicht.</p></div>
|
|
<div class="summary">
|
|
<div class="summary-img"><img id="summaryImage" src="" alt="Gekozen ontwerp"></div>
|
|
<div class="summary-list" id="summaryList"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- STAP 6: AI genereert master (geen gezicht) -->
|
|
<section class="panel" data-panel="6">
|
|
<div class="panel-head"><p class="label">Stap 6</p><h2>AI genereert master-topper</h2><p class="desc">De AI past kleur, leeftijd en naam aan. Het gezicht blijft blanco — dat voegen we pas toe na jouw goedkeuring.</p></div>
|
|
<div class="ai-card">
|
|
<h3>Master genereren</h3>
|
|
<p>Klik op de knop. De AI genereert de topper met jouw kleur, leeftijd en naam. Gezicht blijft leeg.</p>
|
|
<div class="ai-actions">
|
|
<button type="button" class="btn btn-primary" id="generateMasterBtn">✨ Genereer master-topper</button>
|
|
<span class="status" id="masterStatus">Nog niet gestart</span>
|
|
</div>
|
|
<div class="result" id="masterResult"></div>
|
|
</div>
|
|
<!-- Goedkeuring — verschijnt na generatie -->
|
|
<div class="approve-box" id="approveBox" style="display:none">
|
|
<h3>✅ Ziet de master er goed uit?</h3>
|
|
<p>Controleer de kleur, het leeftijdsnummer en de naam op de sokkel. Kloppen die? Dan ga je door naar de foto. Niet tevreden? Genereer opnieuw.</p>
|
|
<div class="approve-actions">
|
|
<button class="btn-approve" id="approveMasterBtn">👍 Goedkeuren — ga naar foto</button>
|
|
<button class="btn-retry" id="retryMasterBtn">🔄 Opnieuw genereren</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- STAP 7: Foto uploaden -->
|
|
<section class="panel" data-panel="7">
|
|
<div class="panel-head"><p class="label">Stap 7</p><h2>Upload de foto</h2><p class="desc">De AI plaatst het gezicht en kapsel van de jarige op de goedgekeurde master-topper.</p></div>
|
|
<div class="form">
|
|
<div class="upload" id="uploadZone">
|
|
<input id="photo" type="file" accept="image/*">
|
|
<div class="upload-icon">📸</div>
|
|
<h3 id="uploadTitle">Foto van de jarige uploaden</h3>
|
|
<p id="uploadText">Klik hier of sleep een foto. Gebruik een duidelijke foto van voren, kapsel volledig zichtbaar.</p>
|
|
</div>
|
|
<div class="note">Tip: goed licht, gezicht van voren, geen zonnebril, kapsel volledig zichtbaar. De AI gebruikt de foto uitsluitend voor gezicht en kapsel.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- STAP 8: Eindpreview -->
|
|
<section class="panel" data-panel="8">
|
|
<div class="panel-head"><p class="label">Stap 8</p><h2>Eindpreview genereren</h2><p class="desc">De AI vervangt nu alleen hoofd en haar. Lichaam, huidskleur van body, pose, tas, sokkel en tekst blijven gelijk.</p></div>
|
|
<div class="ai-card">
|
|
<h3>Gezicht inplaatsen</h3>
|
|
<p>De goedgekeurde master blijft de basis. Alleen het hoofd- en haargebied wordt aangepast; body blijft volledig ongemoeid.</p>
|
|
<div class="ai-actions">
|
|
<button type="button" class="btn btn-primary" id="generateFaceBtn">✨ Genereer eindpreview</button>
|
|
<span class="status" id="faceStatus">Nog niet gestart</span>
|
|
</div>
|
|
<div class="result" id="faceResult"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="nav"><button class="btn btn-secondary" id="prevBtn">← Vorige stap</button><button class="btn btn-primary" id="nextBtn">Volgende stap →</button></div>
|
|
</main>
|
|
|
|
<footer class="footer">© 2026 BirthdayCakeTopper.nl — onderdeel van <a href="https://www.3dcaketopper.nl">3DCakeTopper.nl</a></footer>
|
|
|
|
<script>
|
|
const enc=s=>s.split('/').map(part=>part.includes(' ')?encodeURIComponent(part):part).join('/');
|
|
const base='/verjaardag/images/';
|
|
const categories=[
|
|
{id:'meisjes',title:'Meisjes',desc:'Van unicorn tot fashion girl',folder:'kind meisje',cover:'kind meisje/Meisje1.png',files:['Meisje1.png','Meisje2.png','Meisje3.png','Meisje4.png','Meisje5.png','Meisje6.png','Meisje7.png','Meisje8.png','Meisje9.png']},
|
|
{id:'jongens',title:'Jongens',desc:'Stoer, sportief of speels',folder:'kind jongen',cover:'kind jongen/Jongen1.png',files:['Jongen1.png','Jongen2.png','Jongen3.png','Jongen4.png','Jongen5.png','Jongen6.png']},
|
|
{id:'tieners',title:'Tieners',desc:'Sweet Sixteen en ouder',folder:'sweet16',cover:'sweet16/Sweet2 16.png',files:['Sweet 16.png','Sweet2 16.png','Sweet3 16.png','Sweet4 Sixteen.png','Sweet2 16 blauw.png','Sweet2 16 roze.png','Sweet2 18 bauw.png','Sweet2 25 blauw.png']},
|
|
{id:'vrouwen',title:'Vrouwen',desc:'Voor iedere leeftijd en stijl',folder:'vrouwen',cover:'vrouwen/Jongedame1.png',files:['Jongedame1.png','Jongedame2.png','Jongedame3.png','Jongedame4.png','Jongedame5.png','Jongedame6.png','Vrouw 1.png','vrouw2.png','Vrouw3.png','Vrouw4.png','Vrouw5.png','Sarah1.png']},
|
|
{id:'mannen',title:'Mannen',desc:'Stoer, grappig of klassiek',folder:'mannen',cover:'mannen/Jongeman1.png',files:['Jongeman1.png','Jongeman2.png','Jongeman3.png','Jongeman4.png','Jongeman5.png','Jongeman6.png']},
|
|
{id:'sarah',title:'Sarah',desc:'50 jaar en fabulous',folder:'Sarah',cover:'Sarah/Sarah1.png',files:['Sarah1.png','Sarah 1.png','Sarah2.png','Sarah 2.png','Sarah3.png','Sarah 3.png','Sarah4.png','Sarah 4.png','Sarah5.png','Sarah 5.png','Sarah6.png','Sarah 6.png']},
|
|
{id:'abraham',title:'Abraham',desc:'Voor de echte levensgenieter',folder:'Abraham',cover:'Abraham/Abraham3.png',files:['Abraham1.png','Abraham 1.png','Abraham2.png','Abraham 2.png','Abraham3.png','Abraham 3.png','Abraham4.png','Abraham 4.png','Abraham5.png','Abraham 5.png','Abraham6.png','Abraham 6.png']}
|
|
];
|
|
const colors=[['Roze','#E8477A'],['Blauw','#2F69C9'],['Wit','#F0F0F0'],['Goud','#C9A96E'],['Groen','#2D8A4A'],['Zwart','#111']];
|
|
const steps=['Ontwerp','Kleur','Leeftijd','Naam','Controle','Master AI','Foto','Eindpreview'];
|
|
const state={step:1,category:null,design:null,color:null,age:'',name:'',baseText:'',photoName:'',photoFile:null,masterUrl:'',masterApproved:false,previewUrl:''};
|
|
const $=id=>document.getElementById(id);
|
|
|
|
function path(p){return p&&p.startsWith('/')?enc(p):enc(base+p);}
|
|
function renderTabs(){$('tabs').innerHTML=steps.map((s,i)=>`<div class="tab ${state.step===i+1?'active':state.step>i+1?'done':''}">${i+1}. ${s}</div>`).join('');}
|
|
function tryNextImage(img){img.style.display='none';const wrap=img.parentElement;if(wrap&&!wrap.querySelector('.missing'))wrap.insertAdjacentHTML('beforeend','<div class="missing">Afbeelding niet gevonden</div>');}
|
|
|
|
function renderCategories(){
|
|
$('categoryGrid').innerHTML=categories.map(c=>`<article class="card ${state.category===c.id?'selected':''}" data-cat="${c.id}"><div class="check">✓</div><div class="imgbox"><img src="${path(c.cover)}" alt="${c.title}" onerror="tryNextImage(this)"></div><div class="card-body"><h3>${c.title}</h3><p>${c.desc}</p><div class="pill">Bekijk ontwerpen</div></div></article>`).join('');
|
|
document.querySelectorAll('.card[data-cat]').forEach(el=>el.onclick=()=>selectCategory(el.dataset.cat));
|
|
}
|
|
function selectCategory(id){state.category=id;state.design=null;const cat=categories.find(c=>c.id===id);$('categoryGrid').style.display='none';$('designArea').style.display='block';$('chosenCat').textContent='Categorie: '+cat.title;renderDesigns(cat);update();}
|
|
function renderDesigns(cat){
|
|
$('designGrid').innerHTML=cat.files.map((file)=>{const src=path(cat.folder+'/'+file);return`<article class="card design-card ${state.design&&state.design.src===src?'selected':''}" data-src="${src}" data-name="${file}" data-folder="${cat.folder}"><div class="check">✓</div><div class="design-img"><img src="${src}" alt="${file}" onerror="this.closest('.design-card').style.display='none'"></div><div class="design-name">${file}</div></article>`}).join('');
|
|
document.querySelectorAll('.design-card').forEach(el=>el.onclick=()=>{state.design={src:el.dataset.src,name:el.dataset.name,folder:el.dataset.folder};renderDesigns(cat);update();});
|
|
}
|
|
function renderColors(){
|
|
$('colorGrid').innerHTML=colors.map(([name,color])=>`<button class="color ${state.color===name?'selected':''}" type="button" onclick="selectColor('${name}')"><div class="dot" style="background:${color}"></div>${name}</button>`).join('');
|
|
$('selectedColorNote').textContent=state.color?`Gekozen kleur: ${state.color}`:'Kies hierboven een kleur.';
|
|
}
|
|
function selectColor(name){state.color=name;renderColors();update();}
|
|
|
|
function showStep(n){
|
|
state.step=Math.max(1,Math.min(8,n));
|
|
document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active',Number(p.dataset.panel)===state.step));
|
|
if(state.step===2)renderColors();
|
|
if(state.step===5)renderSummary();
|
|
update();
|
|
window.scrollTo({top:0,behavior:'smooth'});
|
|
}
|
|
function canNext(){
|
|
if(state.step===1)return!!state.design;
|
|
if(state.step===2)return!!state.color;
|
|
if(state.step===3)return!!state.age;
|
|
if(state.step===4)return!!state.name.trim();
|
|
if(state.step===6)return state.masterApproved; // moet goedgekeurd zijn
|
|
if(state.step===7)return!!state.photoFile;
|
|
return true;
|
|
}
|
|
function update(){
|
|
renderTabs();
|
|
$('progressTitle').textContent=steps[state.step-1];
|
|
$('progressStep').textContent=`Stap ${state.step} van 8`;
|
|
$('progressFill').style.width=(state.step/8*100)+'%';
|
|
$('prevBtn').style.visibility=state.step===1?'hidden':'visible';
|
|
$('nextBtn').disabled=!canNext();
|
|
if(state.step===8){
|
|
$('nextBtn').style.display='none';
|
|
} else {
|
|
$('nextBtn').style.display='';
|
|
$('nextBtn').textContent='Volgende stap →';
|
|
}
|
|
}
|
|
|
|
function renderSummary(){
|
|
const cat=categories.find(c=>c.id===state.category);
|
|
$('summaryImage').src=state.design?state.design.src:'';
|
|
$('summaryList').innerHTML=`
|
|
<div class="row"><span>Categorie</span><span>${cat?cat.title:'-'}</span></div>
|
|
<div class="row"><span>Ontwerp</span><span>${state.design?state.design.name:'-'}</span></div>
|
|
<div class="row"><span>Kleur</span><span>${state.color||'-'}</span></div>
|
|
<div class="row"><span>Leeftijd</span><span>${state.age||'-'}</span></div>
|
|
<div class="row"><span>Naam sokkel</span><span>${state.name||'-'}</span></div>
|
|
`;
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
async function fileFromUrl(url,filename){const res=await fetch(url);if(!res.ok)throw new Error('Basisontwerp kon niet worden geladen.');const blob=await res.blob();return new File([blob],filename,{type:blob.type||'image/png'});}
|
|
async function fileFromDataUrl(dataUrl,filename){const res=await fetch(dataUrl);const blob=await res.blob();return new File([blob],filename,{type:blob.type||'image/png'});}
|
|
function imageFromOpenAIResponse(data){const item=data?.data?.[0];if(!item)return'';if(item.b64_json)return`data:image/png;base64,${item.b64_json}`;if(item.url)return item.url;return'';}
|
|
|
|
// ── Prompts ──────────────────────────────────────────────────────────────────
|
|
function buildMasterPrompt(){
|
|
const colorMap={'Zwart':'jet black','Blauw':'navy blue','Groen':'forest green','Roze':'bright pink','Goud':'gold','Wit':'bright white'};
|
|
const colorEn = colorMap[state.color] || state.color;
|
|
return [
|
|
'IMAGE EDITING MODE:',
|
|
'Treat the uploaded image as the master composition.',
|
|
'Do not recreate the figurine.',
|
|
'Do not redesign the figurine.',
|
|
'Do not reinterpret the pose.',
|
|
'',
|
|
'Only modify:',
|
|
`- outfit / clothing color only: change the sportswear/clothing to ${colorEn}. Do NOT change skin color, hair color, shoes color or accessory colors.`,
|
|
`- every visible age number on the figurine: replace with ${state.age}.`,
|
|
`- pedestal text: write "${state.name}" on the front of the existing pedestal in ${colorEn} color.`,
|
|
'',
|
|
'The face must remain blank, smooth and featureless — no eyes, nose, mouth or hair.',
|
|
'Keep the face completely blank as it is in the template.',
|
|
'',
|
|
`Everything else must remain pixel-for-pixel identical to the original image.`,
|
|
'',
|
|
'CRITICAL:',
|
|
'Preserve exactly:',
|
|
'- composition',
|
|
'- framing',
|
|
'- camera distance',
|
|
'- pedestal size',
|
|
'- pedestal position',
|
|
'- body pose',
|
|
'- hand position',
|
|
'- leg position',
|
|
'- clothing shape',
|
|
'The output should look like the original image with only the color, number and pedestal text changed.',
|
|
'IMPORTANT: keep the complete figurine visible.',
|
|
'Do not crop any part of the figurine.',
|
|
'Do not crop heads, hands, feet, arms, legs, pedestal or text.',
|
|
'Keep the entire topper inside the frame with generous clean white margin around it.',
|
|
'Use the uploaded padded white canvas as the exact output frame.',
|
|
'Keep the topper centered on that canvas and keep visible white safety space above the head and below the pedestal.',
|
|
'Leave approximately 8-12% white margin around the complete topper, especially top and bottom.',
|
|
'Do not let the topper touch or cross the image borders.',
|
|
'Preserve the original composition and aspect ratio.',
|
|
'Expand the canvas if necessary.',
|
|
'Never zoom in.',
|
|
'Never crop.',
|
|
'Never fill the entire canvas with the topper.',
|
|
'Always show the full original figurine.',
|
|
].join('\n');
|
|
}
|
|
function buildFacePrompt(){
|
|
return [
|
|
'STRICT LOCAL EDIT MODE — HEAD AND HAIR ONLY.',
|
|
'',
|
|
'The FIRST image is the approved locked master topper.',
|
|
'The SECOND image is the uploaded photo reference.',
|
|
'',
|
|
'THIS IS NOT A NEW IMAGE.',
|
|
'THIS IS NOT A NEW RENDER.',
|
|
'THIS IS NOT A NEW CHARACTER.',
|
|
'The approved master is final and must remain the exact base image.',
|
|
'',
|
|
'EDITABLE AREA:',
|
|
'Only the transparent masked head-and-hair area may change.',
|
|
'Everything outside this mask is locked and must remain visually identical.',
|
|
'',
|
|
'TASK:',
|
|
'Replace the blank head with a sculpted 3D birthday cake topper head based on the uploaded person.',
|
|
'Use the uploaded person\'s facial identity, hair color, hairstyle, hair volume and hair texture.',
|
|
'The face and hair must look like part of the same glossy 3D topper material.',
|
|
'Do not paste a flat photo. Do not create a photographic cut-out.',
|
|
'',
|
|
'HEAD SIZE AND PLACEMENT:',
|
|
'The new head must fit inside the existing blank head position of the master.',
|
|
'Resize the generated head if needed so it fits the original topper proportions.',
|
|
'Never make the head oversized.',
|
|
'Never move, resize or reshape the body to fit the head.',
|
|
'The head must align naturally with the existing neck position.',
|
|
'',
|
|
'HAIR:',
|
|
'Hair may change to match the uploaded photo, but it must stay inside the masked head/hair region.',
|
|
'If the uploaded person has curls, create a compact sculpted topper version of those curls inside the head/hair region.',
|
|
'Do not let hair cover or redraw shoulders, torso, arms, clothing, bag, bottle or pedestal.',
|
|
'',
|
|
'BODY IS ABSOLUTELY LOCKED:',
|
|
'Do NOT edit the neck below the head connection.',
|
|
'Do NOT edit shoulders.',
|
|
'Do NOT edit chest, torso, waist, abdominal muscles, arms, hands, fingers, legs or feet.',
|
|
'Do NOT edit or recolor the body skin.',
|
|
'Do NOT change skin tone of arms, legs, torso or shoulders.',
|
|
'Do NOT edit clothing, outfit color, sportswear shape, fabric, bag, bottle, shoes, pedestal, text or background.',
|
|
'Do NOT change lighting, shadows, camera angle, zoom, crop or framing.',
|
|
'',
|
|
'IMPORTANT:',
|
|
'Ignore the uploaded photo\'s body, shoulders, clothing and pose completely.',
|
|
'Use the uploaded photo only for the head: face identity and hair.',
|
|
'The final image must remain exactly aligned with the master.',
|
|
'Outside the head-and-hair mask, the output must remain pixel-for-pixel identical to the approved master.',
|
|
'No zoom. No crop. No reframing. No redesign.',
|
|
].join('\n');
|
|
}
|
|
|
|
// Vergroot het canvas rondom de originele afbeelding
|
|
// Voeg 15% padding toe boven en onder zodat de AI nooit hoeft te croppen
|
|
async function padCanvas(imageFile, topPct=0.15, bottomPct=0.10){
|
|
const img = await new Promise((res,rej)=>{const i=new Image();i.onload=()=>res(i);i.onerror=rej;i.src=URL.createObjectURL(imageFile);});
|
|
const origW = img.naturalWidth||img.width;
|
|
const origH = img.naturalHeight||img.height;
|
|
const padTop = Math.round(origH * topPct);
|
|
const padBot = Math.round(origH * bottomPct);
|
|
const newH = origH + padTop + padBot;
|
|
const c = document.createElement('canvas');
|
|
c.width = origW; c.height = newH;
|
|
const ctx = c.getContext('2d');
|
|
// Witte achtergrond (of transparant — wit matcht de meeste toppers)
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, origW, newH);
|
|
// Teken origineel met offset naar beneden
|
|
ctx.drawImage(img, 0, padTop, origW, origH);
|
|
return new Promise(resolve => c.toBlob(blob => resolve(
|
|
new File([blob], 'padded-base.png', {type:'image/png'})
|
|
), 'image/png'));
|
|
}
|
|
|
|
// ── STAP 6: Master genereren ─────────────────────────────────────────────────
|
|
async function generateMaster(){
|
|
const btn=$('generateMasterBtn'),status=$('masterStatus'),result=$('masterResult');
|
|
btn.disabled=true;
|
|
$('approveBox').style.display='none';
|
|
state.masterApproved=false;
|
|
result.style.display='flex';
|
|
result.innerHTML='<div class="missing">⏳ AI genereert master-topper — kleur, leeftijd en naam worden verwerkt. Gezicht blijft blanco...</div>';
|
|
status.textContent='Bezig...';
|
|
try{
|
|
const fd=new FormData();
|
|
fd.append('model','gpt-image-1');
|
|
fd.append('prompt',buildMasterPrompt());
|
|
fd.append('n','1');
|
|
fd.append('quality','high');
|
|
const baseFile=await fileFromUrl(state.design.src,'base-topper.png');
|
|
// Zelfde frame-fix als bij WeddingCakeTopper: eerst extra wit canvas rondom de template,
|
|
// zodat de AI de volledige topper inclusief hoofd, sokkel en tekst binnen het kader houdt.
|
|
const paddedBaseFile=await padCanvas(baseFile,0.18,0.14);
|
|
fd.append('image[]',paddedBaseFile,'padded-base-topper.png');
|
|
const res=await fetch('/api/imagine-edit',{method:'POST',body:fd});
|
|
const data=await res.json().catch(()=>({}));
|
|
const masterUrl=imageFromOpenAIResponse(data);
|
|
if(!res.ok||!masterUrl)throw new Error(data?.error?.message||data?.error||'Geen afbeelding ontvangen.');
|
|
state.masterUrl=masterUrl;
|
|
result.innerHTML=`<img src="${masterUrl}" alt="Master topper">`;
|
|
status.textContent='Master gegenereerd.';
|
|
$('approveBox').style.display='block';
|
|
update();
|
|
}catch(err){
|
|
result.innerHTML=`<div class="missing">❌ Fout: ${err.message}</div>`;
|
|
status.textContent='Mislukt.';
|
|
}finally{
|
|
btn.disabled=false;
|
|
}
|
|
}
|
|
|
|
// ── STAP 8: Gezicht inplaatsen ───────────────────────────────────────────────
|
|
async function makeEditMask(imageFile){
|
|
const img=await new Promise((resolve,reject)=>{const i=new Image();i.onload=()=>resolve(i);i.onerror=reject;i.src=URL.createObjectURL(imageFile);});
|
|
const c=document.createElement('canvas');c.width=img.naturalWidth||img.width;c.height=img.naturalHeight||img.height;const ctx=c.getContext('2d');
|
|
// Wit = locked. Transparant = bewerkbaar.
|
|
// V5: extreem beperkte head/hair-mask. Geen nek, schouders, armen, torso of benen.
|
|
ctx.fillStyle='rgba(255,255,255,1)';ctx.fillRect(0,0,c.width,c.height);
|
|
ctx.globalCompositeOperation='destination-out';
|
|
const W=c.width,H=c.height;
|
|
const ellipse=(x,y,rx,ry,rot=0)=>{ctx.beginPath();ctx.ellipse(W*x,H*y,W*rx,H*ry,rot,0,Math.PI*2);ctx.fill();};
|
|
// Compact hoofd + haarzone. Bewust kleiner dan index46 om body/postuur 100% te locken.
|
|
ellipse(0.50,0.185,0.115,0.128,0);
|
|
// Beperkte zijruimte voor haar/krullen, zonder schouders of bovenlijf te raken.
|
|
ellipse(0.445,0.190,0.065,0.105,-0.15);
|
|
ellipse(0.555,0.190,0.065,0.105,0.15);
|
|
// Bovenhaar / volume, compact.
|
|
ellipse(0.50,0.120,0.105,0.060,0);
|
|
// Kleine kin/kaakzone, geen hals/nek-masker.
|
|
ellipse(0.50,0.278,0.048,0.035,0);
|
|
return new Promise(resolve=>c.toBlob(blob=>resolve(new File([blob],'head-hair-only-mask.png',{type:'image/png'})),'image/png'));
|
|
}
|
|
async function generateFace(){
|
|
if(!state.masterUrl){alert('Geen goedgekeurde master gevonden. Ga terug naar stap 6.');return;}
|
|
if(!state.photoFile){alert('Upload eerst een foto in stap 7.');return;}
|
|
const btn=$('generateFaceBtn'),status=$('faceStatus'),result=$('faceResult');
|
|
btn.disabled=true;
|
|
result.style.display='flex';
|
|
result.innerHTML='<div class="missing">⏳ AI vervangt alleen het hoofdgebied; body blijft gelockt...</div>';
|
|
status.textContent='Bezig...';
|
|
try{
|
|
const masterFile=await fileFromDataUrl(state.masterUrl,'locked-master.png');
|
|
const maskFile=await makeEditMask(masterFile);
|
|
const fd=new FormData();
|
|
fd.append('model','gpt-image-1');
|
|
fd.append('prompt',buildFacePrompt());
|
|
fd.append('quality','high');
|
|
fd.append('image[]',masterFile,'locked-master.png');
|
|
fd.append('image[]',state.photoFile,'face-reference.jpg');
|
|
fd.append('mask',maskFile,'head-hair-only-mask.png');
|
|
const res=await fetch('/api/imagine-edit',{method:'POST',body:fd});
|
|
const data=await res.json().catch(()=>({}));
|
|
const finalUrl=imageFromOpenAIResponse(data);
|
|
if(!res.ok||!finalUrl)throw new Error(data?.error?.message||data?.error||'Geen eindpreview ontvangen.');
|
|
state.previewUrl=finalUrl;
|
|
result.innerHTML=`
|
|
<div class="final-grid" style="width:100%">
|
|
<div class="final-card"><h4>✅ Goedgekeurde master</h4><img src="${state.masterUrl}" alt="Master"></div>
|
|
<div class="final-card"><h4>🎉 Eindpreview met gezicht</h4><img src="${finalUrl}" alt="Eindpreview"></div>
|
|
</div>`;
|
|
status.textContent='Eindpreview klaar!';
|
|
}catch(err){
|
|
result.innerHTML=`<div class="missing">❌ Fout: ${err.message}</div>`;
|
|
status.textContent='Mislukt.';
|
|
}finally{
|
|
btn.disabled=false;
|
|
}
|
|
}
|
|
|
|
// ── Events ───────────────────────────────────────────────────────────────────
|
|
$('backToCategories').onclick=()=>{$('categoryGrid').style.display='grid';$('designArea').style.display='none';renderCategories();};
|
|
$('prevBtn').onclick=()=>showStep(state.step-1);
|
|
$('nextBtn').onclick=()=>{
|
|
if(state.step===8)return;
|
|
showStep(state.step+1);
|
|
};
|
|
$('age').addEventListener('input',e=>{state.age=e.target.value;update();});
|
|
$('name').addEventListener('input',e=>{state.name=e.target.value;update();});
|
|
$('photo').addEventListener('change',e=>{
|
|
state.photoFile=e.target.files[0]||null;
|
|
state.photoName=state.photoFile?.name||'';
|
|
$('uploadTitle').textContent=state.photoName?'✓ Foto geüpload':'Foto van de jarige uploaden';
|
|
$('uploadText').textContent=state.photoName||'Klik hier of sleep een foto.';
|
|
update();
|
|
});
|
|
|
|
$('generateMasterBtn').addEventListener('click',generateMaster);
|
|
|
|
$('approveMasterBtn').addEventListener('click',()=>{
|
|
state.masterApproved=true;
|
|
update();
|
|
showStep(7);
|
|
});
|
|
$('retryMasterBtn').addEventListener('click',()=>{
|
|
state.masterApproved=false;
|
|
$('approveBox').style.display='none';
|
|
generateMaster();
|
|
});
|
|
$('generateFaceBtn').addEventListener('click',generateFace);
|
|
|
|
renderCategories();renderColors();update();
|
|
</script>
|
|
</body>
|
|
</html>
|