- 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>
365 lines
9.8 KiB
Svelte
365 lines
9.8 KiB
Svelte
<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>
|