Abre o crea un proyecto para empezar
`; const blob=new Blob([html],{type:'text/html;charset=utf-8'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=(title.replace(/[^\w\-]+/g,'_').replace(/^_+|_+$/g,'').toLowerCase()||'plinko')+'.html'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),5000); const note = failed ? (LANG==='en'?('Exported · '+embedded+' assets inlined, '+failed+' kept online'):('Exportado · '+embedded+' recursos incrustados, '+failed+' quedan online')) : (LANG==='en'?'HTML exported (fully offline)':'HTML exportado (100% offline)'); toast(note); } catch(err){ console.error('[export]',err); toast((LANG==='en'?'Export error: ':'Error al exportar: ')+(err.message||err),true); } finally { hideLoading(); } } function defaultConfig(lang){ if(typeof PLINKO_TEMPLATES!=='undefined'&&PLINKO_TEMPLATES&&PLINKO_TEMPLATES[0]){ const cfg=JSON.parse(JSON.stringify(PLINKO_TEMPLATES[0].config)); if(lang) cfg.meta.lang=lang; return cfg; } return { meta:{name:'Plinko',lang:lang||'es',canvasW:1080,canvasH:1920,fit:'contain',bgColor:'#0a0018',primaryColor:'#7c5cff',accentColor:'#22d3a6',googleFonts:[],transition:'fade',transitionMs:350}, fonts:[],audios:[],leads:{enabled:false,apiUrl:'',consentRequired:false}, plinko:{prizes:defaultPrizes(),settings:defaultSettings()}, screens:[{id:'start',name:'Inicio',role:'start',bg:'',bgColor:'#0a0018',elements:[]}], library:{images:[]} }; } function syncEngineConfig(P){ if(!P) return; P.meta=P.meta||{}; P.plinko=P.plinko||{prizes:defaultPrizes(),settings:defaultSettings()}; (P.plinko.prizes||[]).forEach((pr,i)=>{ if(!pr.id) pr.id='p'+(i+1); if(!pr.label) pr.label='Premio'; if(pr.weight==null) pr.weight=1; if(!pr.type) pr.type='win'; if(!pr.color) pr.color='#7c5cff'; }); } async function createProject(){ const name=($('#newName').value||'').trim(); if(!name){ toast('Escribe un nombre para el proyecto',true); $('#newName').focus(); return; } if(!authToken){ showLogin(); return; } P=defaultConfig(LANG); P.meta.name=name; P.meta.lang=LANG; let _w,_h; if($('#newOrient').value==='custom'){_w=parseInt($('#customW').value)||1280;_h=parseInt($('#customH').value)||720;}else{[_w,_h]=($('#newOrient').value||'1080x1920').split('x').map(Number);} P.meta.canvasW=_w||1080; P.meta.canvasH=_h||1920; P.meta.fit='contain'; PID=null; syncEngineConfig(P); let r=await apiPost({action:'save',pid:null,nombre:name,config:P}); if(!r.success){ if(r.error==='No autorizado') showLogin(); else toast(r.error||'Error al guardar',true); return; } PID=r.pid; activeScreen=P.screens[0].id; setSel([]); $('#pname').value=name; $('#newName').value=''; $('#mProjects').classList.remove('open'); window.history.replaceState(null,'','?edit='+PID); renderAll(); seedHistory(); setSaveStatus('saved'); toast('Proyecto creado y guardado ✓'); } async function loadProject(pid){ const r=await apiGet('action=get&id='+pid); if(!r.success){ toast('No se pudo abrir',true); return; } P=r.config; PID=pid; P.meta=P.meta||{}; P.fonts=P.fonts||[]; P.audios=P.audios||[]; P.library=P.library||{images:[]}; P.plinko=P.plinko||{prizes:defaultPrizes(),settings:defaultSettings()}; P.leads=P.leads||{enabled:false,apiUrl:'',consentRequired:false}; activeScreen=P.screens[0].id; setSel([]); $('#pname').value=r.nombre||P.meta.name||'Plinko'; $('#mProjects').classList.remove('open'); renderAll(); seedHistory(); setSaveStatus('saved'); toast('Proyecto cargado'); } async function saveProject(){ if(!P){ toast('Nada que guardar',true); return; } if(!authToken){ showLogin(); return; } P.meta.name=$('#pname').value||'Plinko'; syncEngineConfig(P); const r=await apiPost({action:'save',pid:PID,nombre:P.meta.name,config:P}); if(r.success){ PID=r.pid; lastSavedJSON=JSON.stringify(P); setSaveStatus('saved'); window.history.replaceState(null,'','?edit='+PID); showSavedUrl(); } else if(r.error==='No autorizado'){ showLogin(); } else toast(r.error||'Error al guardar',true); } function showSavedUrl(){ const url=playerUrl(PID); const t=$('#toast'); t.innerHTML='Guardado ✓ — URL del jugador copiada'; t.className='toast show'; navigator.clipboard&&navigator.clipboard.writeText(url).catch(()=>{}); setTimeout(()=>t.className='toast',2500); } /* ============================ AUTH / USERS ============================ */ function showLogin(){ const e=$('#loginError'); if(e) e.textContent=''; $('#mLogin').classList.add('open'); setTimeout(()=>{ const u=$('#loginUser'); if(u) u.focus(); },60); } async function doLogin(){ const username=($('#loginUser').value||'').trim(), password=$('#loginPass').value||''; if(!username||!password){ $('#loginError').textContent=(LANG==='en'?'Enter user and password':'Completa usuario y contraseña'); return; } const r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'login',username,password})}).then(x=>x.json()).catch(()=>({success:false})); if(!r.success){ $('#loginError').textContent=tr(r.error||'Usuario o contraseña incorrectos'); return; } authToken=r.token; authUser=r.user; localStorage.setItem('gm_token',authToken); localStorage.setItem('gm_user',JSON.stringify(authUser)); $('#loginPass').value=''; if(!gameAccessOK()){ $('#loginError').textContent='Tu cuenta no tiene acceso a '+GAME_LABEL+'. Pide acceso al super admin o entra con otra cuenta.'; updateUserChrome(); return; } $('#mLogin').classList.remove('open'); updateUserChrome(); openProjects(); } async function forgotPassword(){ const email=(prompt(LANG==='en'?'Enter your email to reset your password:':'Ingresa tu correo para restablecer la contraseña:', ($('#loginUser').value||'').trim())||'').trim().toLowerCase(); if(!email) return; if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)){ toast(LANG==='en'?'Invalid email':'Correo inválido',true); return; } await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'forgot',email})}).then(x=>x.json()).catch(()=>({})); toast(LANG==='en'?'If that email exists, we sent a reset link.':'Si el correo existe, te enviamos un enlace para restablecer.'); } function logout(){ authToken=''; authUser=null; localStorage.removeItem('gm_token'); localStorage.removeItem('gm_user'); P=null; PID=null; setSel([]); renderAll(); updateUserChrome(); $$('.modal').forEach(m=>m.classList.remove('open')); showLogin(); } function updateUserChrome(){ const el=$('#userInfo'); if(el) el.textContent = authUser ? (authUser.username + (authUser.role==='superadmin'?' ★':'')) : ''; const ub=$('#btnUsers'); if(ub) ub.style.display = (authUser && authUser.role==='superadmin') ? '' : 'none'; } let ALL_GAMES = ['trivia','memoria','ruleta','rompecabezas']; function gameChk(prefix, uid, key, checked){ return ``; } function renderNewGames(){ const role=$('#newRole') ? $('#newRole').value : 'admin'; const box=$('#newGames'); if(!box) return; if(role==='superadmin'){ box.innerHTML='acceso a todos los juegos'; return; } box.innerHTML='Juegos: '+ALL_GAMES.map(g=>gameChk('ng','new',g, g===GAME)).join(' '); } async function openUsers(){ if(!authUser || authUser.role!=='superadmin'){ toast('Solo el super admin',true); return; } $('#usersBody').innerHTML='
'+tr('Cargando…')+'
'; $('#mUsers').classList.add('open'); const r2=await fetch(API+'?action=users_list',{headers:{'X-Auth':authToken}}).then(x=>x.json()).catch(()=>({success:false})); if(!r2.success){ $('#usersBody').innerHTML='
'+tr(r2.error||'Error')+'
'; return; } if(Array.isArray(r2.allGames)&&r2.allGames.length) ALL_GAMES=r2.allGames; $('#usersBody').innerHTML=''+ r2.users.map(u=>{ const ug=(u.games||'').split(',').map(s=>s.trim()).filter(Boolean); const gamesCell = u.role==='superadmin' ? '★ todos' : ALL_GAMES.map(g=>gameChk('ug',u.id,g, ug.indexOf(g)>=0)).join(' '); return ``; }).join('')+'
'+tr('Usuario')+''+tr('Rol')+''+tr('Juegos')+'
${u.username}${u.role==='superadmin'?'★ Super admin':'Admin'} ${gamesCell}
'; // per-admin game toggles → save immediately $('#usersBody').querySelectorAll('[data-ug]').forEach(c=>c.onchange=async()=>{ const uid=+c.dataset.ug; const games=Array.from($('#usersBody').querySelectorAll('[data-ug="'+uid+'"]:checked')).map(x=>x.dataset.gk); const x=await apiPost({action:'user_set_games',id:uid,games}); if(x.success) toast(LANG==='en'?'Access updated':'Acceso actualizado'); else { toast(x.error||'Error',true); openUsers(); } }); $('#usersBody').querySelectorAll('[data-ureset]').forEach(b=>b.onclick=async()=>{ const np=prompt(LANG==='en'?'New password:':'Nueva contraseña:'); if(!np) return; const x=await apiPost({action:'user_reset',id:+b.dataset.ureset,password:np}); if(x.success) toast(LANG==='en'?'Password reset':'Contraseña reseteada'); else toast(x.error||'Error',true); }); $('#usersBody').querySelectorAll('[data-udel]').forEach(b=>b.onclick=async()=>{ if(!confirm(LANG==='en'?'Delete this user?':'¿Eliminar este usuario?'))return; const x=await apiPost({action:'user_delete',id:+b.dataset.udel}); if(x.success){toast(LANG==='en'?'User deleted':'Usuario eliminado');openUsers();} else toast(x.error||'Error',true); }); renderNewGames(); applyLang($('#mUsers')); } async function createUserFromForm(){ const username=($('#newUser').value||'').trim(), password=$('#newPass').value||'', role=$('#newRole').value; if(!username||!password){ toast(LANG==='en'?'Enter user and password':'Completa usuario y contraseña',true); return; } const games=Array.from(document.querySelectorAll('#newGames [data-ng="new"]:checked')).map(x=>x.dataset.gk); if(role==='admin' && !games.length){ toast(LANG==='en'?'Pick at least one game':'Elige al menos un juego',true); return; } const r=await apiPost({action:'user_create',username,password,role,games}); if(r.success){ $('#newUser').value=''; $('#newPass').value=''; toast(LANG==='en'?'User created':'Usuario creado'); openUsers(); } else toast(r.error||'Error',true); } /* ============================ AUTOSAVE + HISTORY ============================ */ function setSaveStatus(s){ const el=$('#saveStatus'); if(!el) return; if(s==='saving'){ el.textContent=tr('Guardando…'); el.style.color='var(--muted)'; } else if(s==='saved'){ el.textContent=tr('Guardado ✓'); el.style.color='var(--accent)'; } else if(s==='dirty'){ el.textContent=tr('Sin guardar'); el.style.color='#e0a000'; } else if(s==='error'){ el.textContent=tr('Error al guardar'); el.style.color='var(--danger)'; } else el.textContent=''; } async function saveSilent(){ if(saving || !P || !PID || !authToken) return; saving=true; setSaveStatus('saving'); P.meta.name=$('#pname').value||P.meta.name; syncEngineConfig(P); const r=await apiPost({action:'save',pid:PID,nombre:P.meta.name,config:P}); saving=false; lastSaveAt=performance.now(); if(r&&r.success){ PID=r.pid; lastSavedJSON=JSON.stringify(P); setSaveStatus('saved'); } else setSaveStatus('error'); } function seedHistory(){ if(!P) return; const json=JSON.stringify(P); history=[{json,time:new Date().toLocaleTimeString(),label:tr('Estado inicial')}]; histIndex=0; lastSavedJSON=json; prevPoll=json; lastSaveAt=performance.now(); updateUndoRedo(); if($('#mHistory').classList.contains('open')) renderHistory(); } function pushHistory(json){ history=history.slice(0,histIndex+1); history.push({json,time:new Date().toLocaleTimeString()}); if(history.length>10) history.shift(); histIndex=history.length-1; updateUndoRedo(); if($('#mHistory').classList.contains('open')) renderHistory(); } function autosaveTick(){ if(!P||!PID||applyingSnapshot) return; const cur=JSON.stringify(P); const settled = (cur===prevPoll); // unchanged since last tick → the gesture finished const dirty = (cur!==lastSavedJSON); const forced = dirty && (performance.now()-lastSaveAt > 20000); // safety net if editing nonstop // Save only when the action has settled (e.g. drag finished), not on every intermediate frame. if((settled || forced) && dirty && authToken && !saving) saveSilent(); // Record a single history point once the state has settled. if(settled){ const top=history[histIndex]; if(!top || top.json!==cur) pushHistory(cur); } if(dirty && !settled) setSaveStatus('dirty'); prevPoll=cur; } function applySnapshot(json){ applyingSnapshot=true; P=JSON.parse(json); P.meta=P.meta||{}; if(!screenById(activeScreen)) activeScreen=P.screens[0] && P.screens[0].id; setSel(selIds.filter(id=>elById(id))); prevPoll=json; $('#pname').value=P.meta.name||''; renderAll(); updateUndoRedo(); if($('#mHistory').classList.contains('open')) renderHistory(); applyingSnapshot=false; saveSilent(); } function undo(){ if(histIndex>0){ histIndex--; applySnapshot(history[histIndex].json); } } function redo(){ if(histIndex=history.length-1; } function openHistory(){ if(!P){toast('Crea un proyecto',true);return;} renderHistory(); $('#mHistory').classList.add('open'); } function renderHistory(){ const b=$('#historyBody'); if(!b) return; if(!history.length){ b.innerHTML='
'; return; } b.innerHTML=history.map((h,i)=>{ const cur=i===histIndex; const name=h.label || (tr('Cambio')+' '+(i+1)); return `
${cur?'▶ ':''}${name} ${h.time}
`; }).reverse().join(''); b.querySelectorAll('[data-hi]').forEach(btn=>btn.onclick=()=>{ histIndex=+btn.dataset.hi; applySnapshot(history[histIndex].json); }); applyLang($('#mHistory')); } /* ============================ EVENTS ============================ */ $('#btnProjects').onclick=openProjects; $('#btnNewFirst').onclick=openProjects; $('#createProject').onclick=createProject; $('#newName').addEventListener('keydown',ev=>{ if(ev.key==='Enter') createProject(); }); $('#newOrient').addEventListener('change',()=>{ $('#customOrient').style.display=$('#newOrient').value==='custom'?'flex':'none'; }); $('#langSel').value=LANG; $('#langSel').onchange=()=>{ LANG=$('#langSel').value; localStorage.setItem('gm_lang',LANG); applyLang(); }; $('#btnAddScreen').onclick=()=>{ if(!P){toast('Crea un proyecto',true);return;} addScreen(); }; $('#btnTemplates').onclick=(ev)=>{ ev.stopPropagation(); if(!P){toast('Crea un proyecto',true);return;} const r=ev.currentTarget.getBoundingClientRect(); openTemplatesMenu(r.left, r.bottom+4); }; $('#btnAddElement').onclick=(ev)=>{ ev.stopPropagation(); if(!P){toast('Crea un proyecto',true);return;} const r=ev.currentTarget.getBoundingClientRect(); openAddElementMenu(r.left, r.bottom+4); }; $('#btnScreenProps').onclick=()=>{ if(!P){toast('Crea un proyecto',true);return;} setSel([]); renderStage(); renderScreens(); renderProps(); }; $('#btnElements').onclick=()=>{ if(!P){toast('Crea un proyecto',true);return;} renderElementList(); $('#mElements').classList.add('open'); }; $('#btnSelectMode').onclick=()=>{ selectMode=!selectMode; $('#btnSelectMode').classList.toggle('on',selectMode); $('#stage-host').classList.toggle('selmode',selectMode); toast(selectMode?(LANG==='en'?'Select mode ON — drag to select':'Modo selección ON — arrastra para seleccionar'):(LANG==='en'?'Select mode OFF':'Modo selección OFF')); }; $('#btnSave').onclick=saveProject; $('#loginBtn').onclick=doLogin; $('#forgotLink').onclick=(ev)=>{ ev.preventDefault(); forgotPassword(); }; $('#loginPass').addEventListener('keydown',ev=>{ if(ev.key==='Enter') doLogin(); }); $('#loginUser').addEventListener('keydown',ev=>{ if(ev.key==='Enter') $('#loginPass').focus(); }); $('#btnUsers').onclick=openUsers; $('#createUser').onclick=createUserFromForm; $('#newRole').onchange=renderNewGames; $('#btnLogout').onclick=()=>{ if(confirm(LANG==='en'?'Log out?':'¿Cerrar sesión?')) logout(); }; $('#btnSettings').onclick=()=>{ if(!P){toast('Crea un proyecto',true);return;} openSettings(); }; $('#btnPrizes').onclick=openPrizes; $('#btnAssets').onclick=()=>{ if(!P){toast('Crea un proyecto',true);return;} openAssets(); }; $('#btnVars').onclick=openVars; $('#btnLeads').onclick=openLeads; $('#btnUndo').onclick=undo; $('#btnRedo').onclick=redo; $('#btnHistory').onclick=openHistory; $('#addPrize').onclick=()=>{ if(!P.plinko) P.plinko={prizes:defaultPrizes(),settings:defaultSettings()}; const id='p'+(Date.now()%100000); P.plinko.prizes.push({id,label:'Nuevo Premio',weight:2,type:'win',color:'#7c5cff'}); renderPrizes(); }; $('#exportLeads').onclick=exportLeads; $('#btnPreview').onclick=()=>{ if(!P){toast('Crea un proyecto',true);return;} if(!PID){ toast('Guarda primero para probar',true); saveProject(); return; } window.open(playerUrl(PID),'_blank'); }; $('#btnExport').onclick=()=>{ if(!P){toast('Crea un proyecto',true);return;} exportOffline(); }; $('#pname').oninput=()=>{ if(P) P.meta.name=$('#pname').value; }; $$('[data-add]').forEach(b=>b.onclick=()=>addElement(b.dataset.add)); $$('.modal').forEach(m=>{ m.querySelectorAll('[data-close]').forEach(x=>x.onclick=()=>m.classList.remove('open')); if(m.id!=='mLogin') m.addEventListener('click',ev=>{ if(ev.target===m) m.classList.remove('open'); }); }); // stage empty area: click = deselect, drag = marquee multi-select $('#stage-host').addEventListener('pointerdown',ev=>{ if(!(ev.target.id==='stage'||ev.target.id==='stage-host'||ev.target.id==='stage-scaler')) return; if(ev.button!==0) return; startMarquee(ev, ev.shiftKey||ev.ctrlKey||ev.metaKey, null); }); // right-click empty stage => paste menu $('#stage-host').addEventListener('contextmenu',ev=>{ if(['stage','stage-host','stage-scaler'].includes(ev.target.id) && P){ ev.preventDefault(); showContextMenu(ev.clientX, ev.clientY, [ { icon:'📌', label:tr('Pegar'), key:'Ctrl+V', disabled:!getClipEl(), action:()=>pasteElement(ev.clientX,ev.clientY) } ]); } }); // close the context menu on any click / scroll / resize / escape document.addEventListener('click',closeContextMenu); document.addEventListener('scroll',closeContextMenu,true); window.addEventListener('resize',closeContextMenu); // share the copy/paste clipboard across tabs (other tab copied) window.addEventListener('storage',ev=>{ if(ev.key===CLIP_EL) clipboardEl = ev.newValue?JSON.parse(ev.newValue):null; if(ev.key===CLIP_SCR) clipboardScreen = ev.newValue?JSON.parse(ev.newValue):null; }); // keyboard document.addEventListener('keydown',ev=>{ if(ev.key==='Escape') closeContextMenu(); // undo/redo + copy/paste/duplicate (not while typing in a text field) if((ev.ctrlKey||ev.metaKey) && !['INPUT','TEXTAREA'].includes(document.activeElement.tagName)){ const k=ev.key.toLowerCase(); if(k==='z'){ ev.preventDefault(); ev.shiftKey?redo():undo(); return; } if(k==='y'){ ev.preventDefault(); redo(); return; } if(k==='c' && selEl){ ev.preventDefault(); copyElement(selEl); return; } if(k==='v' && getClipEl()){ ev.preventDefault(); pasteElement(); return; } if(k==='d' && selEl){ ev.preventDefault(); dupElement(); return; } } if(['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) return; if(!P||!selIds.length) return; const step=ev.shiftKey?5:0.5; let dx=0,dy=0; if(ev.key==='ArrowLeft'){dx=-step;ev.preventDefault();} else if(ev.key==='ArrowRight'){dx=step;ev.preventDefault();} else if(ev.key==='ArrowUp'){dy=-step;ev.preventDefault();} else if(ev.key==='ArrowDown'){dy=step;ev.preventDefault();} else if(ev.key==='Delete'||ev.key==='Backspace'){delElement();return;} else if(ev.key==='Escape'){setSel([]);renderStage();renderScreens();renderProps();return;} else return; selIds.forEach(id=>{ const e=elById(id); if(e){ e.x=+((e.x||0)+dx).toFixed(2); e.y=+((e.y||0)+dy).toFixed(2); } }); renderStage(); if(selIds.length===1) syncNum(); }); /* ============================ BOOT ============================ */ (async function boot(){ applyLang(); updateUndoRedo(); setInterval(autosaveTick, 1000); // autosave + history polling (saves once an action settles) // validate the stored session (if any) if(authToken){ const r=await fetch(API+'?action=me',{headers:{'X-Auth':authToken}}).then(x=>x.json()).catch(()=>null); if(r && r.success){ authUser=r.user; localStorage.setItem('gm_user',JSON.stringify(authUser)); } else if(r && !r.success){ authToken=''; authUser=null; localStorage.removeItem('gm_token'); localStorage.removeItem('gm_user'); } // if r===null (network/parse error) keep localStorage data and proceed } updateUserChrome(); if(!authToken){ showLogin(); return; } const ed=new URLSearchParams(location.search).get('edit'); if(!gameAccessOK()){ showLogin(); $('#loginError').textContent='Tu cuenta no tiene acceso a '+GAME_LABEL+'. Pide acceso al super admin o entra con otra cuenta.'; return; } if(ed) loadProject(ed); else openProjects(); })();