feat(web): cria painel Svelte com shell, Ao vivo e Ajustes
- web/ com Vite + Svelte 5 + TypeScript; servidor entrega web/dist em "/" (telas antigas seguem em /dashboard, /painel, /braco...) - design system em tokens CSS, tema escuro e claro, fontes locais - estado unico: foto de /estado + um EventSource em /eventos, sem polling - shell com navegacao lateral/inferior, selos de status reais e E-Stop fixo - command palette (Ctrl+K), gestos favoritos com atalhos 1-9, g+letra navega - Ao vivo: camera WebRTC e rastreamento portados para TS, rosto, aparencia, sensores do celular, IA local e feed de acontecimentos - Ajustes: campos do celular agrupados com busca, salvamento otimista e restaurar padrao (GET /app/padrao); motor da conversa, Gemini e token - carinha.html ganha modo ?painel=1 dirigido por postMessage (sem polling) - teste Playwright de ponta a ponta do painel novo Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
<script lang="ts">
|
||||
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||
import { onMount, type Component } from 'svelte';
|
||||
import Avisos from './lib/componentes/Avisos.svelte';
|
||||
import EStop from './lib/componentes/EStop.svelte';
|
||||
import MiniRosto from './lib/componentes/MiniRosto.svelte';
|
||||
import Paleta from './lib/componentes/Paleta.svelte';
|
||||
import Selo from './lib/componentes/Selo.svelte';
|
||||
import Token from './lib/componentes/Token.svelte';
|
||||
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
|
||||
import { conectar, estado } from './lib/estado.svelte';
|
||||
import { paleta } from './lib/paleta.svelte';
|
||||
import { nav, ROTAS, type Rota } from './lib/rotas.svelte';
|
||||
import AoVivo from './telas/AoVivo.svelte';
|
||||
import Ajustes from './telas/Ajustes.svelte';
|
||||
import Conversa from './telas/Conversa.svelte';
|
||||
import Movimento from './telas/Movimento.svelte';
|
||||
import Reacoes from './telas/Reacoes.svelte';
|
||||
|
||||
const TELAS: Record<Rota, Component> = {
|
||||
'ao-vivo': AoVivo,
|
||||
conversa: Conversa,
|
||||
reacoes: Reacoes,
|
||||
movimento: Movimento,
|
||||
ajustes: Ajustes,
|
||||
};
|
||||
const ICONES: Record<Rota, Component<any>> = {
|
||||
'ao-vivo': Video,
|
||||
conversa: MessageCircle,
|
||||
reacoes: Smile,
|
||||
movimento: Hand,
|
||||
ajustes: Settings,
|
||||
};
|
||||
const Tela = $derived(TELAS[nav.atual]);
|
||||
|
||||
// ---------- tema
|
||||
let tema = $state<'dark' | 'light'>('dark');
|
||||
function aplicarTema(t: 'dark' | 'light') {
|
||||
tema = t;
|
||||
document.documentElement.dataset.theme = t;
|
||||
try {
|
||||
localStorage.setItem('carinha_tema', t);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- atalhos: Ctrl+K paleta · 1–9 gestos favoritos · Shift+Espaço ou Esc Esc = E-Stop · g+letra navega
|
||||
let ultimoEsc = 0;
|
||||
let prefixoG = 0;
|
||||
function editando(e: KeyboardEvent) {
|
||||
const alvo = e.target as HTMLElement;
|
||||
return alvo.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(alvo.tagName);
|
||||
}
|
||||
function teclado(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
paleta.aberta ? paleta.fechar() : paleta.abrir();
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && e.code === 'Space' && !editando(e)) {
|
||||
e.preventDefault();
|
||||
pararTudo();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && !paleta.aberta) {
|
||||
const agora = Date.now();
|
||||
if (agora - ultimoEsc < 600) pararTudo();
|
||||
ultimoEsc = agora;
|
||||
return;
|
||||
}
|
||||
if (editando(e) || paleta.aberta || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.key === 'g') {
|
||||
prefixoG = Date.now();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - prefixoG < 800) {
|
||||
const rota = ROTAS.find((r) => r.atalho === 'g ' + e.key);
|
||||
prefixoG = 0;
|
||||
if (rota) nav.ir(rota.id);
|
||||
return;
|
||||
}
|
||||
if (/^[1-9]$/.test(e.key)) {
|
||||
const g = favoritos.lista[Number(e.key) - 1];
|
||||
if (g) gesto(g);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let salvo: string | null = null;
|
||||
try {
|
||||
salvo = localStorage.getItem('carinha_tema');
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
aplicarTema(salvo === 'light' || (!salvo && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark');
|
||||
conectar();
|
||||
});
|
||||
|
||||
const conexaoTexto = $derived(
|
||||
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
|
||||
);
|
||||
const bateria = $derived.by(() => {
|
||||
const b = estado.app?.estado?.bateria;
|
||||
const n = typeof b === 'number' ? b : b?.nivel;
|
||||
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={teclado} />
|
||||
|
||||
{#if estado.conexao === 'token'}
|
||||
<Token />
|
||||
{/if}
|
||||
|
||||
<div class="app">
|
||||
<nav class="lateral" aria-label="Seções">
|
||||
<div class="marca">
|
||||
<span class="logo" aria-hidden="true"><Bot size={20} /></span>
|
||||
<div>
|
||||
<strong>Carinha</strong>
|
||||
<small class="fraco mono">{estado.versao ? 'v' + estado.versao : ''}</small>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each ROTAS as r (r.id)}
|
||||
{@const Icone = ICONES[r.id]}
|
||||
<li>
|
||||
<a href={'#/' + r.id} aria-current={nav.atual === r.id ? 'page' : undefined}>
|
||||
<Icone size={20} /> <span>{r.titulo}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<button class="btn fantasma paleta-btn" onclick={paleta.abrir}>
|
||||
<Command size={16} /> Comandos <kbd>Ctrl K</kbd>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<header class="topo">
|
||||
<h1 class="titulo">{ROTAS.find((r) => r.id === nav.atual)?.titulo}</h1>
|
||||
<div class="status linha">
|
||||
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
|
||||
{conexaoTexto}
|
||||
</Selo>
|
||||
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>
|
||||
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
|
||||
</Selo>
|
||||
{#if estado.conversa}
|
||||
<Selo tom="destaque">IA: {estado.conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'}</Selo>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="acoes linha">
|
||||
<button class="btn fantasma icone so-celular" onclick={paleta.abrir} aria-label="Comandos">
|
||||
<Command size={18} />
|
||||
</button>
|
||||
<button
|
||||
class="btn fantasma icone"
|
||||
onclick={() => aplicarTema(tema === 'dark' ? 'light' : 'dark')}
|
||||
aria-label={tema === 'dark' ? 'Usar tema claro' : 'Usar tema escuro'}
|
||||
>
|
||||
{#if tema === 'dark'}<Sun size={18} />{:else}<Moon size={18} />{/if}
|
||||
</button>
|
||||
<EStop />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="conteudo">
|
||||
<Tela />
|
||||
</main>
|
||||
|
||||
{#if nav.atual !== 'ao-vivo'}
|
||||
<MiniRosto />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Paleta />
|
||||
<Avisos />
|
||||
|
||||
<style>
|
||||
.app {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: var(--topo-altura) 1fr;
|
||||
grid-template-areas: 'topo' 'conteudo';
|
||||
}
|
||||
/* ---------- navegação: barra inferior no celular, lateral a partir de 1024px */
|
||||
.lateral {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
background: var(--vidro);
|
||||
backdrop-filter: blur(16px);
|
||||
border-top: 1px solid var(--borda);
|
||||
}
|
||||
.marca,
|
||||
.paleta-btn {
|
||||
display: none;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--e-1);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
li a {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--e-2) 0;
|
||||
border-radius: var(--raio);
|
||||
color: var(--texto-3);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
li a[aria-current='page'] {
|
||||
color: var(--destaque);
|
||||
}
|
||||
.topo {
|
||||
grid-area: topo;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
padding: 0 var(--e-4);
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
height: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
|
||||
background: var(--vidro);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--borda);
|
||||
}
|
||||
.titulo {
|
||||
font-size: var(--t-lg);
|
||||
flex: none;
|
||||
}
|
||||
.status {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.status :global(.selo:nth-child(n + 2)) {
|
||||
display: none;
|
||||
}
|
||||
.acoes {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.conteudo {
|
||||
grid-area: conteudo;
|
||||
padding: var(--e-4);
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
kbd {
|
||||
margin-left: auto;
|
||||
font: 500 11px var(--fonte-mono);
|
||||
color: var(--texto-3);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.status :global(.selo:nth-child(n + 2)) {
|
||||
display: inline-flex;
|
||||
}
|
||||
.conteudo {
|
||||
padding: var(--e-5);
|
||||
padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.app {
|
||||
grid-template-columns: var(--nav-largura) 1fr;
|
||||
grid-template-areas: 'nav topo' 'nav conteudo';
|
||||
/* a coluna da navegação continua pintada quando a página rola além da viewport */
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--fundo-2) calc(var(--nav-largura) - 1px),
|
||||
var(--borda) 0 var(--nav-largura),
|
||||
transparent 0
|
||||
);
|
||||
}
|
||||
.lateral {
|
||||
grid-area: nav;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--e-4);
|
||||
padding: var(--e-4) var(--e-3);
|
||||
border-top: 0;
|
||||
border-right: 1px solid var(--borda);
|
||||
background: var(--fundo-2);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.marca {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-2);
|
||||
}
|
||||
.marca div {
|
||||
display: grid;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.logo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--raio);
|
||||
background: var(--destaque-suave);
|
||||
color: var(--destaque);
|
||||
}
|
||||
ul {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-2) var(--e-3);
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-2);
|
||||
}
|
||||
li a:hover {
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
}
|
||||
li a[aria-current='page'] {
|
||||
background: var(--destaque-suave);
|
||||
color: var(--destaque);
|
||||
}
|
||||
.paleta-btn {
|
||||
display: flex;
|
||||
margin-top: auto;
|
||||
justify-content: flex-start;
|
||||
color: var(--texto-2);
|
||||
}
|
||||
.so-celular {
|
||||
display: none;
|
||||
}
|
||||
.conteudo {
|
||||
padding-bottom: var(--e-6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user