Files
carinha-control-panel/web/src/App.svelte
T
julioandClaude Opus 5.5 f45e05ab81 feat(ao-vivo): avisa bateria baixa e mostra o ultimo sinal do celular
- aviso no painel quando a bateria do celular chega a 15% e a 5% sem
  carregador (uma vez cada; volta a valer depois de carregar)
- selo do celular fica vermelho com bateria baixa
- celular offline mostra ha quanto tempo foi o ultimo sinal e com quanta
  bateria; abaixo de 10% sugere que a bateria acabou

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:24:33 -03:00

365 lines
9.8 KiB
Svelte
Raw 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.
<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, SECUNDARIAS, tituloDa, 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';
import Rosto from './telas/Rosto.svelte';
const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo,
conversa: Conversa,
reacoes: Reacoes,
movimento: Movimento,
ajustes: Ajustes,
rosto: Rosto,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
conversa: MessageCircle,
reacoes: Smile,
movimento: Hand,
ajustes: Settings,
};
const Tela = $derived(TELAS[nav.atual]);
// Páginas secundárias acendem a seção de onde vieram.
const secao = $derived(SECUNDARIAS.find((r) => r.id === nav.atual)?.volta ?? 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(estado.bateria.nivel);
</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={secao === 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">{tituloDa(nav.atual)}</h1>
<div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto}
</Selo>
<Selo tom={!estado.celularOnline ? 'neutro' : estado.bateriaBaixa ? 'perigo' : 'ok'}>
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' && nav.atual !== 'reacoes' && nav.atual !== 'rosto'}
<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>