460 lines
19 KiB
JavaScript
460 lines
19 KiB
JavaScript
/**
|
||
* dashboard.js — Shell global da Central de Controle do Carinha
|
||
* Injeta barra lateral unificada, telemetria em tempo real no topo,
|
||
* controle de áudio robótico e dock de comandos rápidos.
|
||
*/
|
||
(() => {
|
||
if (window.location.search.includes('clean=1')) {
|
||
document.documentElement.classList.add('is-clean-embed');
|
||
return;
|
||
}
|
||
|
||
// Carrega dinamicamente o sintetizador de áudio caso não esteja presente
|
||
if (!window.RobotAudio) {
|
||
const audioScript = document.createElement('script');
|
||
audioScript.src = '/audio-robot.js';
|
||
audioScript.defer = true;
|
||
document.head.appendChild(audioScript);
|
||
}
|
||
|
||
const sections = [
|
||
{
|
||
title: 'Principal',
|
||
items: [
|
||
['/dashboard', 'Visão geral', 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z'],
|
||
['/painel', 'Câmera e rosto', 'M3 6h12v12H3z M15 10l6-4v12l-6-4']
|
||
]
|
||
},
|
||
{
|
||
title: 'Inteligência & IA',
|
||
items: [
|
||
['/conversa', 'Conversar com IA', 'M3 4h18v13H9l-6 4z'],
|
||
['/emojis', 'Emojis & Reações', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18 M8 9h1 M15 9h1 M7 14q5 5 10 0'],
|
||
['/treinamento', 'Treinamento', 'M3 8l9-5 9 5-9 5z M6 11v6q6 5 12 0v-6']
|
||
]
|
||
},
|
||
{
|
||
title: 'Robótica & Hardware',
|
||
items: [
|
||
['/braco', 'Braço robótico', 'M3 21h18 M8 21v-5l7-5-3-6 M9 4h6v4H9z M14 10h4v4h-4z'],
|
||
['/servos', 'Servos PCA9685', 'M5 3v18 M12 3v18 M19 3v18 M2 8h6 M9 16h6 M16 7h6']
|
||
]
|
||
},
|
||
{
|
||
title: 'Configurações',
|
||
items: [
|
||
['/api', 'API Gemini & Modelo', 'M8 5L2 12l6 7 M16 5l6 7-6 7 M14 3l-4 18']
|
||
]
|
||
}
|
||
];
|
||
|
||
const pathname = location.pathname === '/' ? '/dashboard' : location.pathname;
|
||
document.body.classList.add('dashboard');
|
||
|
||
const frame = document.createElement('div');
|
||
frame.className = 'app-frame';
|
||
|
||
const main = document.createElement('main');
|
||
main.className = 'app-content';
|
||
main.id = 'appContent';
|
||
|
||
// Move elementos existentes do body para o container principal
|
||
for (const child of [...document.body.childNodes]) {
|
||
if (!(child.nodeType === 1 && ['SCRIPT', 'STYLE', 'LINK'].includes(child.tagName))) {
|
||
main.append(child);
|
||
}
|
||
}
|
||
|
||
// Sidebar moderna e estilizada
|
||
const sidebar = document.createElement('aside');
|
||
sidebar.className = 'app-sidebar';
|
||
sidebar.innerHTML = `
|
||
<a href="/dashboard" class="brand" title="Central Carinha">
|
||
<span class="brand-face" aria-hidden="true"><i></i><i></i></span>
|
||
<span>Carinha <span style="font-size:12px;color:var(--ciano);font-weight:600;letter-spacing:0.05em;vertical-align:middle;background:rgba(92,214,255,0.12);padding:2px 6px;border-radius:6px;border:1px solid rgba(92,214,255,0.25)">IA</span></span>
|
||
</a>
|
||
<nav aria-label="Navegação principal" id="sidebarNav"></nav>
|
||
<div class="sidebar-footer">
|
||
<div><strong>Orange Pi Zero 3</strong></div>
|
||
<div style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--muito-fraco)" id="shellServer">${location.host}</div>
|
||
<div style="margin-top:6px;display:flex;align-items:center;gap:6px">
|
||
<span class="badge-dot" style="background:#10b981;color:#10b981"></span>
|
||
<span style="font-size:11px;color:var(--fraco)">Serviço operacional</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const nav = sidebar.querySelector('#sidebarNav');
|
||
for (const sec of sections) {
|
||
const title = document.createElement('div');
|
||
title.className = 'nav-section-title';
|
||
title.textContent = sec.title;
|
||
nav.append(title);
|
||
|
||
for (const [url, label, path] of sec.items) {
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${path}"/></svg><span>${label}</span>`;
|
||
if (url === pathname) {
|
||
a.setAttribute('aria-current', 'page');
|
||
}
|
||
a.addEventListener('click', () => {
|
||
if (window.RobotAudio) window.RobotAudio.click();
|
||
});
|
||
nav.append(a);
|
||
}
|
||
}
|
||
|
||
// Barra de ferramentas superior (Topbar)
|
||
const top = document.createElement('header');
|
||
top.className = 'app-topbar';
|
||
top.innerHTML = `
|
||
<div class="topbar-left">
|
||
<button class="menu-button" type="button" aria-label="Abrir menu" aria-expanded="false">☰</button>
|
||
<span class="server-label" style="font-size:14px;font-weight:600;color:var(--texto);display:flex;align-items:center;gap:8px">
|
||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ciano);box-shadow:0 0 8px var(--ciano)"></span>
|
||
Central de Controle
|
||
</span>
|
||
</div>
|
||
|
||
<div class="topbar-telemetry">
|
||
<button id="pipToggleBtn" type="button" title="Mini-monitor flutuante (Picture-in-Picture) que fica fixo em qualquer página" style="padding:6px 12px;font-size:12.5px;border-radius:20px;border-color:var(--borda)">
|
||
📺 Monitor PiP
|
||
</button>
|
||
<button id="quickRemoteBtn" type="button" title="Atalhos rápidos de expressão" style="padding:6px 12px;font-size:12.5px;border-radius:20px;border-color:var(--borda)">
|
||
⚡ Gestos
|
||
</button>
|
||
<button id="sfxToggleBtn" type="button" title="Ativar/Desativar efeitos sonoros robóticos" style="padding:6px 12px;font-size:12.5px;border-radius:20px">
|
||
🔊 Som: Ativo
|
||
</button>
|
||
<span class="badge" id="shellConnection">
|
||
<span class="badge-dot"></span>
|
||
<span id="shellConnectionText">Verificando celular…</span>
|
||
</span>
|
||
</div>
|
||
`;
|
||
|
||
// Mini-Monitor Flutuante (Picture-in-Picture)
|
||
const pipWidget = document.createElement('div');
|
||
pipWidget.id = 'floatingPipWidget';
|
||
pipWidget.className = 'floating-pip-widget';
|
||
pipWidget.style.display = 'none';
|
||
pipWidget.innerHTML = `
|
||
<div class="floating-pip-header">
|
||
<div class="pip-title">
|
||
<span class="badge-dot" style="background:var(--ciano)"></span>
|
||
<span>Carinha Monitor</span>
|
||
</div>
|
||
<div class="floating-pip-actions">
|
||
<button id="pipMinimizeBtn" type="button" title="Minimizar ou Expandir">_</button>
|
||
<button id="pipCloseBtn" type="button" title="Fechar">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="floating-pip-body" id="pipBody">
|
||
<iframe src="/carinha.html?espelho=1&audio=0" title="Monitor Flutuante do Carinha"></iframe>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(pipWidget);
|
||
|
||
const pipToggleBtn = top.querySelector('#pipToggleBtn');
|
||
const pipMinBtn = pipWidget.querySelector('#pipMinimizeBtn');
|
||
const pipCloseBtn = pipWidget.querySelector('#pipCloseBtn');
|
||
|
||
function togglePip(force) {
|
||
const isVisible = pipWidget.style.display === 'flex';
|
||
const next = force !== undefined ? force : !isVisible;
|
||
pipWidget.style.display = next ? 'flex' : 'none';
|
||
pipToggleBtn.style.color = next ? 'var(--ciano)' : 'var(--texto)';
|
||
pipToggleBtn.style.borderColor = next ? 'var(--ciano)' : 'var(--borda)';
|
||
localStorage.setItem('carinha_pip', next ? 'true' : 'false');
|
||
if (next && window.RobotAudio) window.RobotAudio.click();
|
||
}
|
||
|
||
pipToggleBtn.onclick = () => togglePip();
|
||
pipCloseBtn.onclick = () => togglePip(false);
|
||
pipMinBtn.onclick = () => {
|
||
const isMin = pipWidget.classList.toggle('minimized');
|
||
pipMinBtn.textContent = isMin ? '□' : '_';
|
||
};
|
||
|
||
// Se o usuário já deixou o PiP ativo anteriormente
|
||
if (localStorage.getItem('carinha_pip') === 'true') {
|
||
pipWidget.style.display = 'flex';
|
||
pipToggleBtn.style.color = 'var(--ciano)';
|
||
pipToggleBtn.style.borderColor = 'var(--ciano)';
|
||
}
|
||
|
||
// Menu móvel
|
||
const menuBtn = top.querySelector('.menu-button');
|
||
menuBtn.onclick = () => {
|
||
const open = document.body.classList.toggle('menu-open');
|
||
menuBtn.setAttribute('aria-expanded', String(open));
|
||
};
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') {
|
||
document.body.classList.remove('menu-open');
|
||
menuBtn.setAttribute('aria-expanded', 'false');
|
||
fecharQuickRemote();
|
||
}
|
||
});
|
||
main.addEventListener('click', () => {
|
||
document.body.classList.remove('menu-open');
|
||
menuBtn.setAttribute('aria-expanded', 'false');
|
||
});
|
||
|
||
// Modal / Dropdown de Gestos Rápidos
|
||
const quickRemote = document.createElement('div');
|
||
quickRemote.id = 'quickRemoteBar';
|
||
quickRemote.style.cssText = `
|
||
position: fixed; top: 72px; right: 28px; z-index: 90;
|
||
background: rgba(14, 24, 38, 0.95); backdrop-filter: blur(16px);
|
||
border: 1px solid var(--borda-brilho); border-radius: 14px;
|
||
padding: 16px; box-shadow: var(--sombra); display: none;
|
||
flex-direction: column; gap: 10px; width: 290px;
|
||
`;
|
||
quickRemote.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--borda);padding-bottom:8px">
|
||
<strong style="font-size:14px;color:var(--texto)">⚡ Disparo Rápido</strong>
|
||
<button id="closeQuickRemote" type="button" style="padding:3px 8px;font-size:12px;border:none;background:transparent;color:var(--fraco)">✕</button>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:4px">
|
||
<button data-gesto="feliz" style="padding:8px;font-size:12px">😊 Feliz</button>
|
||
<button data-gesto="surpreso" style="padding:8px;font-size:12px">😲 Surpreso</button>
|
||
<button data-gesto="coracao" style="padding:8px;font-size:12px">❤️ Coração</button>
|
||
<button data-gesto="bravo" style="padding:8px;font-size:12px">😠 Bravo</button>
|
||
<button data-gesto="piscar" style="padding:8px;font-size:12px">😉 Piscar</button>
|
||
<button data-gesto="piscadinha" style="padding:8px;font-size:12px">✨ Piscar 2</button>
|
||
<button data-gesto="acordar" style="padding:8px;font-size:12px" class="primary">☀️ Acordar</button>
|
||
<button data-gesto="dormir" style="padding:8px;font-size:12px">🌙 Dormir</button>
|
||
</div>
|
||
<button data-gesto="parar" class="danger" style="width:100%;font-size:12px;padding:8px;margin-top:4px">🛑 Parar Tudo (E-Stop)</button>
|
||
`;
|
||
document.body.appendChild(quickRemote);
|
||
|
||
function toggleQuickRemote() {
|
||
const isVisible = quickRemote.style.display === 'flex';
|
||
quickRemote.style.display = isVisible ? 'none' : 'flex';
|
||
if (!isVisible && window.RobotAudio) window.RobotAudio.click();
|
||
}
|
||
function fecharQuickRemote() {
|
||
quickRemote.style.display = 'none';
|
||
}
|
||
|
||
top.querySelector('#quickRemoteBtn').onclick = toggleQuickRemote;
|
||
quickRemote.querySelector('#closeQuickRemote').onclick = fecharQuickRemote;
|
||
|
||
quickRemote.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('button[data-gesto]');
|
||
if (!btn) return;
|
||
const gesto = btn.getAttribute('data-gesto');
|
||
try {
|
||
if (window.RobotAudio) window.RobotAudio.tocarGesto(gesto);
|
||
await fetch('/gesto/' + encodeURIComponent(gesto));
|
||
btn.style.transform = 'scale(0.95)';
|
||
setTimeout(() => btn.style.transform = '', 150);
|
||
} catch (err) {
|
||
console.error('Falha ao enviar gesto:', err);
|
||
}
|
||
});
|
||
|
||
// Botão de Áudio
|
||
const sfxBtn = top.querySelector('#sfxToggleBtn');
|
||
function atualizarBotaoSfx() {
|
||
if (!window.RobotAudio) return;
|
||
sfxBtn.textContent = window.RobotAudio.enabled ? '🔊 Som: Ativo' : '🔇 Som: Mudo';
|
||
sfxBtn.style.color = window.RobotAudio.enabled ? 'var(--ciano)' : 'var(--fraco)';
|
||
}
|
||
sfxBtn.onclick = () => {
|
||
if (window.RobotAudio) {
|
||
window.RobotAudio.toggle();
|
||
atualizarBotaoSfx();
|
||
}
|
||
};
|
||
setTimeout(atualizarBotaoSfx, 400);
|
||
|
||
frame.append(top, main);
|
||
document.body.prepend(sidebar, frame);
|
||
|
||
// Monitoramento periódico de status do celular e servidor
|
||
async function checkStatus() {
|
||
try {
|
||
const r = await fetch('/app/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) });
|
||
if (!r.ok) throw Error();
|
||
const s = await r.json();
|
||
const badge = document.getElementById('shellConnection');
|
||
const text = document.getElementById('shellConnectionText') || badge;
|
||
|
||
const bat = s.estado?.bateria?.nivel;
|
||
const carregando = s.estado?.bateria?.carregando;
|
||
let batInfo = '';
|
||
if (bat != null) {
|
||
batInfo = ` · ${bat}%${carregando ? ' ⚡' : ''}`;
|
||
}
|
||
|
||
if (s.online) {
|
||
text.textContent = `S10 Online${batInfo}`;
|
||
badge.className = 'badge ativo';
|
||
} else {
|
||
text.textContent = 'S10 Desconectado';
|
||
badge.className = 'badge offline';
|
||
}
|
||
} catch (e) {
|
||
const badge = document.getElementById('shellConnection');
|
||
if (badge) {
|
||
badge.className = 'badge danger';
|
||
const text = document.getElementById('shellConnectionText') || badge;
|
||
text.textContent = 'Servidor Offline';
|
||
}
|
||
}
|
||
}
|
||
|
||
checkStatus();
|
||
setInterval(checkStatus, 4000);
|
||
|
||
// =========================================================================
|
||
// BARRA DE MONITORAMENTO NO TOPO — câmera e rosto sempre visíveis
|
||
// Com botão de expandir/recolher cada painel individualmente
|
||
// =========================================================================
|
||
const watchBar = document.createElement('div');
|
||
watchBar.id = 'watchBar';
|
||
watchBar.className = 'watch-bar';
|
||
|
||
// Lê estado salvo (expandido ou não) e tamanho
|
||
const watchState = (() => {
|
||
try { return JSON.parse(localStorage.getItem('carinha_watch') || '{}'); }
|
||
catch { return {}; }
|
||
})();
|
||
|
||
function saveWatchState() {
|
||
localStorage.setItem('carinha_watch', JSON.stringify(watchState));
|
||
}
|
||
|
||
// Altura dos painéis: compacto ou expandido
|
||
const HEIGHTS = { compact: 90, medium: 200, full: 340 };
|
||
|
||
function makePanelHTML(id, icon, label, src) {
|
||
const h = watchState[id + '_size'] || 'compact';
|
||
const hidden = watchState[id + '_hidden'] === true;
|
||
return `
|
||
<div class="watch-panel ${hidden ? 'watch-panel-hidden' : ''}" id="wp_${id}" data-id="${id}" data-size="${h}">
|
||
<div class="watch-panel-header">
|
||
<span class="watch-panel-label">${icon} ${label}</span>
|
||
<div class="watch-panel-actions">
|
||
<button class="wp-btn wp-shrink" title="Recolher" data-id="${id}">−</button>
|
||
<button class="wp-btn wp-expand" title="Expandir" data-id="${id}">+</button>
|
||
<button class="wp-btn wp-hide" title="Ocultar painel" data-id="${id}">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="watch-panel-body" style="height:${HEIGHTS[h]}px">
|
||
<iframe src="${src}" title="${label}" loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
watchBar.innerHTML = `
|
||
<div class="watch-bar-inner">
|
||
<div class="watch-bar-grip" title="Arrastar">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/></svg>
|
||
<span style="font-size:11px;font-weight:700;letter-spacing:0.06em;color:var(--ciano)">MONITOR</span>
|
||
</div>
|
||
<div class="watch-panels" id="watchPanels">
|
||
${makePanelHTML('camera', '📷', 'Câmera ao Vivo', '/painel?clean=1')}
|
||
${makePanelHTML('rosto', '🤖', 'Rosto do Carinha', '/carinha.html?espelho=1&audio=0&clean=1')}
|
||
</div>
|
||
<div class="watch-bar-tools">
|
||
<button id="watchAddCamera" class="wp-tool-btn" title="Mostrar/ocultar Câmera">📷</button>
|
||
<button id="watchAddRosto" class="wp-tool-btn" title="Mostrar/ocultar Rosto">🤖</button>
|
||
<button id="watchBarToggle" class="wp-tool-btn" title="Ocultar barra inteira" style="margin-top:auto">☰</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(watchBar);
|
||
|
||
// Aplica estado inicial de visibilidade dos painéis
|
||
['camera', 'rosto'].forEach(id => {
|
||
const panel = watchBar.querySelector('#wp_' + id);
|
||
if (!panel) return;
|
||
if (watchState[id + '_hidden'] === true) panel.classList.add('watch-panel-hidden');
|
||
});
|
||
|
||
// Oculta a barra inteira se o usuário fechou antes
|
||
if (watchState['bar_hidden'] === true) {
|
||
watchBar.classList.add('watch-bar-collapsed');
|
||
}
|
||
|
||
// Toggle da barra inteira
|
||
watchBar.querySelector('#watchBarToggle').addEventListener('click', () => {
|
||
const collapsed = watchBar.classList.toggle('watch-bar-collapsed');
|
||
watchState['bar_hidden'] = collapsed;
|
||
saveWatchState();
|
||
if (window.RobotAudio) window.RobotAudio.click();
|
||
});
|
||
|
||
// Toggle de visibilidade dos painéis individuais
|
||
watchBar.querySelector('#watchAddCamera').addEventListener('click', () => {
|
||
const panel = watchBar.querySelector('#wp_camera');
|
||
const hidden = panel.classList.toggle('watch-panel-hidden');
|
||
watchState['camera_hidden'] = hidden;
|
||
saveWatchState();
|
||
});
|
||
watchBar.querySelector('#watchAddRosto').addEventListener('click', () => {
|
||
const panel = watchBar.querySelector('#wp_rosto');
|
||
const hidden = panel.classList.toggle('watch-panel-hidden');
|
||
watchState['rosto_hidden'] = hidden;
|
||
saveWatchState();
|
||
});
|
||
|
||
// Expandir / Recolher tamanho dos painéis
|
||
watchBar.addEventListener('click', e => {
|
||
const btn = e.target.closest('.wp-expand, .wp-shrink, .wp-hide');
|
||
if (!btn) return;
|
||
const id = btn.dataset.id;
|
||
const panel = watchBar.querySelector('#wp_' + id);
|
||
const body = panel.querySelector('.watch-panel-body');
|
||
const sizes = Object.keys(HEIGHTS);
|
||
|
||
if (btn.classList.contains('wp-expand')) {
|
||
const cur = sizes.indexOf(panel.dataset.size);
|
||
const next = sizes[Math.min(cur + 1, sizes.length - 1)];
|
||
panel.dataset.size = next;
|
||
body.style.height = HEIGHTS[next] + 'px';
|
||
watchState[id + '_size'] = next;
|
||
} else if (btn.classList.contains('wp-shrink')) {
|
||
const cur = sizes.indexOf(panel.dataset.size);
|
||
const next = sizes[Math.max(cur - 1, 0)];
|
||
panel.dataset.size = next;
|
||
body.style.height = HEIGHTS[next] + 'px';
|
||
watchState[id + '_size'] = next;
|
||
} else if (btn.classList.contains('wp-hide')) {
|
||
const hidden = panel.classList.toggle('watch-panel-hidden');
|
||
watchState[id + '_hidden'] = hidden;
|
||
}
|
||
saveWatchState();
|
||
if (window.RobotAudio) window.RobotAudio.click();
|
||
});
|
||
|
||
// Ajusta padding do conteúdo principal para não sobrepor a watch-bar
|
||
const appContent = document.getElementById('appContent');
|
||
function syncWatchBarOffset() {
|
||
const collapsed = watchBar.classList.contains('watch-bar-collapsed');
|
||
const h = collapsed ? 0 : watchBar.offsetHeight;
|
||
if (appContent) appContent.style.paddingTop = h > 0 ? (h + 12) + 'px' : '';
|
||
document.body.classList.toggle('watch-bar-active', !collapsed);
|
||
}
|
||
const ro = new ResizeObserver(syncWatchBarOffset);
|
||
ro.observe(watchBar);
|
||
syncWatchBarOffset();
|
||
|
||
// Também atualiza o offset ao colapsar/expandir a barra
|
||
const origToggle = watchBar.querySelector('#watchBarToggle');
|
||
if (origToggle) {
|
||
const prevHandler = origToggle.onclick;
|
||
origToggle.addEventListener('click', () => {
|
||
setTimeout(syncWatchBarOffset, 320); // após animação
|
||
});
|
||
}
|
||
})();
|
||
|