jarvisdevil.com

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>TMBuilder - "The Map" Customizer</title>
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css">
  <style>
    html,body{min-height:100vh;margin:0;padding:0;background:linear-gradient(180deg,#07101a 0%,#0b0f14 100%);background-attachment:fixed;color:#e6eef6;font-family:Inter, "Segoe UI", Roboto, system-ui}
    .bar{display:flex;gap:12px;align-items:center;padding:14px;background:rgba(255,255,255,0.02);border-bottom:1px solid rgba(255,255,255,0.01)}
    h1{margin:0;font-size:18px;font-weight:900;letter-spacing:0.2px}
    .spacer{flex:1}
    .btn{background:#ff7a18;border:0;color:#071014;padding:11px 14px;border-radius:10px;cursor:pointer;font-weight:900;font-size:15px;box-shadow:0 6px 12px rgba(255,122,24,0.08);touch-action:manipulation}
    .btn.ghost{background:transparent;color:#9aa7b2;border:1px solid rgba(255,255,255,0.03);box-shadow:none;font-weight:700}
    .container{display:flex;justify-content:center;padding:16px}
    .main{width:1100px;max-width:calc(100% - 32px)}
    .world{background:#0f1720;border:1px solid rgba(255,255,255,0.02);padding:12px;border-radius:10px;margin-bottom:12px}
    .hdr{display:flex;justify-content:space-between;gap:8px;align-items:center}
    .hdr .left{display:flex;gap:12px;align-items:center}
    .drag{cursor:grab;display:inline-block;width:18px;height:18px;line-height:18px;text-align:center;user-select:none}
    label{display:block;margin-top:10px;margin-bottom:6px;color:#9aa7b2;font-size:13px}
    input[type="text"],input[type="number"],input[type="file"],textarea{width:100%;padding:10px;border-radius:8px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.02);color:inherit;font-size:14px;box-sizing:border-box;outline:none}
    input[type="number"]{-moz-appearance:textfield}input[type="number"]::-webkit-outer-spin-button,input[type="number"]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
    .file-name{margin-top:6px;color:#cfe8ff;font-size:13px}
    .file-name .missing{color:#ffb8a0}
    table.levels{width:100%;border-collapse:collapse;margin-top:10px;font-size:14px}
    table.levels th,table.levels td{padding:8px 6px;border-bottom:1px dashed rgba(255,255,255,0.02);text-align:left;vertical-align:middle}
    .ye{width:100%;padding:6px 8px;border-radius:6px;background:transparent;border:1px solid rgba(255,255,255,0.02);color:inherit;font-size:13px;box-sizing:border-box}
    .kill-world{background:#e04b3b;color:#fff;border:0;padding:8px 10px;border-radius:8px;cursor:pointer;font-weight:700}
    .kill-lvl{background:transparent;color:#ff8a7a;border:0;cursor:pointer;font-size:16px}
    @media (max-width:720px){.main{width:calc(100% - 32px)}.btn{padding:12px 16px;font-size:16px}input[type="text"],input[type="number"]{padding:12px}}
  </style>
</head>
<body>
  <div class="bar">
    <h1>TMBuilder - "The Map" Customizer Tool</h1>
    <div class="spacer"></div>
    <button id="importJSON" class="btn ghost">Import JSON</button>
    <input id="thejson" type="file" accept="application/json" style="display:none">
    <button id="plusWorld" class="btn">+ World</button>
    <button id="zipitup" class="btn">Export .ZIP</button>
  </div>

  <div class="container">
    <div id="main" class="main"></div>
  </div>

  <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>

  <script>
  $(function(){
    const $main = $('#main'), $thejson = $('#thejson');
    let worlds = [];

    const uid = ()=> 'w-'+Math.random().toString(36).slice(2,9);
    const esc = s => String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
    const sanitize = s => String(s||'').replace(/[<>:"\/\\|?*\x00-\x1F]/g,'_').replace(/\s+/g,'_');
    const parseCoord = v=>{
      if (v==null) return 0;
      const s=String(v).trim(); if(!s) return 0;
      if(s.endsWith('%')){ const n=parseFloat(s.slice(0,-1)); return isNaN(n)?0:n/100; }
      const n=parseFloat(s); return isNaN(n)?0:n;
    };

    function readTbody($tbody){
      const arr=[];
      $tbody.children('tr').each(function(){
        const $r=$(this);
        arr.push({
          levelID: Number($r.find('.lv-id').val())||0,
          x: $r.find('.lv-x').val(),
          y: $r.find('.lv-y').val(),
          authorName: $r.find('.lv-author').val()||'',
          accountID: String($r.find('.lv-account').val())||0,
          userID: String($r.find('.lv-user').val())||0
        });
      });
      return arr;
    }

    function AMAZING(){
      $main.empty();
      worlds.forEach((w,i)=>{
        const rows = (w.levels||[]).map(lv=>`
          <tr>
            <td class="drag">≡</td>
            <td><input class="ye lv-id" type="number" value="${lv.levelID||0}"></td>
            <td><input class="ye lv-x" type="text" value="${esc(lv.x||0)}"></td>
            <td><input class="ye lv-y" type="text" value="${esc(lv.y||0)}"></td>
            <td><input class="ye lv-author" type="text" value="${esc(lv.authorName||'')}"></td>
            <td><input class="ye lv-account" type="number" value="${lv.accountID||0}"></td>
            <td><input class="ye lv-user" type="number" value="${lv.userID||0}"></td>
            <td><button class="kill-lvl">✕</button></td>
          </tr>`).join('');

        const card = `
          <div class="world" data-id="${w.id}">
            <div class="hdr">
              <div class="left"><span class="drag">≡</span><strong>World ${i+1}</strong></div>
              <div style="display:flex;gap:8px;align-items:center">
                <button class="btn add-level">+ Level</button>
                <button class="kill-world">Remove</button>
              </div>
            </div>

            <label>Title (required)</label>
            <input class="title" type="text" value="${esc(w.title)}" placeholder="Title">

            <label>Short text (optional)</label>
            <input class="shortText" type="text" value="${esc(w.shortText)}" placeholder="">

            <label>Background (PNG — required)</label>
            <input class="bgInput" type="file" accept="image/png">
            <div class="file-name bg-name">${w.backgroundFile ? esc(w.backgroundFile.name) : (w.bgFile ? esc(w.bgFile) : '<span class="missing">no background</span>')}</div>

            <label>Music (MP3 — required)</label>
            <input class="mp3Input" type="file" accept="audio/mpeg">
            <div class="file-name music-name">${w.musicFile ? esc(w.musicFile.name) : (w.musicName ? esc(w.musicName) : '<span class="missing">no music</span>')}</div>

            <div style="margin-top:10px;font-weight:600">Levels</div>

            <table class="levels">
              <thead><tr><th></th><th>levelID</th><th>x</th><th>y</th><th>author</th><th>accountID</th><th>userID</th><th></th></tr></thead>
              <tbody class="levels-body">${rows}</tbody>
            </table>
          </div>`;
        $main.append(card);
      });

      $main.find('.levels-body').each(function(){
        const $tb=$(this);
        new Sortable(this,{handle:'.drag',animation:150,onEnd:()=>{ const id=$tb.closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w) w.levels = readTbody($tb); AMAZING(); }});
      });

      new Sortable($main[0],{handle:'.drag',animation:150,onEnd:()=>{
        const ids=$main.children().map(function(){return $(this).attr('data-id');}).get();
        worlds = ids.map(id=>worlds.find(w=>w.id===id));
        AMAZING();
      }});
    }

    function bobTheBuilder(){ return worlds.map(w=>({
      title: w.title||'',
      shortText: w.shortText||'',
      background: w.bgFile||'',
      music: w.musicName||'',
      levels: (w.levels||[]).map((lv,idx)=>({
        levelID: Number(lv.levelID)||0,
        x: parseCoord(lv.x),
        y: parseCoord(lv.y),
        order: idx+1,
        authorName: lv.authorName||'',
        accountID: String(lv.accountID)||0,
        userID: String(lv.userID)||0
      }))
    })); }

    async function exportZip(){
      if(!Array.isArray(worlds)||!worlds.length){ alert('Nothing to export.'); return; }
      for(let i=0;i<worlds.length;i++){
        const w=worlds[i];
        if(!w.title||!w.title.trim()){ alert('World '+(i+1)+' title is required.'); return; }
        if(!w.backgroundFile){ if(w.bgFile) alert('World '+(i+1)+' expecting background file: '+w.bgFile+'.'); else alert('World '+(i+1)+' background PNG is required.'); return; }
        if(!/\.png$/i.test(w.backgroundFile.name)){ alert('World '+(i+1)+': background must be a PNG file, Geometry Dash does not support other image formats.'); return; }
        if(!w.musicFile){ if(w.musicName) alert('World '+(i+1)+' expecting music file: '+w.musicName+'.'); else alert('World '+(i+1)+' music MP3 is required.'); return; }
        if(!/\.mp3$/i.test(w.musicFile.name)){ alert('World '+(i+1)+': music must be an MP3 file, Geometry Dash does not support any other audio formats.'); return; }
      }

      const zip = new JSZip();
      const map = bobTheBuilder();
      const e = {};
      for(let i=0;i<worlds.length;i++){
        const w=worlds[i];
        let b = sanitize(w.backgroundFile.name), m = sanitize(w.musicFile.name);
        if(e[b]){ e[b]++; b=b.replace(/(\.[^.]+)?$/,'_'+e[b]+'$1'); } else e[b]=1;
        if(e[m]){ e[m]++; m=m.replace(/(\.[^.]+)?$/,'_'+e[m]+'$1'); } else e[m]=1;
        zip.folder('backgrounds').file(b, w.backgroundFile);
        zip.folder('music').file(m, w.musicFile);
        map[i].background = b; map[i].music = m;
      }
      zip.file('map.json', JSON.stringify(map,null,2));
      const blob = await zip.generateAsync({type:'blob'});
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a'); a.href = url; a.download = 'custom.zip'; document.body.appendChild(a); a.click(); a.remove();
      URL.revokeObjectURL(url);
    }

    function importJson(file){
      const r = new FileReader();
      r.onload = ()=> {
        try{
          const parsed = JSON.parse(r.result);
          if(!Array.isArray(parsed)){ alert('Invalid JSON: expected an array of worlds.'); return; }
          parsed.forEach(obj=>{
            const w = { id: uid(), title: obj.title?String(obj.title):'', shortText: obj.shortText?String(obj.shortText):'', backgroundFile:null, bgFile: obj.background?String(obj.background):'', musicFile:null, musicName: obj.music?String(obj.music):'', levels:[] };
            if(Array.isArray(obj.levels)) obj.levels.forEach(lv=> w.levels.push({
              levelID: Number(lv.levelID)||0, x: lv.x===undefined?0:lv.x, y: lv.y===undefined?0:lv.y,
              authorName: lv.authorName?String(lv.authorName):'', accountID: String(lv.accountID)||0, userID: String(lv.userID)||0
            }));
            worlds.push(w);
          });
          AMAZING();
          alert('Imported '+parsed.length+' world(s)! But... You need to reupload backgrounds and music for all worlds.');
        }catch(e){ alert('Failed to parse JSON: '+(e&&e.message?e.message:e)); }
      };
      r.onerror = ()=> alert('Failed to read file.');
      r.readAsText(file);
    }

    $main.on('input','input.title',function(){ const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w) w.title=$(this).val(); });
    $main.on('input','input.shortText',function(){ const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w) w.shortText=$(this).val(); });
    $main.on('input','.levels-body input',function(){ const $tb=$(this).closest('.levels-body'); const id=$tb.closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w) w.levels = readTbody($tb); });
    $main.on('change','.bgInput',function(){ const f=this.files&&this.files[0]?this.files[0]:null; const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(!w) return; if(f){ w.backgroundFile=f; w.bgFile=sanitize(f.name); $(this).siblings('.bg-name').text(f.name); } else { w.backgroundFile=null; } });
    $main.on('change','.mp3Input',function(){ const f=this.files&&this.files[0]?this.files[0]:null; const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(!w) return; if(f){ w.musicFile=f; w.musicName=sanitize(f.name); $(this).siblings('.music-name').text(f.name); } else { w.musicFile=null; } });

    $main.on('click','.add-level',function(){ const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(w){ if(!Array.isArray(w.levels)) w.levels=[]; w.levels.push({levelID:0,x:0,y:0,authorName:'',accountID:0,userID:0}); AMAZING(); } });
    $main.on('click','.kill-world',function(){ if(!confirm('Remove this world?')) return; const id=$(this).closest('.world').attr('data-id'); const idx=worlds.findIndex(x=>x.id===id); if(idx>=0){ worlds.splice(idx,1); AMAZING(); } });
    $main.on('click','.kill-lvl',function(){ const $tr=$(this).closest('tr'); const id=$(this).closest('.world').attr('data-id'); const w=worlds.find(x=>x.id===id); if(!w) return; const idx=$tr.index(); if(idx>=0){ w.levels.splice(idx,1); AMAZING(); } });

    $('#plusWorld').on('click',function(){ worlds.push({ id: uid(), title:'', shortText:'', backgroundFile:null, bgFile:'', musicFile:null, musicName:'', levels:[] }); AMAZING(); setTimeout(()=>window.scrollTo({top:document.body.scrollHeight,behavior:'smooth'}),80); });
    $('#zipitup').on('click',()=> exportZip().catch(err=>alert('Export failed: '+err)));
    $('#importJSON').on('click',()=>{ $thejson.val(null); $thejson.trigger('click'); });
    $thejson.on('change',e=>{ const f=e.target.files&&e.target.files[0]; if(f) importJson(f); });

    AMAZING();
  });
  </script>
</body>
</html>