51 lines
34 KiB
JavaScript
51 lines
34 KiB
JavaScript
const app=document.querySelector('#app');
|
||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const paths={dashboard:'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',bot:'M9 3h6 M12 3v3 M5 8h14v12H5z M8 12v3 M16 12v3 M2 11v6 M22 11v6',flow:'M4 3h6v6H4z M14 15h6v6h-6z M7 9v9h7 M10 6h7v9',play:'M8 5l12 7-12 7z',clock:'M12 8v5l3 2 M12 3a9 9 0 1 0 0 18 9 9 0 1 0 0-18',users:'M16 21v-3a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v3 M9 3a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M18 4a4 4 0 0 1 0 7 M22 21v-3a4 4 0 0 0-3-4',link:'M10 14l4-4 M8 16l-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0 M16 8l1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M5 19l2-2 M17 7l2-2',variable:'M8 3H5v18h3 M16 3h3v18h-3 M10 9l4 6 M14 9l-4 6',plus:'M12 5v14 M5 12h14',save:'M4 3h14l3 3v15H3V3z M7 3v6h10V3 M7 21v-8h10v8',refresh:'M20 7a9 9 0 1 0 1 6 M20 2v5h-5',check:'M5 12l4 4L19 6',close:'M6 6l12 12 M18 6L6 18',shield:'M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6',music:'M9 18V5l11-2v13 M9 18a3 3 0 1 1-6 0 3 3 0 1 1 6 0 M20 16a3 3 0 1 1-6 0 3 3 0 1 1 6 0',trash:'M3 6h18 M8 6V3h8v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7',data:'M3 5c0-4 18-4 18 0s-18 4-18 0z M3 5v14c0 4 18 4 18 0V5 M3 12c0 4 18 4 18 0',condition:'M12 2l9 10-9 10-9-10z',text:'M4 5h16 M12 5v15 M8 20h8',sort:'M4 5h16 M4 12h12 M4 19h8',channel:'M8 3l-2 18 M18 3l-2 18 M3 8h18 M2 16h18',expand:'M8 3H3v5 M16 3h5v5 M3 16v5h5 M21 16v5h-5'};
|
||
const icon=n=>`<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[n]||paths.bot}"/></svg>`;
|
||
const nodeIcon=t=>({timer:'clock',manual:'play',serverinfo:'data',clients:'users',condition:'condition',sort:'sort',limit:'dashboard',template:'text',channelEdit:'channel',message:'text',delay:'clock',log:'text'}[t]);
|
||
let user,data,page='dashboard',botId,draft,selectedNode,zoom=.85,pending,dirty=false;
|
||
const owner=()=>user?.role==='Dono', dis=()=>owner()?'':'disabled title="O perfil Leitor permite consultar"';
|
||
const selectedBot=()=>data.bots.find(b=>b.id===botId);
|
||
async function api(path,options={}){const r=await fetch(path,{...options,headers:{'Content-Type':'application/json'}});const body=await r.json();if(!r.ok)throw new Error(body.error||'O pedido falhou.');return body;}
|
||
function toast(text,error=false){document.querySelector('#toast')?.remove();const el=document.createElement('div');el.id='toast';el.className=`toast ${error?'error':''}`;el.setAttribute('role','status');el.textContent=text;document.body.append(el);setTimeout(()=>el.remove(),6000);}
|
||
async function refresh(){data=await api('/api/dashboard');if(!data.bots.some(b=>b.id===botId))botId=data.bots[0]?.id;}
|
||
const nav=[['dashboard','dashboard','Dashboard'],['bots','bot','Bots'],['editor','flow','Editor de flows'],['executions','clock','Execuções'],['variables','variable','Variáveis'],['connections','link','Ligações'],['settings','settings','Definições']];
|
||
const empty=(i,title,text)=>`<div class="empty"><span class="icon-tile purple">${icon(i)}</span><h3>${title}</h3><p>${text}</p></div>`;
|
||
const button=(id,text,i='plus',style='primary')=>`<button id="${id}" class="button ${style}" ${dis()}>${icon(i)}${text}</button>`;
|
||
const heading=(title,subtitle,actions='')=>`<div class="page-heading"><div><h1>${title}</h1><p>${subtitle}</p></div><div class="heading-actions">${actions}</div></div>`;
|
||
const ago=d=>!d?'Sem execuções':Math.floor((Date.now()-Date.parse(d))/60000)<1?'Agora':`Há ${Math.floor((Date.now()-Date.parse(d))/60000)} min`;
|
||
async function boot(){const me=await api('/api/me');user=me.user;if(!user){app.innerHTML=`<section class="login"><div class="login-card"><img class="logo" src="/logo.png" alt="XanaX"><span class="overline">TS6 BOTFLOW</span><h1>O teu centro de bots.</h1><p>Cria flows, gere bots e acompanha as tuas automações num só lugar.</p><div id="notice"></div><a class="button primary" href="/auth/discord">Entrar com Discord ${icon('play')}</a><p class="muted">Acesso reservado às contas autorizadas.</p></div></section>`;const messages={denied:'Esta conta não está autorizada.',authentication:'O login expirou ou foi cancelado.',configuration:'Configura a aplicação Discord no Docker.'};const msg=messages[new URLSearchParams(location.search).get('error')]||(!me.configured&&messages.configuration);if(msg){const el=document.querySelector('#notice');el.className='notice';el.textContent=msg;}return;}
|
||
await refresh();app.innerHTML=`<div class="shell"><aside class="sidebar"><a href="#dashboard" class="brand"><img class="logo" src="/logo.png" alt="XanaX"></a><div class="overline">AUTOMAÇÃO</div><nav>${nav.map(([id,i,label])=>`<button class="nav" data-page="${id}">${icon(i)}<span>${label}</span></button>`).join('')}</nav><div class="sidebar-bottom"><span class="status-dot"></span><span>TS6 Botflow<small>Ambiente local</small></span><small>0.2</small></div></aside><div class="workspace"><header class="topbar"><div class="breadcrumb">Workspace <span>/</span><strong id="page-label"></strong></div><div class="topbar-right"><select id="server-select" aria-label="Servidor">${data.connections.length?data.connections.map(c=>`<option>${esc(c.name)}</option>`).join(''):'<option>Sem servidor ligado</option>'}</select><span class="divider"></span><div class="avatar">${esc(user.name.slice(0,1).toUpperCase())}</div><div class="user-info"><strong>${esc(user.name)}</strong><small>${esc(user.role)}</small></div><button class="icon-button" id="logout" title="Terminar sessão">${icon('close')}</button></div></header><main id="content"></main><footer><span>XanaX · TS6 Botflow</span><span>O teu centro de automações</span></footer></div></div><dialog id="dialog"></dialog>`;
|
||
document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));document.querySelector('.brand').onclick=e=>{e.preventDefault();navigate('dashboard');};document.querySelector('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location.assign('/');};document.querySelector('#server-select').onchange=()=>navigate('connections');navigate(location.hash.slice(1));}
|
||
function navigate(name){if(dirty&&!confirm('Sair sem guardar as alterações do flow?'))return;dirty=false;pending=null;page=nav.some(([id])=>id===name)?name:'dashboard';location.hash=page;document.querySelectorAll('.nav').forEach(b=>b.classList.toggle('active',b.dataset.page===page));document.querySelector('#page-label').textContent=nav.find(([id])=>id===page)[2];document.querySelector('.workspace').classList.toggle('editor-workspace',page==='editor');({dashboard:dashboardPage,bots:botsPage,editor:editorPage,executions:executionsPage,variables:variablesPage,connections:connectionsPage,settings:settingsPage}[page])();wireExecutions();}
|
||
function botCard(bot){const runs=data.executions.filter(e=>e.botId===bot.id);return `<article class="card bot-card"><div class="bot-card-top"><span class="icon-tile ${esc(bot.color)}">${icon(bot.color==='red'?'shield':bot.color==='orange'?'music':bot.color==='cyan'?'users':'bot')}</span><div class="bot-card-title"><h2>${esc(bot.name)}</h2><span class="badge ${bot.enabled?'green':'gray'}">${bot.enabled?'Ativo':'Rascunho'}</span></div><button class="switch ${bot.enabled?'on':''}" data-toggle="${bot.id}" aria-label="Ativar ou desativar ${esc(bot.name)}" aria-pressed="${bot.enabled}" ${dis()}></button></div><p class="bot-description">${esc(bot.description||'Personaliza o flow deste bot.')}</p><div class="bot-meta"><span>${icon('flow')}1 flow</span><span>${icon('play')}${runs.length} execuções</span><span>${icon('clock')}${ago(runs[0]?.startedAt)}</span></div><div class="bot-actions"><button class="button secondary" data-open="${bot.id}">${icon('flow')}Abrir flow</button><button class="icon-button bordered" data-config="${bot.id}" aria-label="Configurar bot">${icon('settings')}</button></div></article>`;}
|
||
function wireBots(){document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>{botId=b.dataset.open;navigate('editor');});document.querySelectorAll('[data-config]').forEach(b=>b.onclick=()=>configureBot(b.dataset.config));const create=document.querySelector('#create-bot');if(create)create.onclick=()=>newBot();document.querySelectorAll('[data-toggle]').forEach(b=>b.onclick=async()=>{try{const bot=data.bots.find(bot=>bot.id===b.dataset.toggle);await api(`/api/bots/${bot.id}`,{method:'PUT',body:JSON.stringify({enabled:!bot.enabled})});await refresh();navigate(page);toast('Estado atualizado.');}catch(e){toast(e.message,true);}});}
|
||
function dashboardPage(){const today=new Date().toLocaleDateString('pt-PT',{timeZone:'Europe/Lisbon'});document.querySelector('#content').innerHTML=heading('Dashboard','Uma visão geral dos teus bots e automações.',button('create-bot','Criar bot'))+`<div class="stats">${[['Bots ativos',data.bots.filter(b=>b.enabled).length,'bot','green'],['Execuções hoje',data.executions.filter(e=>new Date(e.startedAt).toLocaleDateString('pt-PT',{timeZone:'Europe/Lisbon'})===today).length,'play','cyan'],['Flows guardados',data.bots.length,'flow','purple'],['Servidores configurados',data.connections.length,'link','orange']].map(([label,n,i,c])=>`<div class="card stat"><span class="icon-tile ${c}">${icon(i)}</span><div><span>${label}</span><strong>${n}</strong></div></div>`).join('')}</div><div class="section-heading"><h2>Os teus bots</h2><button class="text-button" id="all-bots">Ver todos →</button></div><div class="bot-grid">${data.bots.map(botCard).join('')}</div><div class="dashboard-bottom"><section class="card"><div class="section-heading"><h2>Atividade recente</h2><button class="text-button" id="all-runs">Ver execuções →</button></div>${data.executions.slice(0,4).map(runRow).join('')||empty('clock','Ainda sem execuções','Simula um flow para acompanhar aqui cada passo.')}</section><section class="card quick-start"><span class="icon-tile purple">${icon('flow')}</span><h2>O teu primeiro flow</h2><p>O rascunho Estatísticas prepara a descrição do canal 21. Escolhe a ligação e simula antes de ativar.</p><button class="button secondary" id="first-flow">${icon('flow')}Abrir editor</button></section></div>`;wireBots();document.querySelector('#all-bots').onclick=()=>navigate('bots');document.querySelector('#all-runs').onclick=()=>navigate('executions');document.querySelector('#first-flow').onclick=()=>navigate('editor');}
|
||
function botsPage(){document.querySelector('#content').innerHTML=heading('Bots','Gere os teus bots do TeamSpeak num só lugar.',button('create-bot','Criar bot'))+`<div class="filterbar"><input id="search-bots" placeholder="Pesquisar bots…" aria-label="Pesquisar bots"><span class="muted">${data.bots.length} bots · ${data.bots.filter(b=>b.enabled).length} ativos</span></div><div class="bot-grid" id="bot-list">${data.bots.map(botCard).join('')}</div><div class="section-heading templates-heading"><h2>Começar um novo bot</h2><span class="muted">Rascunhos para personalizar no editor</span></div><div class="template-grid">${[['Estatísticas','green','bot'],['Moderação','red','shield'],['Boas-vindas','cyan','users'],['Música','orange','music']].map(([name,c,i])=>`<button class="template-card" data-template="${name}" ${dis()}><span class="icon-tile ${c}">${icon(i)}</span><strong>${name}</strong><p>Cria um rascunho e configura os seus blocos.</p><span>Criar bot →</span></button>`).join('')}</div>`;wireBots();document.querySelector('#search-bots').oninput=e=>{document.querySelector('#bot-list').innerHTML=data.bots.filter(b=>b.name.toLowerCase().includes(e.target.value.toLowerCase())).map(botCard).join('');wireBots();};document.querySelectorAll('[data-template]').forEach(b=>b.onclick=()=>newBot(b.dataset.template));}
|
||
function modal(title,body,submit){const dialog=document.querySelector('#dialog');dialog.innerHTML=`<form id="modal-form"><div class="modal-heading"><h2>${title}</h2><button class="icon-button" type="button" id="close-dialog" aria-label="Fechar">${icon('close')}</button></div>${body}<div class="modal-actions"><button type="button" class="button secondary" id="cancel">Cancelar</button><button class="button primary" ${dis()}>${icon('save')}Guardar</button></div></form>`;dialog.showModal();document.querySelector('#close-dialog').onclick=document.querySelector('#cancel').onclick=()=>dialog.close();document.querySelector('#modal-form').onsubmit=async e=>{e.preventDefault();try{await submit(new FormData(e.target));dialog.close();await refresh();dirty=false;navigate(page);toast('Guardado.');}catch(error){toast(error.message,true);}};}
|
||
function newBot(name='Novo bot'){modal('Criar bot',`<label>Nome<input name="name" value="${esc(name)}" required maxlength="80"></label><label>Descrição<textarea name="description"></textarea></label><label>Cor<select name="color">${['purple','green','red','cyan','orange'].map(c=>`<option>${c}</option>`).join('')}</select></label><p class="form-help">O bot começa desativado. Usa o flow de estatísticas como base ou substitui os blocos.</p>`,async form=>{const bot=await api('/api/bots',{method:'POST',body:JSON.stringify(Object.fromEntries(form))});botId=bot.id;page='editor';});}
|
||
function configureBot(id){botId=id;const bot=selectedBot();modal('Configuração do bot',`<div class="config-tabs"><span class="active">Geral</span><button type="button" id="config-vars">Variáveis</button></div><label>Nome<input name="name" value="${esc(bot.name)}" required></label><label>Descrição<textarea name="description">${esc(bot.description)}</textarea></label><label>Servidor<select name="connectionId"><option value="">Escolher ligação</option>${data.connections.map(c=>`<option value="${c.id}" ${bot.connectionId===c.id?'selected':''}>${esc(c.name)}</option>`).join('')}</select></label><p class="form-help">Escolhe uma ligação para executar o flow no TeamSpeak.</p>`,f=>api(`/api/bots/${bot.id}`,{method:'PUT',body:JSON.stringify(Object.fromEntries(f))}));document.querySelector('#config-vars').onclick=()=>{document.querySelector('#dialog').close();navigate('variables');};}
|
||
function editorPage(){const bot=selectedBot();if(!bot){document.querySelector('#content').innerHTML=heading('Editor de flows','Cria um bot para começar.',button('create-bot','Criar bot'))+empty('flow','Ainda sem flows','Cria o teu primeiro bot.');wireBots();return;}draft=structuredClone(bot);selectedNode=draft.graph.nodes[0]?.id;zoom=.85;const groups=[...new Set(Object.values(data.nodeTypes).map(t=>t.group))];document.querySelector('#content').innerHTML=heading('Editor de flows','Cria e edita a lógica dos teus bots com um editor visual.',`<select id="bot-select" aria-label="Bot">${data.bots.map(b=>`<option value="${b.id}" ${b.id===botId?'selected':''}>${esc(b.name)}</option>`).join('')}</select><span class="badge gray">${owner()?'Rascunho':'Só leitura'}</span>`)+`<div class="editor" id="editor"><aside class="palette"><div class="panel-heading">Blocos ${icon('plus')}</div><input id="search-block" placeholder="Pesquisar blocos…" aria-label="Pesquisar blocos">${groups.map(g=>`<div class="palette-group"><span class="overline">${g}</span>${Object.entries(data.nodeTypes).filter(([,t])=>t.group===g).map(([id,t])=>`<button class="palette-item ${t.color}" data-add="${id}" ${dis()}>${icon(nodeIcon(id))}<span>${esc(t.title)}</span>${icon('plus')}</button>`).join('')}</div>`).join('')}</aside><div class="canvas-column"><div class="canvas-toolbar"><div><strong>${esc(bot.name)}</strong><span id="save-state">Guardado</span></div><div><button class="icon-button" id="zoom-out" title="Reduzir zoom">−</button><span id="zoom-label">85%</span><button class="icon-button" id="zoom-in" title="Aumentar zoom">+</button><button class="icon-button" id="fullscreen" title="Ecrã inteiro">${icon('expand')}</button></div></div><div class="canvas" id="canvas"><div class="world" id="world"><svg id="edges"></svg><div id="nodes"></div></div></div><div class="canvas-footer"><span id="graph-count"></span><span id="canvas-hint">${owner()?'Liga as portas dos blocos.':'Flow em modo de consulta.'}</span></div></div><aside class="inspector"><div id="node-properties"></div><div class="inspector-actions"><button class="button secondary" id="simulate" ${dis()}>${icon('play')}Simular</button><button class="button primary" id="save-flow" ${dis()}>${icon('save')}Guardar</button><button class="text-button" id="run" ${dis()}>Executar no TeamSpeak →</button></div></aside></div>`;
|
||
document.querySelector('#bot-select').onchange=e=>{if(dirty&&!confirm('Sair sem guardar?')){e.target.value=botId;return;}botId=e.target.value;dirty=false;editorPage();};document.querySelectorAll('[data-add]').forEach(b=>b.onclick=()=>addNode(b.dataset.add));document.querySelector('#search-block').oninput=e=>document.querySelectorAll('.palette-item').forEach(b=>b.hidden=!b.textContent.toLowerCase().includes(e.target.value.toLowerCase()));document.querySelector('#zoom-in').onclick=()=>{zoom=Math.min(1.5,zoom+.1);setZoom();};document.querySelector('#zoom-out').onclick=()=>{zoom=Math.max(.4,zoom-.1);setZoom();};document.querySelector('#fullscreen').onclick=()=>document.fullscreenElement?document.exitFullscreen():document.querySelector('#editor').requestFullscreen().catch(()=>toast('Ecrã inteiro indisponível.',true));document.querySelector('#save-flow').onclick=saveFlow;document.querySelector('#simulate').onclick=()=>execute(true);document.querySelector('#run').onclick=()=>execute(false);renderGraph();}
|
||
function markDirty(){dirty=true;const el=document.querySelector('#save-state');el.textContent='Por guardar';el.className='unsaved';}
|
||
function setZoom(){document.querySelector('#world').style.transform=`scale(${zoom})`;document.querySelector('#zoom-label').textContent=`${Math.round(zoom*100)}%`;}
|
||
function addNode(type){const configs={timer:{seconds:30},condition:{field:'count',operator:'greater',value:'0'},sort:{field:'client_lastconnected',direction:'asc'},limit:{count:5},template:{text:'{{top}}'},channelEdit:{channelId:21,description:'{{text}}'},message:{target:21,mode:'channel',text:'Olá!'},delay:{seconds:1},log:{text:'Executado às {{time}}'}};const node={id:`n${Date.now()}${Math.floor(Math.random()*10000)}`,type,label:data.nodeTypes[type].title,x:200,y:60+draft.graph.nodes.length*100,config:configs[type]||{}};draft.graph.nodes.push(node);selectedNode=node.id;markDirty();renderGraph();}
|
||
function renderGraph(){const nodes=draft.graph.nodes;document.querySelector('#nodes').innerHTML=nodes.map(n=>`<div class="flow-node ${data.nodeTypes[n.type].color} ${n.id===selectedNode?'selected':''}" data-node="${esc(n.id)}" style="left:${n.x}px;top:${n.y}px"><button class="port input" data-input="${esc(n.id)}" aria-label="Entrada de ${esc(n.label)}" ${dis()}></button><div class="node-head">${icon(nodeIcon(n.type))}<strong>${esc(n.label)}</strong></div><div class="node-subtitle">${esc(n.type==='timer'?`A cada ${n.config.seconds}s`:data.nodeTypes[n.type].title)}</div>${(n.type==='condition'?['true','false']:['out']).map((p,i)=>`<button class="port output ${p}" data-output="${esc(n.id)}" data-port="${p}" style="top:${n.type==='condition'?i?54:28:38}px" aria-label="Saída ${p}" ${dis()}></button>`).join('')}</div>`).join('');document.querySelector('#world').style.width=`${Math.max(1000,...nodes.map(n=>n.x+350))}px`;document.querySelector('#world').style.height=`${Math.max(950,...nodes.map(n=>n.y+160))}px`;renderEdges();setZoom();inspector();document.querySelector('#graph-count').textContent=`${nodes.length} blocos · ${draft.graph.edges.length} ligações`;
|
||
document.querySelectorAll('[data-node]').forEach(el=>el.onpointerdown=e=>{if(e.target.closest('button'))return;const n=nodes.find(n=>n.id===el.dataset.node);selectedNode=n.id;document.querySelectorAll('.flow-node').forEach(b=>b.classList.toggle('selected',b===el));inspector();if(!owner())return;const sx=e.clientX,sy=e.clientY,x=n.x,y=n.y;el.setPointerCapture(e.pointerId);let moved=false;el.onpointermove=ev=>{const dx=(ev.clientX-sx)/zoom,dy=(ev.clientY-sy)/zoom;if(Math.abs(dx)+Math.abs(dy)>3)moved=true;n.x=Math.max(20,Math.round(x+dx));n.y=Math.max(20,Math.round(y+dy));el.style.left=`${n.x}px`;el.style.top=`${n.y}px`;renderEdges();};el.onpointerup=()=>{el.onpointermove=null;el.onpointerup=null;if(moved){markDirty();renderGraph();}};});
|
||
document.querySelectorAll('[data-output]').forEach(b=>b.onclick=()=>{pending={from:b.dataset.output,port:b.dataset.port};document.querySelector('#canvas-hint').textContent='Clica na entrada do bloco de destino.';});document.querySelectorAll('[data-input]').forEach(b=>b.onclick=()=>{if(!pending||pending.from===b.dataset.input)return;const edge={...pending,to:b.dataset.input};if(!draft.graph.edges.some(e=>e.from===edge.from&&e.to===edge.to&&e.port===edge.port)){draft.graph.edges.push(edge);markDirty();}pending=null;renderGraph();document.querySelector('#canvas-hint').textContent='Ligação criada.';});}
|
||
function renderEdges(){document.querySelector('#edges').innerHTML=`<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0 0L10 5L0 10" fill="#9290b8"/></marker></defs>`+draft.graph.edges.map(e=>{const a=draft.graph.nodes.find(n=>n.id===e.from),b=draft.graph.nodes.find(n=>n.id===e.to);if(!a||!b)return '';const x=a.x+230,y=a.y+(e.port==='true'?28:e.port==='false'?54:38),x2=b.x,y2=b.y+38;return `<path class="edge ${e.port}" d="M${x} ${y} C${x+80} ${y},${x2-80} ${y2},${x2} ${y2}" marker-end="url(#arrow)"/>`;}).join('');}
|
||
function inspector(){const n=draft.graph.nodes.find(n=>n.id===selectedNode),el=document.querySelector('#node-properties');if(!n){el.innerHTML=empty('flow','Seleciona um bloco','Clica num bloco para editar.');return;}const type=data.nodeTypes[n.type],edges=draft.graph.edges.map((e,i)=>({e,i})).filter(({e})=>e.from===n.id||e.to===n.id);el.innerHTML=`<div class="panel-heading"><span>${icon(nodeIcon(n.type))}${esc(type.title)}</span><button class="icon-button danger" id="delete-node" aria-label="Eliminar bloco" ${dis()}>${icon('trash')}</button></div><div class="inspector-body"><label>Nome do bloco<input id="node-label" value="${esc(n.label)}" ${owner()?'':'readonly'}></label><span class="badge ${type.color}">${esc(type.group)}</span><span class="overline">CONFIGURAÇÃO</span>${type.fields.map(([key,label,input,values])=>`<label>${esc(label)}${input==='textarea'?`<textarea data-field="${key}" ${owner()?'':'readonly'}>${esc(n.config[key])}</textarea>`:input==='select'?`<select data-field="${key}" ${dis()}>${values.map(v=>`<option value="${v}" ${n.config[key]===v?'selected':''}>${esc(({asc:'Crescente',desc:'Decrescente',greater:'Maior que',equals:'Igual a',contains:'Contém',client:'Cliente',channel:'Canal',server:'Servidor'})[v]||v)}</option>`).join('')}</select>`:`<input data-field="${key}" type="${input}" value="${esc(n.config[key])}" ${owner()?'':'readonly'}>`}</label>`).join('')||'<p class="form-help">Sem parâmetros adicionais.</p>'}<p class="form-help">Variáveis: <code>{{time}}</code>, <code>{{top}}</code>, <code>{{text}}</code> e <code>{{variables.nome}}</code>.</p><span class="overline">LIGAÇÕES</span>${edges.map(({e,i})=>`<div class="connection-row"><span>${esc(draft.graph.nodes.find(node=>node.id===(e.from===n.id?e.to:e.from))?.label)}<small>${e.from===n.id?'Saída':'Entrada'} · ${e.port}</small></span><button class="icon-button danger" data-delete-edge="${i}" aria-label="Eliminar ligação" ${dis()}>${icon('trash')}</button></div>`).join('')||'<p class="form-help">Ainda sem ligações.</p>'}</div>`;document.querySelector('#node-label').oninput=e=>{if(!owner())return;n.label=e.target.value;markDirty();document.querySelector(`[data-node="${n.id}"] strong`).textContent=n.label;};document.querySelectorAll('[data-field]').forEach(input=>input.onchange=()=>{if(!owner())return;n.config[input.dataset.field]=input.type==='number'?Number(input.value):input.value;markDirty();renderGraph();});document.querySelector('#delete-node').onclick=()=>{draft.graph.nodes=draft.graph.nodes.filter(node=>node.id!==n.id);draft.graph.edges=draft.graph.edges.filter(e=>e.from!==n.id&&e.to!==n.id);selectedNode=draft.graph.nodes[0]?.id;markDirty();renderGraph();};document.querySelectorAll('[data-delete-edge]').forEach(b=>b.onclick=()=>{draft.graph.edges.splice(Number(b.dataset.deleteEdge),1);markDirty();renderGraph();});}
|
||
async function saveFlow(){try{await api(`/api/bots/${draft.id}`,{method:'PUT',body:JSON.stringify({graph:draft.graph})});await refresh();dirty=false;document.querySelector('#save-state').textContent='Guardado';document.querySelector('#save-state').className='';toast('Flow guardado.');return true;}catch(e){toast(e.message,true);return false;}}
|
||
async function execute(simulation){if(!simulation&&!confirm('Executar este flow no servidor TeamSpeak configurado?'))return;if(dirty&&!(await saveFlow()))return;const b=document.querySelector(simulation?'#simulate':'#run');b.disabled=true;try{const result=await api(`/api/bots/${draft.id}/${simulation?'simulate':'run'}`,{method:'POST'});await refresh();showRun(result);}catch(e){toast(e.message,true);}finally{b.disabled=!owner();}}
|
||
function runRow(e){return `<button class="execution-row" data-execution="${e.id}"><span class="execution-status ${e.status==='success'?'green':'red'}">${icon(e.status==='success'?'check':'close')}</span><span><strong>${esc(e.botName)}</strong><small>${e.simulation?'Simulação':'Execução real'} · ${e.status==='success'?'Concluída':'Falhou'}</small></span><time>${ago(e.startedAt)}</time></button>`;}
|
||
function runDetail(e){return `<div class="section-heading"><h2>${esc(e.botName)}</h2><span class="badge ${e.status==='success'?'green':'red'}">${e.status==='success'?'Sucesso':'Falha'}</span></div><span class="muted">${new Date(e.startedAt).toLocaleString('pt-PT')} · ${e.simulation?'Simulação com dados de exemplo':'Servidor TeamSpeak'}</span><div class="execution-summary"><div><span>Duração</span><strong>${((e.durationMs||0)/1000).toFixed(2)}s</strong></div><div><span>Iniciado por</span><strong>${esc(e.actor)}</strong></div><div><span>Passos</span><strong>${e.steps.length}</strong></div></div><h3>Eventos e etapas</h3><div class="timeline">${e.steps.map(s=>`<div class="timeline-step"><span class="execution-status ${s.status==='success'?'green':'red'}">${icon(s.status==='success'?'check':'close')}</span><div><strong>${esc(s.label)}</strong><p>${esc(s.message)}</p></div><time>${s.durationMs||0} ms</time></div>`).join('')}</div>${e.error?`<div class="notice error">${esc(e.error)}</div>`:''}${e.output?`<h3>Texto produzido</h3><pre>${esc(e.output)}</pre>`:''}`;}
|
||
function showRun(e){const dialog=document.querySelector('#dialog');dialog.innerHTML=`<div class="modal-heading"><h2>${e.simulation?'Resultado da simulação':'Resultado da execução'}</h2><button class="icon-button" id="close-run">${icon('close')}</button></div>${runDetail(e)}`;dialog.showModal();document.querySelector('#close-run').onclick=()=>dialog.close();}
|
||
function wireExecutions(){document.querySelectorAll('[data-execution]').forEach(b=>b.onclick=()=>{const e=data.executions.find(e=>e.id===b.dataset.execution);const el=document.querySelector('#execution-detail');if(el){el.innerHTML=runDetail(e);document.querySelectorAll('.execution-row').forEach(row=>row.classList.toggle('selected',row===b));}else showRun(e);});}
|
||
function executionsPage(){document.querySelector('#content').innerHTML=heading('Execuções','Acompanha e analisa cada passo dos teus flows.',`<button class="button secondary" id="refresh-runs">${icon('refresh')}Atualizar</button>`)+`<div class="executions-layout"><section class="card execution-list"><div class="section-heading"><h2>Execuções recentes</h2><span class="badge gray">${data.executions.length}</span></div>${data.executions.map(runRow).join('')||empty('clock','Ainda sem execuções','Abre um flow e carrega em Simular.')}</section><section class="card" id="execution-detail">${data.executions.length?runDetail(data.executions[0]):empty('flow','Cada passo, num só lugar','Os resultados ficam guardados aqui.')}</section></div>`;document.querySelector('#refresh-runs').onclick=async()=>{await refresh();executionsPage();wireExecutions();};}
|
||
const variableRow=(k,v)=>`<div class="variable-row"><input data-key value="${esc(k)}" placeholder="nome" ${owner()?'':'readonly'}><input data-value value="${esc(v)}" placeholder="valor" ${owner()?'':'readonly'}><button type="button" class="icon-button danger" data-remove-var ${dis()}>${icon('trash')}</button></div>`;
|
||
function variablesPage(){const bot=selectedBot();document.querySelector('#content').innerHTML=heading('Variáveis','Valores persistentes para reutilizar nos teus flows.',`<select id="variables-bot" aria-label="Bot">${data.bots.map(b=>`<option value="${b.id}" ${b.id===botId?'selected':''}>${esc(b.name)}</option>`).join('')}</select>`)+`<section class="card"><div class="section-heading"><h2>Variáveis do bot</h2>${button('add-variable','Adicionar variável','plus','secondary')}</div><p class="form-help">Usa <code>{{variables.nome}}</code> no texto de um bloco.</p><form id="variables-form"><div class="table-heading"><span>Nome</span><span>Valor</span><span></span></div><div id="variable-rows">${Object.entries(bot?.variables||{}).map(([k,v])=>variableRow(k,v)).join('')}</div><div class="form-actions"><button class="button primary" ${dis()}>${icon('save')}Guardar variáveis</button></div></form></section>`;const wire=()=>document.querySelectorAll('[data-remove-var]').forEach(b=>b.onclick=()=>b.closest('.variable-row').remove());wire();document.querySelector('#variables-bot').onchange=e=>{botId=e.target.value;variablesPage();};document.querySelector('#add-variable').onclick=()=>{document.querySelector('#variable-rows').insertAdjacentHTML('beforeend',variableRow('',''));wire();};document.querySelector('#variables-form').onsubmit=async e=>{e.preventDefault();if(!bot)return;try{const variables=Object.create(null);document.querySelectorAll('.variable-row').forEach(row=>{const key=row.querySelector('[data-key]').value.trim();if(!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)||Object.hasOwn(variables,key))throw new Error('Usa nomes únicos com letras, números e underscore.');variables[key]=row.querySelector('[data-value]').value;});await api(`/api/bots/${bot.id}`,{method:'PUT',body:JSON.stringify({variables})});await refresh();toast('Variáveis guardadas.');}catch(error){toast(error.message,true);}};}
|
||
function connectionsPage(){document.querySelector('#content').innerHTML=heading('Ligações','Liga os teus bots ao servidor TeamSpeak.',button('new-connection','Adicionar servidor','link'))+`<div class="connection-grid">${data.connections.map(c=>`<article class="card"><div class="bot-card-top"><span class="icon-tile cyan">${icon('link')}</span><div><h2>${esc(c.name)}</h2><span class="muted">WebQuery · Servidor #${c.serverId}</span></div></div><p class="connection-url">${esc(c.url)}</p><div class="connection-actions"><button class="button secondary" data-edit-connection="${c.id}">${icon('settings')}Configurar</button><button class="button secondary" data-test-connection="${c.id}" ${dis()}>${icon('refresh')}Testar</button></div></article>`).join('')||`<section class="card">${empty('link','Liga o teu TeamSpeak','Adiciona o endereço WebQuery e a API key.')}</section>`}</div><div class="notice connection-tip">Podes usar <code>http://192.168.88.99:10080</code> se a porta estiver publicada. O nome <code>teamspeak6</code> só funciona quando os containers partilham a rede.</div>`;document.querySelector('#new-connection').onclick=()=>connectionModal();document.querySelectorAll('[data-edit-connection]').forEach(b=>b.onclick=()=>connectionModal(data.connections.find(c=>c.id===b.dataset.editConnection)));document.querySelectorAll('[data-test-connection]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{const result=await api(`/api/connections/${b.dataset.testConnection}/test`,{method:'POST'});toast(`Ligação confirmada: ${result.server.virtualserver_name||'TeamSpeak'}`);}catch(e){toast(e.message,true);}finally{b.disabled=!owner();}});}
|
||
function connectionModal(c={}){modal(c.id?'Configurar servidor':'Adicionar servidor',`<label>Nome<input name="name" value="${esc(c.name||'Servidor principal')}" required></label><label>Endereço WebQuery<input name="url" type="url" value="${esc(c.url||'http://192.168.88.99:10080')}" required></label><label>ID do servidor virtual<input name="serverId" type="number" min="1" value="${c.serverId||1}" required></label><label>API key<input name="apiKey" type="password" autocomplete="new-password" placeholder="${c.hasApiKey?'Guardada · deixa vazio para manter':'Coloca a API key'}" ${c.hasApiKey?'':'required'}></label><p class="form-help">A chave é guardada cifrada e não aparece no painel.</p>`,f=>api('/api/connections',{method:'POST',body:JSON.stringify({...Object.fromEntries(f),id:c.id})}));}
|
||
function settingsPage(){document.querySelector('#content').innerHTML=heading('Definições','A tua conta e o ambiente do painel.')+`<div class="settings-grid"><section class="card"><h2>O teu acesso</h2><div class="profile-card"><div class="avatar large">${esc(user.name.slice(0,1).toUpperCase())}</div><div><h3>${esc(user.name)}</h3><span class="badge purple">${esc(user.role)}</span></div></div><div class="details"><div><span>ID Discord</span><strong>${esc(user.id)}</strong></div><div><span>Autenticação</span><strong>Discord</strong></div><div><span>Permissões</span><strong>${owner()?'Configurar, guardar e executar':'Consultar bots, flows e execuções'}</strong></div></div></section><section class="card"><h2>Ambiente</h2><div class="details"><div><span>Painel</span><strong>TS6 Botflow 0.2</strong></div><div><span>Dados</span><strong>Volume Docker persistente</strong></div><div><span>Novas contas</span><strong>Apenas IDs autorizados</strong></div></div><p class="form-help">A tua conta Discord e as ligações TeamSpeak têm permissões separadas.</p></section></div>`;}
|
||
boot().catch(e=>{app.innerHTML=`<div class="login"><div class="login-card"><h1>Não foi possível carregar o painel.</h1><p>${esc(e.message)}</p><a class="button primary" href="/">Tentar novamente</a></div></div>`;});window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
|