const operations = { schematic: { endpoint:'/api/v1/schematics/render', suffix:'.SchDoc', label:'Render schematic', fileLabel:'Schematic file', comparison:false }, 'schematic-comparison': { endpoint:'/api/v1/comparisons', suffix:'.SchDoc', label:'Compare schematics', comparison:true }, pcb: { endpoint:'/api/v1/pcbs/render', suffix:'.PcbDoc', label:'Render PCB', fileLabel:'PCB file', comparison:false }, 'pcb-comparison': { endpoint:'/api/v1/pcbs/comparisons', suffix:'.PcbDoc', label:'Compare PCBs', comparison:true } }; const form=document.querySelector('#upload'), result=document.querySelector('#result'); const operation=document.querySelector('#operation'), button=document.querySelector('#submit-upload'); const fields=document.querySelector('#upload-fields'), viewerLink=document.querySelector('#viewer-link'); const details=document.querySelector('#response-details'), json=document.querySelector('#response-json'); let polling=null, uploading=false; function reset(){ polling?.abort();polling=null;result.textContent='';viewerLink.replaceChildren();details.hidden=true;json.textContent=''; } function configure(){ if(uploading)return; reset();const config=operations[operation.value]; document.querySelector('#selected-endpoint').textContent=`POST ${config.endpoint}`; document.querySelector('#single-input').hidden=config.comparison; document.querySelector('#comparison-inputs').hidden=!config.comparison; document.querySelector('#file-label').textContent=config.fileLabel||'Schematic file'; for(const [selector,active]of [['#file',!config.comparison],['#old-file',config.comparison],['#new-file',config.comparison]]){ const input=document.querySelector(selector);input.accept=config.suffix;input.required=active;input.disabled=!active;input.value=''; } button.textContent=config.label+' ↗'; for(const choice of document.querySelectorAll('[data-operation]'))choice.setAttribute('aria-pressed',String(choice.dataset.operation===operation.value)); } operation.addEventListener('change',configure); for(const choice of document.querySelectorAll('[data-operation]'))choice.addEventListener('click',()=>{ if(uploading)return;operation.value=choice.dataset.operation;configure();operation.focus(); }); configure(); function showResponse(body){json.textContent=JSON.stringify(body,null,2);details.hidden=false;} async function decode(response){ let body; try{body=await response.json();}catch{throw new Error(`Server returned HTTP ${response.status} without a JSON response.`);} if(!response.ok)throw new Error(body.error?.message||`Request failed (HTTP ${response.status}).`); return body; } function pause(signal){ return new Promise((resolve,reject)=>{ const abort=()=>{clearTimeout(timer);reject(new DOMException('Stopped','AbortError'));}; const timer=setTimeout(()=>{signal.removeEventListener('abort',abort);resolve();},1000); signal.addEventListener('abort',abort,{once:true}); if(signal.aborted)abort(); }); } async function poll(created,controller){ const headers=created.viewer_token?{'X-Viewer-Token':created.viewer_token}:{}; const status=new URL(created.status_url,location.origin); if(status.origin!==location.origin)throw new Error('Status URL must use this service origin. Check PUBLIC_BASE_URL.'); for(;;){ await pause(controller.signal); const body=await decode(await fetch(status.pathname+status.search,{headers,signal:controller.signal,cache:'no-store'})); if(controller.signal.aborted)return; showResponse(body); if(body.status==='failed')throw new Error(body.error?.message||'Processing failed.'); if(body.status==='completed'){ const summary=body.summary?` ${Object.entries(body.summary).map(([name,count])=>`${count} ${name}`).join(' · ')}.`:''; result.textContent=`Completed.${summary} Open the viewer below.`;return; } result.textContent=body.status==='processing'?'Processing your files…':'Queued. Waiting for a worker…'; } } form.addEventListener('submit',async event=>{ event.preventDefault();if(uploading)return;reset(); const config=operations[operation.value],data=new FormData(); const inputs=config.comparison?[['old_file','#old-file'],['new_file','#new-file']]:[['file','#file']]; for(const [name,selector]of inputs){ const file=document.querySelector(selector).files[0]; if(!file||!file.name.toLowerCase().endsWith(config.suffix.toLowerCase())){result.textContent=`Select ${config.suffix} files for this operation.`;return;} data.append(name,file); } data.append('access',document.querySelector('#access').value); const expiry=document.querySelector('#expires-in').value; if(expiry){if(!/^\d+$/.test(expiry)||Number(expiry)<1){result.textContent='Expiry must be a positive whole number of seconds.';return;}data.append('expires_in',expiry);} const headers={},key=document.querySelector('#key').value;if(key)headers['X-API-Key']=key; uploading=true;button.disabled=true;fields.disabled=true;result.textContent='Uploading…'; try{ const created=await decode(await fetch(config.endpoint,{method:'POST',headers,body:data}));showResponse(created); const url=new URL(created.viewer_url||created.url,location.origin); if(url.origin!==location.origin)throw new Error('Viewer URL must use this service origin. Check PUBLIC_BASE_URL.'); const link=document.createElement('a');link.href=url.href;link.target='_blank';link.rel='noopener noreferrer';link.textContent=config.comparison?'Open comparison ↗':'Open viewer ↗';viewerLink.append(link); result.textContent='Queued. You can open the viewer while processing continues.'; const controller=new AbortController();polling=controller; poll(created,controller).catch(error=>{if(error.name!=='AbortError'&&!controller.signal.aborted)result.textContent=`${error.message} The accepted upload is not retried automatically.`;}); }catch(error){result.textContent=`${error.message} No automatic upload retry was made.`;} finally{uploading=false;button.disabled=false;fields.disabled=false;} }); addEventListener('pagehide',()=>polling?.abort(),{once:true});