syntheogenesis / dee /static /workspaces.js
github-actions[bot]
Deploy 46ff792
5e40a55
Raw History Blame Contribute Delete
32.8 kB
/* Private shared projects. No polling, persistent sequence cache or tool calls on open. */
(function () {
'use strict';
// The site that wraps the engine. Sign-in and sign-up live there, never
// inside this frame, and are opened in the TOP window.
var SITE = 'https://turingdna.com';
var KIND = {library:'Variant library', plasmid:'Plasmid', crispr:'CRISPR design', primer:'Primer set'};
var EMAIL = {queued:'Email queued', sent:'Email sent', failed:'Email failed — check the address and resend', opened:'Opened the invitation link'};
var OUTCOMES = ['measured','worked','partial','failed'];
var dialog, body, status, current, generation = 0, dirty = false;
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
function role(r) { return r === 'edit' ? 'Editor' : r === 'owner' ? 'Owner' : 'Viewer'; }
function day(t) { var d = new Date(t); return isNaN(d) ? '' : d.toLocaleDateString(undefined, {day:'numeric', month:'short', year:'numeric'}); }
function button(text, action, value) { return '<button type="button" data-ws="' + action + '" data-value="' + esc(value || '') + '">' + esc(text) + '</button>'; }
async function api(id, action, data) {
var options = action ? {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(Object.assign({}, data, {action:action}))} : {};
var r = await fetch('/api/workspaces' + (id ? '/' + encodeURIComponent(id) : ''), options);
var d = await r.json();
if (!r.ok) throw new Error(d.error || 'Could not load this project.');
return d;
}
async function benchApi(id, rows, libraryId) {
// The server checks library_id against the one the project links and
// writes that one; sending it is how a stale page fails loudly
// instead of recording against a library the project has moved off.
var r = await fetch('/api/workspaces/' + encodeURIComponent(id) + '/measurements', rows ?
{method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({measurements:rows, library_id:libraryId})} : {});
var d = await r.json();
if (!r.ok) throw new Error(d.error || 'Could not load bench results.');
return d;
}
function message(text) { status.textContent = text || ''; }
function saveFile(blob, filename) {
var url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = filename; a.click();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
function stem(name, kind) { return String(name || kind).replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 60) || kind; }
/* Spreadsheet-safe CSV. A text cell that starts with = + - @ is read as a
formula by Excel and Sheets; a shared result is somebody else's data,
so those cells are quoted inert. Numbers pass through untouched. */
function csv(rows) {
var keys = [];
rows.forEach(function (r) { Object.keys(r || {}).forEach(function (k) { if (keys.indexOf(k) < 0 && (r[k] == null || typeof r[k] !== 'object')) keys.push(k); }); });
function cell(v) {
if (v == null) return '';
if (typeof v !== 'number' && /^[=+\-@]/.test(String(v)) && isNaN(Number(v))) v = "'" + v;
v = String(v);
return /[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v;
}
return [keys.map(cell).join(',')].concat(rows.map(function (r) { return keys.map(function (k) { return cell(r[k]); }).join(','); })).join('\r\n');
}
/* Each result in the format the next tool expects: GenBank for a plasmid
(the engine's own exporter, so features survive), CSV for anything
tabular, JSON only when the result has no table in it. */
async function download(id, kind, artifactId, name) {
var d = await api(id, 'artifact', {kind:kind, artifact_id:artifactId}), a = d.artifact, base = stem(name || a.name || a.label, kind);
if (kind === 'plasmid' && a.sequence) {
var r = await fetch('/api/plasmid/export', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:a.name || base, sequence:a.sequence, topology:a.topology, features:a.features || [], format:'genbank'})});
if (!r.ok) { var e = {}; try { e = await r.json(); } catch (_) {} throw new Error(e.error || 'Could not export this plasmid.'); }
saveFile(await r.blob(), base + '.gb'); return 'GenBank file downloaded.';
}
var rows = kind === 'library' ? a.variants : kind === 'crispr' ? a.guides : null;
if (Array.isArray(rows) && rows.length && rows[0] && typeof rows[0] === 'object') {
saveFile(new Blob([csv(rows)], {type:'text/csv'}), base + '.csv'); return 'CSV downloaded.';
}
saveFile(new Blob([JSON.stringify(a, null, 2)], {type:'application/json'}), base + '.json'); return 'Result downloaded.';
}
function mount() {
if (dialog) return;
dialog = document.createElement('dialog'); dialog.className = 'ws-dialog'; dialog.setAttribute('aria-labelledby','wsTitle');
dialog.innerHTML = '<header><div><span class="ws-eyebrow">Private collaboration</span><h2 id="wsTitle">Project workspace</h2></div>' + button('Close','close') + '</header><p class="ws-status" role="status" aria-live="polite"></p><div class="ws-body"></div>';
document.body.appendChild(dialog); body = dialog.querySelector('.ws-body'); status = dialog.querySelector('.ws-status');
dialog.addEventListener('cancel', function (e) { if (dirty && !window.confirm('Discard this unsaved draft?')) e.preventDefault(); });
dialog.addEventListener('close', function () { if(dialog.open) return; generation++; dirty = false; current = null; body.replaceChildren(); });
body.addEventListener('input', function (e) { if (e.target.closest('#wsEdit')) dirty = true; });
dialog.addEventListener('click', async function (e) {
var b = e.target.closest('[data-ws]'); if (!b || b.disabled) return;
var action = b.dataset.ws, value = b.dataset.value, id = current && current.id;
if (dirty && ['role','revoke','open','contribute','leave','resend','open-plasmid'].indexOf(action)!==-1 && !window.confirm('Discard this unsaved draft?')) return;
message(''); b.disabled = true;
try {
if (action === 'close') { if (!dirty || window.confirm('Discard this unsaved draft?')) dialog.close(); }
if (action === 'list') { if (!dirty || window.confirm('Discard this unsaved draft?')) await list(); }
if (action === 'open') await openProject(value);
if (action === 'reload') { if (!dirty || window.confirm('Discard this draft and load the latest revision?')) await openProject(id); }
if (action === 'accept') {
// Straight into the project: accepting and then hunting for
// it in a list is one step too many for somebody new here.
var accepted = await api(null,'respond',{invitation_id:value,response:'accept'});
if (accepted.project_id) { await openProject(accepted.project_id); message('You joined this project.'); }
else await list();
}
if (action === 'decline') { await api(null,'respond',{invitation_id:value,response:'decline'}); await list(); message('Invitation declined.'); }
if (action === 'revoke') { await api(id,'revoke',{member_id:value}); await openProject(id); message('Access revoked. A pending invitation email is withdrawn too.'); }
if (action === 'role') { await api(id,'role',{member_id:value,permission:b.dataset.permission}); await openProject(id); }
if (action === 'resend') {
if (!window.confirm('Send the invitation to ' + value + ' again? It will last another 30 days.')) return;
var again = await api(id,'invite',{email:value, permission:b.dataset.permission, consent:true});
await openProject(id);
message(again && again.invite_emailed ? 'Invitation sent again.' : 'Invitation renewed, but the email could not be queued. They can still find it under Shared projects in Turing.');
}
if (action === 'leave') {
if (!window.confirm('Leave this project? You will lose access to its sequences and results until the owner invites you again.')) return;
await api(id,'leave'); await list(); message('You left the project.');
}
if (action === 'artifact') {
var d = await api(id,'artifact',{kind:b.dataset.kind,artifact_id:value});
var panel=document.getElementById('wsResult');
panel.innerHTML='<h3>'+esc(b.dataset.name||d.artifact.name||d.artifact.label||KIND[d.kind]||'Result')+'</h3>'+renderResult(d);
panel.scrollIntoView({block:'nearest',behavior:'smooth'});
}
if (action === 'download') message(await download(id, b.dataset.kind, value, b.dataset.name));
if (action === 'open-plasmid') {
// A working copy under the acting account, like "Use saved
// input": saving it never writes back to its author.
var pl = await api(id,'artifact',{kind:'plasmid',artifact_id:value});
if (!window.TDPlasmid || !window.TDPlasmid.openRecord || !window.TDPlasmid.openRecord({
name:(pl.artifact.name || 'Shared plasmid') + ' (shared)', sequence:pl.artifact.sequence,
topology:pl.artifact.topology, features:pl.artifact.features})) throw new Error('Could not open this plasmid in the editor.');
dialog.close();
}
if (action === 'use') {
// Re-authorize on every handoff. Runs stay on the acting user's
// account; new outputs are private until explicitly contributed.
var latest = await api(id); var p = latest.project;
if (!p.sequence_dna && !p.wt_protein) throw new Error('This project has no input sequence.');
if (dirty && !window.confirm('Use the saved input? Your unsaved draft will be discarded.')) return;
await window.TDNavigate.workspaceInput(p); dialog.close();
}
if (action === 'contribute') await contributePicker();
} catch (err) { message(err.message); }
finally { b.disabled = false; }
});
dialog.addEventListener('submit', async function (e) {
e.preventDefault(); var form=e.target, b=form.querySelector('[type=submit]'); if (!b || b.disabled) return;
if(form.id!=='wsEdit' && dirty && !window.confirm('Discard this unsaved project draft?')) return;
b.disabled=true; message(''); var data=Object.fromEntries(new FormData(form)), id=current.id;
try {
if (form.id==='wsEdit') { await api(id,'save',Object.assign(data,{revision:current.revision})); dirty=false; await openProject(id); message('Project saved.'); }
if (form.id==='wsInvite') { var inv=await api(id,'invite',Object.assign(data,{consent:form.elements.consent.checked})); await openProject(id); message(inv && inv.invite_emailed ? 'Invitation sent. They will get an email with a link; it expires in 30 days.' : 'Invitation added, but the email could not be queued. They can still find it under Shared projects in Turing.'); }
if (form.id==='wsContribute') {
var parts=data.result.split(':'); await api(id,'contribute',{kind:parts[0],artifact_id:parts[1],revision:current.revision,consent:form.elements.consent.checked});
await openProject(id); message('Result added to this project.');
}
if (form.id==='wsMeasure') {
var rows = parseBench(data.rows);
var saved = await benchApi(id, rows, current.library_id);
await loadBench(id);
message('Recorded ' + saved.n + ' measurement' + (saved.n === 1 ? '' : 's') + '. Everyone in the project can see them.');
}
} catch (err) { message(err.message); }
finally { b.disabled=false; }
});
}
/* `focus` is the project an emailed link pointed at. Its invitation goes
first, as the one thing on the page, because the person who clicked
the email came to answer it -- not to read a list. */
async function list(focus) {
var ticket=++generation; dirty=false; current=null; message('Loading projects…');
var d=await api(); if(ticket!==generation || !dialog.open) return;
message('');
if (focus) {
var mine = (d.projects||[]).some(function(p){return p.id===focus;});
var inv = (d.invitations||[]).filter(function(i){return i.construct_id===focus;})[0];
if (!inv && mine) return openProject(focus);
if (inv) { body.innerHTML = invitationCard(inv); return; }
message('This invitation has been accepted, declined or withdrawn, or it has expired. Ask the person who sent it to invite you again.');
}
body.innerHTML='<p>Invite researchers to a specific project. Sequences and linked results are visible only to accepted collaborators.</p>' +
(d.invitations.length ? '<h3>Invitations</h3>' + d.invitations.map(function(i){return '<article class="ws-row"><div><strong>'+esc(i.project_name || 'A shared project')+'</strong><small>From '+esc(i.invited_by || 'a researcher')+' · '+role(i.permission)+' access · expires '+esc(day(i.expires_at))+'</small></div><div>'+button('Accept','accept',i.id)+button('Decline','decline',i.id)+'</div></article>';}).join('') : '') +
'<h3>Your projects and shared work</h3>'+(d.projects.length ? d.projects.map(function(p){return '<article class="ws-row"><div><strong>'+esc(p.name)+'</strong><small>'+role(p.role)+' · '+esc(p.phase)+(p.shared?' · shared':'')+(p.expires_at?' · deleted '+esc(day(p.expires_at)):'')+'</small></div>'+button('Open','open',p.id)+'</article>';}).join('') : '<p>Save a project in the engine to invite collaborators.</p>');
}
function invitationCard(i) {
return '<section class="ws-invite" aria-labelledby="wsInviteTitle">' +
'<p class="ws-eyebrow">Invitation</p>' +
'<h3 id="wsInviteTitle">'+esc(i.invited_by || 'A researcher')+' invited you to '+esc(i.project_name || 'a project')+'</h3>' +
'<p>'+(i.permission==='edit' ? 'As an <strong>editor</strong> you can update the project input, add your own results and record bench measurements.' : 'As a <strong>viewer</strong> you can see and download the sequence and results, and record bench measurements.')+'</p>' +
'<p class="ws-note">Nothing in your own account is shared by joining. Runs you start stay private until you add them to the project. Expires '+esc(day(i.expires_at))+'.</p>' +
'<div class="ws-actions">'+button('Accept and open','accept',i.id)+button('Decline','decline',i.id)+button('All projects','list')+'</div>' +
'</section>';
}
function expiryBanner(p) {
if (!p.expires_at) return '';
var when = esc(day(p.expires_at));
return '<p class="ws-banner" role="note">'+(p.role==='owner'
? 'This project is on the free plan and will be deleted on <strong>'+when+'</strong>, with everyone’s access to it. <a href="'+SITE+'/pricing/" target="_top" rel="noopener">Keep it with a paid plan</a> — paid work never expires.'
: 'This project is on its owner’s free plan and will be deleted on <strong>'+when+'</strong>. Download anything you need, or ask the owner to keep it.')+'</p>';
}
async function openProject(id) {
var ticket=++generation; message('Loading project…');
var d=await api(id); if(ticket!==generation || !dialog.open) return;
current=d.project; dirty=false; var p=current, editable=p.role!=='read';
var attrs=editable?'':' readonly';
body.innerHTML='<div class="ws-actions">'+button('All projects','list')+button('Reload latest','reload')+(p.role!=='owner'?button('Leave project','leave'):'')+'</div><h3>'+esc(p.name)+'</h3><p>'+role(p.role)+' · revision '+esc(p.revision)+(p.owner_name&&p.role!=='owner'?' · owned by '+esc(p.owner_name):'')+' · private</p>'+expiryBanner(p)+
'<form id="wsEdit"><label>Project name<input name="name" maxlength="120" required value="'+esc(p.name)+'"'+attrs+'></label><label>Phase<select name="phase"'+(editable?'':' disabled')+'>'+['Design','Build','Edit','Learn'].map(function(x){return '<option'+(p.phase===x?' selected':'')+'>'+x+'</option>';}).join('')+'</select></label>'+
'<details><summary>Project input sequences</summary><label>DNA<textarea name="sequence_dna" spellcheck="false"'+attrs+'>'+esc(p.sequence_dna)+'</textarea></label><label>Protein<textarea name="wt_protein" spellcheck="false"'+attrs+'>'+esc(p.wt_protein)+'</textarea></label><small>If you change DNA, update its protein separately where appropriate. Existing results retain their original inputs.</small></details>'+
(editable?'<button type="submit">Save changes</button>':'')+'</form><div class="ws-actions">'+button('Use saved input in engine','use')+(editable?button('Add a saved result','contribute'):'')+'</div><p class="ws-note">Engine runs use your own account and credits. New results remain private until you add them here. Private chats and unrelated work are never shared.</p><div id="wsContribution"></div>'+
'<h3>Shared results</h3>'+(Array.isArray(d.results) ? namedResults(d.results) : '<div class="ws-results">'+artifacts(p)+'</div>')+'<div id="wsResult"></div>'+
'<h3>Bench results</h3><div id="wsBench">'+(p.library_id ? '<p class="ws-note">Loading bench results…</p>' : '<p class="ws-note">Bench results can be recorded once the project has a variant library.</p>')+'</div>'+
'<h3>People with access</h3><p>'+(p.owner_name?esc(p.owner_name)+' · ':'')+'Project owner · full control</p>'+d.members.map(function(m){
return '<article class="ws-row"><div><strong>'+esc(m.display_name||m.invited_email||'Collaborator')+'</strong><small>'+role(m.permission)+' · '+esc(m.state)+(m.state==='pending'&&m.expires_at?' · expires '+esc(day(m.expires_at)):'')+(m.email_status?' · '+esc(EMAIL[m.email_status]||m.email_status):'')+'</small></div>'+(p.role==='owner'?'<div>'+
(m.state!=='expired'?'<button type="button" data-ws="role" data-value="'+esc(m.id)+'" data-permission="'+(m.permission==='read'?'edit':'read')+'">Make '+(m.permission==='read'?'editor':'viewer')+'</button>':'')+
((m.state==='pending'||m.state==='expired')&&m.invited_email?'<button type="button" data-ws="resend" data-value="'+esc(m.invited_email)+'" data-permission="'+esc(m.permission)+'">Resend</button>':'')+
button('Revoke','revoke',m.id)+'</div>':'')+'</article>';
}).join('')+(p.role==='owner'?'<details><summary>Invite a researcher</summary><form id="wsInvite"><label>Email<input type="email" name="email" maxlength="254" required autocomplete="off"></label><label>Access<select name="permission"><option value="read">Viewer — read and download</option><option value="edit">Editor — update input and add results</option></select></label><label class="ws-consent"><input name="consent" type="checkbox" required> I permit this researcher to access and use this project, its sequences and linked results, with the selected permissions.</label><p class="ws-note">We email them a link. It only works for this address, after they verify it, and it expires in 30 days. Someone without an account is taken to sign-up first.</p><button type="submit">Invite</button></form></details>':'')+
'<h3>Project activity</h3>'+(d.activity.length?'<ol>'+d.activity.map(function(a){return '<li>'+esc(a.display_name||'Researcher')+' · '+esc({save:'updated the project',invite:'invited a collaborator',role:'changed access',revoke:'revoked access',contribute:'added a result',measure:'recorded bench results',leave:'left the project',invitation_accept:'accepted an invitation',invitation_decline:'declined an invitation'}[a.kind]||a.kind)+'<small>'+esc(new Date(a.created_at).toLocaleString())+'</small></li>';}).join('')+'</ol>':'<p>No collaboration activity yet.</p>');
message('');
if (p.library_id) loadBench(id).catch(function (e) { var box=document.getElementById('wsBench'); if (box && current && current.id===id) box.innerHTML='<p class="ws-note">'+esc(e.message)+'</p>'; });
}
function namedResults(results) {
if (!results.length) return '<p>No linked results yet.</p>';
return results.map(function(r){
var label = r.kind==='plasmid' ? 'GenBank' : (r.kind==='library'||r.kind==='crispr') ? 'CSV' : 'JSON';
var attrs = ' data-value="'+esc(r.id)+'" data-kind="'+esc(r.kind)+'" data-name="'+esc(r.name)+'"';
return '<article class="ws-row"><div><strong>'+esc(r.name)+'</strong><small>'+esc(KIND[r.kind]||r.kind)+(r.added_by?' · added by '+esc(r.mine?'you':r.added_by):'')+(r.created_at?' · '+esc(day(r.created_at)):'')+'</small></div><div>'+
'<button type="button" data-ws="artifact"'+attrs+'>View</button>'+
'<button type="button" data-ws="download"'+attrs+' aria-label="Download '+esc(r.name)+' as '+label+'">'+label+'</button>'+
(r.kind==='plasmid'?'<button type="button" data-ws="open-plasmid"'+attrs+'>Open in plasmid editor</button>':'')+
'</div></article>';
}).join('');
}
function artifacts(p) {
var rows=[]; [['library',p.library_id?[p.library_id]:[]],['plasmid',p.plasmid_ids],['crispr',p.crispr_ids],['primer',p.primer_ids]].forEach(function(pair){(pair[1]||[]).forEach(function(id,i){ rows.push('<span><button type="button" data-ws="artifact" data-value="'+esc(id)+'" data-kind="'+pair[0]+'">View '+pair[0]+' result '+(i+1)+'</button> <button type="button" data-ws="download" data-value="'+esc(id)+'" data-kind="'+pair[0]+'" aria-label="Download '+pair[0]+' result '+(i+1)+'">Download</button></span>'); });});
return rows.join('')||'<p>No linked results yet.</p>';
}
function renderResult(d) {
var a=d.artifact, rows=a.variants||a.guides||a.features, h='';
if(Array.isArray(rows)&&rows.length&&typeof rows[0]==='object'&&rows[0]) {
var keys=Object.keys(rows[0]).filter(function(k){return typeof rows[0][k]!=='object';}).slice(0,8);
h='<p>'+esc(rows.length)+' recorded entries · showing up to 30</p><div class="ws-table"><table><thead><tr>'+keys.map(function(k){return '<th>'+esc(k.replace(/_/g,' '))+'</th>';}).join('')+'</tr></thead><tbody>'+rows.slice(0,30).map(function(r){return '<tr>'+keys.map(function(k){return '<td>'+esc(r[k])+'</td>';}).join('')+'</tr>';}).join('')+'</tbody></table></div>';
}
if(a.sequence) h+='<details><summary>Construct sequence</summary><textarea readonly aria-label="Construct sequence">'+esc(a.sequence)+'</textarea></details>';
if(a.result) h+='<pre class="ws-result-data">'+esc(JSON.stringify(a.result,null,2))+'</pre>';
return h||'<p>Download the recorded result to inspect its complete data.</p>';
}
/* One measurement per line: variant, value, outcome. Read from the right,
because a variant can itself contain commas ("S1109T,W1126L") and the
last two fields cannot: an outcome is one of four words and a value is
a number. A header row is skipped. */
function parseBench(text) {
var rows = [];
String(text || '').split(/\r?\n/).forEach(function (line, n) {
var parts = line.split(line.indexOf('\t') >= 0 ? '\t' : ',').map(function (s) { return s.trim(); });
while (parts.length && parts[parts.length - 1] === '') parts.pop();
if (!parts.length || !parts.join('')) return;
var outcome = 'measured', value = null;
if (parts.length > 1 && OUTCOMES.indexOf(parts[parts.length - 1].toLowerCase()) >= 0) outcome = parts.pop().toLowerCase();
if (parts.length > 1 && parts[parts.length - 1] !== '' && !isNaN(Number(parts[parts.length - 1]))) value = Number(parts.pop());
var mutations = parts.join(',').trim();
if (n === 0 && value === null && /mutation|variant/i.test(mutations)) return;
if (!mutations) throw new Error('Line ' + (n + 1) + ' has no variant.');
rows.push({mutations: mutations, measured_value: value, outcome: outcome});
});
if (!rows.length) throw new Error('Paste at least one measurement: variant, value, outcome.');
return rows;
}
async function loadBench(id) {
var d = await benchApi(id), box = document.getElementById('wsBench');
if (!box || !current || current.id !== id) return;
var rows = d.measurements || [], mine = rows.filter(function (m) { return m.mine; });
var people = {}; rows.forEach(function (m) { people[m.measured_by_name || 'someone'] = 1; });
box.innerHTML = (rows.length
? '<p>'+rows.length+' measurement'+(rows.length===1?'':'s')+' from '+Object.keys(people).length+' '+(Object.keys(people).length===1?'person':'people')+' · newest first'+(rows.length>50?' · showing 50':'')+'</p>' +
'<div class="ws-table"><table><thead><tr><th>Variant</th><th>Value</th><th>Outcome</th><th>Recorded by</th><th>Date</th></tr></thead><tbody>' +
rows.slice(0, 50).map(function (m) { return '<tr><td>'+esc(m.mutations)+'</td><td>'+esc(m.measured_value == null ? '' : m.measured_value)+'</td><td>'+esc(m.outcome)+'</td><td>'+esc(m.mine ? 'You' : (m.measured_by_name || 'Collaborator'))+'</td><td>'+esc(day(m.created_at))+'</td></tr>'; }).join('') +
'</tbody></table></div>'
: '<p class="ws-note">No bench results yet. Whoever runs the assay can record them here, and the designer sees them at once.</p>') +
'<details'+(rows.length?'':' open')+'><summary>'+(mine.length?'Edit your bench results':'Record your bench results')+'</summary><form id="wsMeasure">' +
'<label>One per line: variant, value, outcome<textarea name="rows" spellcheck="false" placeholder="S1109T/W1126L, 7.86, worked&#10;M1169K, 2.10, failed">'+esc(mine.map(function (m) { return m.mutations + ', ' + (m.measured_value == null ? '' : m.measured_value) + ', ' + m.outcome; }).join('\n'))+'</textarea></label>' +
'<p class="ws-note">Outcome is optional: measured, worked, partial or failed. Saving replaces the results you recorded before for this library. Other people’s results are never changed.</p>' +
'<button type="submit">Save bench results</button></form></details>';
}
async function contributePicker() {
var id=current.id;
var r=await fetch('/api/workspace-results'), d=await r.json(); if(!r.ok) throw new Error(d.error||'Could not load saved results.');
if(!current||current.id!==id) return;
document.getElementById('wsContribution').innerHTML=d.results.length?'<form id="wsContribute"><label>Your saved result<select name="result">'+d.results.map(function(x){return '<option value="'+esc(x.kind+':'+x.id)+'">'+esc(x.name)+' · '+esc(x.kind)+'</option>';}).join('')+'</select></label><label class="ws-consent"><input type="checkbox" name="consent" required> Share the complete result, including its molecular data, with this project’s collaborators.</label><p class="ws-note">Adding a variant library replaces the project’s current library link. The previous library remains in its creator’s saved work.</p><button type="submit">Add to project</button></form>':'<p>No saved results to contribute yet. Run a tool and save its result first.</p>';
}
async function show(id, opts) {
mount(); if(!dialog.open) dialog.showModal();
try { if (opts && opts.invitation) await list(id); else if(id) await openProject(id); else await list(); } catch(e) {message(e.message);}
}
window.TDWorkspaces={show:show};
var entry=document.getElementById('workspaceCollaboration'); if(entry) entry.addEventListener('click',function(){show();});
/* The research-use acceptance comes first. It is a gate a new account must
click through (app.js opens it before this file runs), and an emailed
invitation is exactly how new accounts arrive: a modal dialog opened on
top of it covered the one button that dismisses it. */
function afterConsent(fn) {
var gate = document.getElementById('firstUseModal');
if (!gate || gate.hidden) return fn();
var watch = new MutationObserver(function () { if (gate.hidden) { watch.disconnect(); fn(); } });
watch.observe(gate, {attributes:true, attributeFilter:['hidden']});
}
var deepLink=new URLSearchParams(location.search).get('workspace');
if(deepLink === 'invitations') afterConsent(function(){ show(); });
else if(deepLink && /^[0-9a-f-]{36}$/i.test(deepLink)) afterConsent(function(){ show(deepLink); });
/* Arriving from an emailed invitation.
The token in the link grants nothing on its own: the server requires
the signed-in caller's VERIFIED email to match the address it was sent
to. Claiming is idempotent, so a mail client that prefetches the link
cannot consume it, and landing here signed out is not an error -- sign
in and the same link still works.
The project is NOT opened directly. Until the invitation is accepted
the caller is not a member, so 'get' refuses by design -- which is what
every first click used to land on: an error over an empty dialog, with
the Accept button somewhere else. The invitation card comes first.
SIGNED OUT IS THE NORMAL CASE, not the edge one: an invitation is
addressed to somebody who may have no account at all. The site's /app/
page sends signed-out visitors to sign-in before the engine loads; this
is the fallback for when it could not, and it offers both doors. */
function stashInvite(t){ try{ sessionStorage.setItem('td.invite',t); }catch(e){} }
function takeInvite(){ try{ var t=sessionStorage.getItem('td.invite'); if(t) sessionStorage.removeItem('td.invite'); return t; }catch(e){ return null; } }
var inviteToken=new URLSearchParams(location.search).get('invite')||takeInvite();
if(inviteToken && /^[0-9a-f]{32,80}$/i.test(inviteToken)) afterConsent(function(){
fetch('/api/invites/claim',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({token:inviteToken})})
.then(function(r){return r.json().then(function(d){return {ok:r.ok,status:r.status,d:d};},
function(){return {ok:r.ok,status:r.status,d:{}};});})
.then(function(res){
if(res.ok){
if(res.d.kind==='project'&&res.d.target_id) return show(res.d.target_id,{invitation:true});
return show();
}
if(res.status===401){
stashInvite(inviteToken);
mount(); if(!dialog.open) dialog.showModal();
var next=encodeURIComponent('/app/?invite='+inviteToken);
body.innerHTML='<section class="ws-invite"><p class="ws-eyebrow">Invitation</p><h3>You have been invited to a project</h3>'+
'<p>Sign in, or create a free account, with the email address this invitation was sent to. The invitation will be waiting.</p>'+
'<div class="ws-actions"><a class="ws-link-btn" href="'+SITE+'/signup/?invite='+esc(inviteToken)+'" target="_top">Create an account</a>'+
'<a class="ws-link-btn ws-link-btn--quiet" href="'+SITE+'/signin/?next='+esc(next)+'" target="_top">Sign in</a></div></section>';
return;
}
return show().then(function(){message((res.d&&res.d.error)||'This invitation is not available.');});
}).catch(function(){});
});
})();