Files

460 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
});
}
})();