feat(painel): Ajustes com submenu e ligações do PIR pelo esquema real

Ajustes vira uma tela com submenu (#/ajustes/<seção>): cada grupo do
celular é uma seção (Tela, Sensores, Sono, Vídeo, Sensibilidade), mais
Robô (Inteligência artificial, Ligações da placa) e Painel (Segurança,
Sistema). No PC o menu fica numa coluna fixa com descrição; no celular,
numa fileira rolável que traz a seção aberta para a vista. A busca
procura em todos os grupos do celular.

Ligações da placa passa a ser uma seção dos Ajustes (#/ligacoes antigo
redireciona) e segue o esquema que já tinha sido desenhado para o PIR:
- HC-SR505 mini, marcação + · out · −; no cabo, + no fio preto →
  pino 4, out no verde → pino 11, − no branco → pino 14;
- etiquetas do conector e tabela mostram a cor real do fio;
- tela TFT reserva também o pino 7; pinos livres listados;
- avisos do HC-SR505 (sinal de ~8 s, 30–60 s para estabilizar, teste
  com gpiomon).

Rotas ganham nav.sub (seção da tela); atalhos vão direto à seção
(câmera, IA, ligações).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-09 18:26:15 -03:00
co-authored by Claude Opus 5.5
parent 47a836276a
commit ccd89d125e
9 changed files with 427 additions and 207 deletions
-2
View File
@@ -21,7 +21,6 @@
import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.svelte';
import DiaADia from './telas/DiaADia.svelte';
import Ligacoes from './telas/Ligacoes.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia';
@@ -36,7 +35,6 @@
guia: Guia,
criar: Criar,
'dia-a-dia': DiaADia,
ligacoes: Ligacoes,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pedir } from '../lib/api';
import Card from '../lib/componentes/Card.svelte';
import { estado } from '../lib/estado.svelte';
import { dispositivos, NOME_TIPO, pinoDaLinha, PINOS, type Dispositivo, type Pino, type TipoPino } from '../lib/ligacoes';
import { pedir } from '../api';
import { estado } from '../estado.svelte';
import { dispositivos, NOME_TIPO, pinoDaLinha, PINOS, type Dispositivo, type Fio, type Pino, type TipoPino } from '../ligacoes';
import Card from './Card.svelte';
// Conector de 26 pinos do Orange Pi Zero 3 com o que está ligado em cada pino. O pino do PIR
// vem da placa (CARINHA_PIR); o estado ao vivo do PIR e da PCA9685 vem do servidor.
@@ -25,8 +25,8 @@
// pino -> fios ligados nele (um pino pode ter mais de um dispositivo só no caso dos GND/5 V)
const fiosPorPino = $derived.by(() => {
const m = new Map<number, { d: Dispositivo; terminal: string; cor: string }[]>();
for (const d of lista) for (const f of d.fios) m.set(f.pino, [...(m.get(f.pino) ?? []), { d, terminal: f.terminal, cor: f.cor }]);
const m = new Map<number, (Fio & { d: Dispositivo })[]>();
for (const d of lista) for (const f of d.fios) m.set(f.pino, [...(m.get(f.pino) ?? []), { ...f, d }]);
return m;
});
const linhas = $derived(Array.from({ length: 13 }, (_, i) => [PINOS[i * 2], PINOS[i * 2 + 1]] as [Pino, Pino]));
@@ -62,7 +62,9 @@
{@const fd = fiosPorPino.get(par.num) ?? []}
<div class="fios esq" class:apagado={apagado(impar)}>
{#each fe as f (f.d.id)}
<span class="fio" style="--c: {f.cor}" title={f.d.nome}>{f.d.emoji} {f.terminal}</span>
<span class="fio" style="--c: {f.cor}" title={f.d.nome + (f.cabo ? ' · fio ' + f.cabo.nome : '')}>
{f.d.emoji} {f.terminal}{#if f.cabo}<i class="cabo" style="--k: {f.cabo.cor}" aria-label={'fio ' + f.cabo.nome}></i>{/if}
</span>
{/each}
</div>
<div class="pino esq" class:apagado={apagado(impar)} class:usado={fe.length} title={impar.funcao ?? NOME_TIPO[impar.tipo]}>
@@ -75,7 +77,9 @@
</div>
<div class="fios dir" class:apagado={apagado(par)}>
{#each fd as f (f.d.id)}
<span class="fio" style="--c: {f.cor}" title={f.d.nome}>{f.d.emoji} {f.terminal}</span>
<span class="fio" style="--c: {f.cor}" title={f.d.nome + (f.cabo ? ' · fio ' + f.cabo.nome : '')}>
{#if f.cabo}<i class="cabo" style="--k: {f.cabo.cor}" aria-label={'fio ' + f.cabo.nome}></i>{/if}{f.d.emoji} {f.terminal}
</span>
{/each}
</div>
{/each}
@@ -106,13 +110,17 @@
</span>
</button>
<p class="desc">{d.descricao}</p>
{@const temCabo = d.fios.some((f) => f.cabo)}
<table>
<thead><tr><th>No dispositivo</th><th>Pino da placa</th><th>O que é</th></tr></thead>
<thead><tr><th>No dispositivo</th>{#if temCabo}<th>Fio do cabo</th>{/if}<th>Pino da placa</th><th>O que é</th></tr></thead>
<tbody>
{#each d.fios as f (f.pino + f.terminal)}
{@const p = PINOS[f.pino - 1]}
<tr>
<td><span class="ponto" style="--c: {f.cor}"></span>{f.terminal}</td>
{#if temCabo}
<td class="sem-quebra">{#if f.cabo}<i class="cabo" style="--k: {f.cabo.cor}"></i> {f.cabo.nome}{/if}</td>
{/if}
<td class="mono">{f.pino}</td>
<td>{p.funcao ?? NOME_TIPO[p.tipo]}{p.tipo === 'gpio' ? ` (${p.nome})` : ''}</td>
</tr>
@@ -207,6 +215,19 @@
font-size: 11px;
white-space: nowrap;
}
.sem-quebra {
white-space: nowrap;
}
.cabo {
display: inline-block;
width: 10px;
height: 10px;
margin: 0 4px;
border-radius: 50%;
background: var(--k);
border: 1.5px solid var(--texto-3);
vertical-align: -1px;
}
.apagado {
opacity: 0.25;
}
+4 -4
View File
@@ -81,12 +81,12 @@ export const TOPICOS: Record<string, TopicoGuia> = {
resumo:
'Mapa do conector de 26 pinos do Orange Pi Zero 3 com o que está ligado em cada pino: o sensor de presença (PIR), a placa dos servos (PCA9685) e os pinos reservados para a tela SPI. O pino do PIR vem da própria placa, então o desenho acompanha a configuração.',
itens: [
{ emoji: '🚶', nome: 'PIR', texto: 'VCC no 5 V (pino 2 ou 4), OUT no pino 11 (PC6) e GND num terra (pino 9, por exemplo). Nunca ligue o VCC no pino 11: os GPIOs são de 3,3 V. A ordem dos terminais muda entre módulos; leia o que está escrito no sensor.' },
{ emoji: '🚶', nome: 'PIR (HC-SR505)', texto: 'Plaquinha marcada + · out · −. No seu cabo: + no fio PRETO → pino 4 (5 V); out no fio VERDE → pino 11 (PC6); − no fio BRANCO → pino 14 (terra; o 9 também serve). Siga a marcação, não a cor. Nunca ligue o + no pino 11: os GPIOs são de 3,3 V.' },
{ emoji: '🦾', nome: 'PCA9685', texto: 'VCC no 3,3 V (pino 1), SDA no 3, SCL no 5 e GND no 6. Os servos usam a fonte externa no borne V+, com terra comum com a placa.' },
{ emoji: '🖥️', nome: 'Tela SPI (tft35)', texto: 'O overlay tft35 reserva os pinos 18, 19, 21, 22, 23 e 24. Não ligue sensores neles.' },
{ emoji: '🖥️', nome: 'Tela SPI (tft35)', texto: 'O overlay tft35 reserva os pinos 7, 18, 19, 21, 22, 23 e 24. Não ligue sensores neles. Livres para sensores novos: 12, 13, 15, 16 e 26.' },
{ emoji: '🟢', nome: 'Estado ao vivo', texto: 'Cada dispositivo mostra se a placa está lendo: o PIR diz se o sinal está alto agora, e a PCA9685 se responde no I²C.' },
],
dica: 'Sinal do PIR alto o tempo todo, por muito tempo, costuma ser fio trocado (VCC e OUT invertidos), sensor apontado para uma fonte de calor ou o ajuste de tempo do HC-SR501 no máximo.',
dica: 'Sinal do PIR alto o tempo todo, por muito tempo, não é normal no HC-SR505 (ele segura só uns 8 s): confira se o + e o out não estão trocados, ou se ele aponta para uma fonte de calor.',
},
cuidados: {
titulo: 'Cuidar do Carinha',
@@ -375,5 +375,5 @@ export const ORDEM_GUIA = [
/** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado', ligacoes: 'ligacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
};
+18 -14
View File
@@ -53,6 +53,8 @@ export interface Fio {
/** Nome do terminal no dispositivo (VCC, OUT, GND, SDA...). */
terminal: string;
cor: string;
/** Cor real do fio no cabo, quando a gente sabe (ex.: o PIR tem o fio preto no +). */
cabo?: { nome: string; cor: string };
}
export interface Dispositivo {
@@ -79,19 +81,20 @@ export function dispositivos(pinoPir: number | null): Dispositivo[] {
id: 'pir',
nome: 'Sensor de presença (PIR)',
emoji: '🚶',
cor: '#34c759',
cor: '#1c9b4f',
situacao: 'em uso',
descricao: 'Módulo PIR (tipo HC-SR501 ou AM312). Avisa quando alguém se mexe na frente: acorda o rosto e liga a câmera no modo presença.',
descricao: 'HC-SR505 mini. Avisa quando alguém se mexe na frente (até uns 3 m, cone de 100°): acorda o rosto e liga a câmera no modo presença.',
fios: [
{ pino: 2, terminal: 'VCC', cor: '#ff3b30' },
{ pino: saida, terminal: 'OUT', cor: '#ffcc00' },
{ pino: 9, terminal: 'GND', cor: '#8e8e93' },
{ pino: 4, terminal: '+', cor: '#1c9b4f', cabo: { nome: 'preto', cor: '#111111' } },
{ pino: saida, terminal: 'out', cor: '#1c9b4f', cabo: { nome: 'verde', cor: '#1c9b4f' } },
{ pino: 14, terminal: '−', cor: '#1c9b4f', cabo: { nome: 'branco', cor: '#f4f4f4' } },
],
avisos: [
'A ordem dos 3 terminais muda de um módulo para outro: leia o que está escrito na plaquinha do sensor (no HC-SR501, tire a cúpula branca para ver VCC, OUT e GND).',
`Nunca ligue o VCC (5 V) no pino ${saida}: os GPIOs da placa são de 3,3 V e queimam com 5 V.`,
'A saída do HC-SR501 e do AM312 é de 3,3 V, segura para o GPIO. Se o seu sensor soltar 5 V na saída, use um divisor de tensão.',
'O VCC pode ir em qualquer 5 V (pino 2 ou 4) e o GND em qualquer terra (6, 9, 14, 20 ou 25).',
'No seu cabo o fio PRETO está no +. Siga a marcação da plaquinha (+ · out · −, da esquerda para a direita), não a cor do fio. Inverter + e − pode queimar o sensor.',
`Nunca ligue o + (5 V) no pino ${saida}: os GPIOs da placa são de 3,3 V e queimam com 5 V. O out do HC-SR505 já sai em 3,3 V, seguro para o pino ${saida}.`,
'O HC-SR505 não tem ajuste e segura o sinal ligado por uns 8 s depois de cada movimento. Sinal ligado direto por minutos seguidos não é normal: confira os fios.',
'Depois de ligado, ele leva de 30 a 60 s para estabilizar; disparos falsos nesse tempo são normais. Aponte para onde as pessoas passam, longe de sol e de saída de ar quente.',
'O − também pode ir no pino 9 (é terra). Teste na placa: gpiomon -c gpiochip1 -e rising ' + (saida === 11 ? '70' : 'LINHA') + ' imprime uma linha a cada movimento.',
],
},
{
@@ -102,13 +105,13 @@ export function dispositivos(pinoPir: number | null): Dispositivo[] {
situacao: 'em uso',
descricao: 'Controla os servos do braço por I²C (barramento /dev/i2c-2, endereço 0x40).',
fios: [
{ pino: 1, terminal: 'VCC', cor: '#ff9500' },
{ pino: 1, terminal: 'VCC', cor: '#0a84ff' },
{ pino: 3, terminal: 'SDA', cor: '#0a84ff' },
{ pino: 5, terminal: 'SCL', cor: '#5ac8fa' },
{ pino: 6, terminal: 'GND', cor: '#8e8e93' },
{ pino: 5, terminal: 'SCL', cor: '#0a84ff' },
{ pino: 6, terminal: 'GND', cor: '#0a84ff' },
],
avisos: [
'O VCC da PCA9685 vai no 3,3 V (pino 1): é só a lógica.',
'Já ligada e funcionando. O VCC da PCA9685 vai no 3,3 V (pino 1): é só a lógica.',
'Os servos NÃO são alimentados pela placa: o borne V+ da PCA9685 recebe a fonte externa (6 V), com o GND da fonte ligado ao GND da placa (terra comum).',
],
},
@@ -118,8 +121,9 @@ export function dispositivos(pinoPir: number | null): Dispositivo[] {
emoji: '🖥️',
cor: '#af52de',
situacao: 'reservado',
descricao: 'O overlay tft35 está ativo no boot e reserva estes pinos para uma tela SPI. O Carinha não usa essa tela: não ligue outros sensores neles.',
descricao: 'O overlay tft35 está ativo no boot e reserva estes pinos para uma tela SPI. O Carinha não usa essa tela: não ligue outros sensores neles. Livres para sensores novos: 12, 13, 15, 16 e 26.',
fios: [
{ pino: 7, terminal: 'TFT', cor: '#af52de' },
{ pino: 18, terminal: 'DC', cor: '#af52de' },
{ pino: 19, terminal: 'MOSI', cor: '#af52de' },
{ pino: 21, terminal: 'MISO', cor: '#af52de' },
+1 -1
View File
@@ -37,7 +37,7 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
{ id: 'ir-ligacoes', grupo: 'Ir para', titulo: 'Ligações da placa (pinos do PIR, servos e tela)', fazer: () => nav.ir('ligacoes') },
{ id: 'ir-ligacoes', grupo: 'Ir para', titulo: 'Ligações da placa (pinos do PIR, servos e tela)', fazer: () => nav.ir('ajustes', 'ligacoes') },
{ id: 'ir-dia-a-dia', grupo: 'Ir para', titulo: 'Dia a dia (rotina e decisões do Carinha)', fazer: () => nav.ir('dia-a-dia') },
{ id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') },
...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({
+18 -7
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia' | 'ligacoes';
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -17,23 +17,34 @@ export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ id: 'guia', titulo: 'Guia', volta: 'ajustes' },
{ id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' },
{ id: 'dia-a-dia', titulo: 'Dia a dia', volta: 'ao-vivo' },
{ id: 'ligacoes', titulo: 'Ligações da placa', volta: 'ajustes' },
];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
/** #/ajustes/ia -> ['ajustes', 'ia']: a segunda parte é a seção (submenu) da tela. */
function partes(): [string, string] {
const [id, sub = ''] = location.hash.replace(/^#\/?/, '').split('?')[0].split('/');
if (id === 'historico') return ['eventos', sub]; // endereço antigo
if (id === 'ligacoes') return ['ajustes', 'ligacoes']; // virou seção dos Ajustes
return [id, sub];
}
function lerHash(): Rota {
let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
const id = partes()[0] as Rota;
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
}
class Navegacao {
atual = $state<Rota>(lerHash());
ir(rota: Rota) {
location.hash = '/' + rota;
/** Seção dentro da tela (ex.: a parte dos Ajustes aberta). */
sub = $state(partes()[1]);
ir(rota: Rota, sub = '') {
location.hash = '/' + rota + (sub ? '/' + sub : '');
}
}
export const nav = new Navegacao();
addEventListener('hashchange', () => (nav.atual = lerHash()));
addEventListener('hashchange', () => {
nav.atual = lerHash();
nav.sub = partes()[1];
});
+242 -56
View File
@@ -1,29 +1,74 @@
<script lang="ts">
import { BookOpen, Cable, Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte';
import {
Activity, BookOpen, Brain, Cable, Download, Info, KeyRound, Moon, RefreshCw, RotateCcw, Search, Shield, ShieldCheck, ShieldOff,
SlidersHorizontal, Smartphone, Video,
} from '@lucide/svelte';
import type { Component } from 'svelte';
import { gravarToken, lerToken, postar } from '../lib/api';
import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte';
import { avisos, tentar } from '../lib/avisos.svelte';
import { recarregarCelular } from '../lib/comandos.svelte';
import Ajuda from '../lib/componentes/Ajuda.svelte';
import Card from '../lib/componentes/Card.svelte';
import LigacoesPlaca from '../lib/componentes/LigacoesPlaca.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Slider from '../lib/componentes/Slider.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { conectar, estado } from '../lib/estado.svelte';
import { nav } from '../lib/rotas.svelte';
// ---------- busca nos ajustes do celular
// Ajustes divididos em seções (submenu): os grupos do celular, o robô (IA e ligações da placa)
// e o painel (segurança e sistema). A seção fica no endereço: #/ajustes/ia, #/ajustes/ligacoes...
type Item = { id: string; titulo: string; detalhe: string; icone: Component<any>; cor: string };
const ICONE_GRUPO: Record<string, [Component<any>, string]> = {
tela: [Smartphone, '#0a84ff'], sensores: [Activity, '#34c759'], sono: [Moon, '#5e5ce6'], camera: [Video, '#ff9500'],
sensibilidade: [SlidersHorizontal, '#8e8e93'],
};
const SECOES: { grupo: string; itens: Item[] }[] = [
{
grupo: 'Celular',
itens: GRUPOS.map((g) => ({ id: g.ajuda, titulo: g.titulo, detalhe: g.descricao, icone: ICONE_GRUPO[g.ajuda]?.[0] ?? Smartphone, cor: ICONE_GRUPO[g.ajuda]?.[1] ?? '#8e8e93' })),
},
{
grupo: 'Robô',
itens: [
{ id: 'ia', titulo: 'Inteligência artificial', detalhe: 'Motor da conversa e chave do Gemini', icone: Brain, cor: '#af52de' },
{ id: 'ligacoes', titulo: 'Ligações da placa', detalhe: 'Pinos do PIR, dos servos e da tela', icone: Cable, cor: '#1c9b4f' },
],
},
{
grupo: 'Painel',
itens: [
{ id: 'seguranca', titulo: 'Segurança', detalhe: 'Token de acesso', icone: Shield, cor: '#ff3b30' },
{ id: 'sistema', titulo: 'Sistema', detalhe: 'Versão, app do celular e guia', icone: Info, cor: '#8e8e93' },
],
},
];
const TODOS = SECOES.flatMap((s) => s.itens);
const secao = $derived(TODOS.some((i) => i.id === nav.sub) ? nav.sub : TODOS[0].id);
const atual = $derived(TODOS.find((i) => i.id === secao)!);
const grupoAtual = $derived(GRUPOS.find((g) => g.ajuda === secao) ?? null);
// ---------- busca: procura em todos os ajustes do celular, de qualquer seção
let busca = $state('');
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const grupos = $derived(
const achados = $derived(
GRUPOS.map((g) => ({
...g,
campos: g.campos.filter((c) => !busca || normal(c.rotulo + ' ' + (c.ajuda ?? '')).includes(normal(busca))),
campos: g.campos.filter((c) => normal(c.rotulo + ' ' + (c.ajuda ?? '')).includes(normal(busca))),
})).filter((g) => g.campos.length),
);
const valor = (c: Campo) => estado.appConfig?.[c.chave];
let confirmarPadrao = $state(false);
// no celular o submenu é uma fileira que rola: traz a seção aberta para a vista
let menu = $state<HTMLElement>();
$effect(() => {
secao;
menu?.querySelector<HTMLElement>('.item.ativo')?.scrollIntoView({ block: 'nearest', inline: 'center' });
});
// ---------- IA
let modelo = $state('');
let chave = $state('');
@@ -67,41 +112,8 @@
}
</script>
<div class="ajustes">
<Card titulo="Celular" descricao="Mudanças valem na hora no aparelho. Nada trava enquanto salva.">
{#snippet acoes()}
<label class="busca">
<Search size={16} />
<input type="search" bind:value={busca} placeholder="Buscar ajuste" aria-label="Buscar ajuste" />
</label>
{#if confirmarPadrao}
<span class="pequeno-texto">Restaurar todos?</span>
<button
class="btn pequeno perigo"
onclick={async () => {
confirmarPadrao = false;
await tentar(restaurarPadrao);
}}>Sim, restaurar</button
>
<button class="btn pequeno" onclick={() => (confirmarPadrao = false)}>Cancelar</button>
{:else}
<button class="btn pequeno" onclick={() => (confirmarPadrao = true)} disabled={!estado.appConfig}>
<RotateCcw size={14} /> Restaurar padrão
</button>
{/if}
{/snippet}
{#if !estado.appConfig}
<p class="fraco">Carregando ajustes…</p>
{:else}
<div class="grupos">
{#each grupos as g (g.titulo)}
<fieldset>
<legend>
<strong class="titulo-grupo">{g.titulo} <Ajuda topico={g.ajuda} /></strong>
<span class="fraco pequeno-texto">{g.descricao}</span>
</legend>
{#each g.campos as c (c.chave)}
{#snippet campos(lista: Campo[])}
{#each lista as c (c.chave)}
{#if c.tipo === 'toggle'}
<Toggle rotulo={c.rotulo} ajuda={c.ajuda} checked={!!valor(c)} onchange={(v) => salvarApp({ [c.chave]: v })} />
{:else if c.tipo === 'slider'}
@@ -129,15 +141,77 @@
</label>
{/if}
{/each}
{/snippet}
{#snippet restaurar()}
{#if confirmarPadrao}
<span class="pequeno-texto">Restaurar todos os ajustes do celular?</span>
<button
class="btn pequeno perigo"
onclick={async () => {
confirmarPadrao = false;
await tentar(restaurarPadrao);
}}>Sim, restaurar</button
>
<button class="btn pequeno" onclick={() => (confirmarPadrao = false)}>Cancelar</button>
{:else}
<button class="btn pequeno" onclick={() => (confirmarPadrao = true)} disabled={!estado.appConfig}>
<RotateCcw size={14} /> Restaurar padrão
</button>
{/if}
{/snippet}
<div class="ajustes">
<nav class="menu" aria-label="Seções dos ajustes" bind:this={menu}>
<label class="busca">
<Search size={16} />
<input type="search" bind:value={busca} placeholder="Buscar ajuste do celular" aria-label="Buscar ajuste do celular" />
</label>
{#each SECOES as g (g.grupo)}
<div class="grupo-menu">
<span class="rotulo-grupo">{g.grupo}</span>
{#each g.itens as it (it.id)}
<a
href={'#/ajustes/' + it.id}
class="item"
class:ativo={!busca && secao === it.id}
aria-current={!busca && secao === it.id ? 'page' : undefined}
onclick={() => (busca = '')}
>
<span class="icone" style="--c: {it.cor}"><it.icone size={15} /></span>
<span class="txt"><span>{it.titulo}</span><small>{it.detalhe}</small></span>
</a>
{/each}
</div>
{/each}
</nav>
<div class="conteudo">
{#if busca}
<Card titulo={`Ajustes com “${busca}”`} descricao="Resultado em todos os grupos do celular.">
{#if !estado.appConfig}
<p class="fraco">Carregando ajustes…</p>
{:else}
{#each achados as g (g.titulo)}
<fieldset>
<legend><strong class="titulo-grupo">{g.titulo} <Ajuda topico={g.ajuda} /></strong></legend>
{@render campos(g.campos)}
</fieldset>
{:else}
<p class="fraco">Nenhum ajuste com “{busca}”.</p>
{/each}
</div>
{/if}
</Card>
<div class="coluna">
{:else if grupoAtual}
<Card titulo={grupoAtual.titulo} ajuda={grupoAtual.ajuda} descricao={grupoAtual.descricao + ' Mudanças valem na hora no aparelho.'}>
{#snippet acoes()}{@render restaurar()}{/snippet}
{#if !estado.appConfig}
<p class="fraco">Carregando ajustes…</p>
{:else}
<div class="campos">{@render campos(grupoAtual.campos)}</div>
{/if}
</Card>
{:else if secao === 'ia'}
<Card titulo="Inteligência artificial" ajuda="conversa">
<div class="campo">
<span>Motor da conversa</span>
@@ -191,7 +265,9 @@
{/if}
</form>
</Card>
{:else if secao === 'ligacoes'}
<LigacoesPlaca />
{:else if secao === 'seguranca'}
<Card titulo="Segurança">
<div class="linha">
{#if estado.autenticacao}
@@ -212,7 +288,7 @@
<button class="btn pequeno" onclick={esquecerToken}>Esquecer token neste navegador</button>
{/if}
</Card>
{:else}
<Card titulo="Sistema">
<dl class="info">
<div><dt>Versão do servidor</dt><dd class="mono">{estado.versao || '—'}</dd></div>
@@ -225,9 +301,10 @@
</button>
<a class="btn pequeno" href="/app.apk" download><Download size={14} /> Baixar app Android</a>
<a class="btn pequeno" href="#/guia"><BookOpen size={14} /> Guia do painel</a>
<a class="btn pequeno" href="#/ligacoes"><Cable size={14} /> Ligações da placa</a>
</div>
</Card>
{/if}
<span class="sr" aria-live="polite">{busca ? '' : atual.titulo}</span>
</div>
</div>
@@ -237,14 +314,63 @@
gap: var(--e-4);
align-items: start;
}
.coluna {
.conteudo {
min-width: 0;
display: grid;
gap: var(--e-4);
}
/* ---------- submenu: fileira rolável no celular, coluna fixa no PC */
.menu {
display: flex;
gap: var(--e-2);
align-items: center;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: var(--e-1);
}
.grupo-menu {
display: contents;
}
.rotulo-grupo {
display: none;
}
.item {
display: inline-flex;
align-items: center;
gap: var(--e-2);
flex: none;
padding: 6px 12px 6px 6px;
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: var(--superficie);
color: var(--texto-2);
font-size: var(--t-sm);
text-decoration: none;
white-space: nowrap;
}
.item.ativo {
border-color: var(--destaque);
background: var(--destaque-suave);
color: var(--texto);
}
.item small {
display: none;
}
.icone {
display: grid;
place-items: center;
width: 26px;
height: 26px;
flex: none;
border-radius: 7px;
background: var(--c);
color: #fff;
}
.busca {
display: flex;
align-items: center;
gap: var(--e-2);
flex: none;
padding-left: var(--e-3);
border: 1px solid var(--borda-forte);
border-radius: var(--raio-sm);
@@ -254,17 +380,73 @@
.busca input {
border: 0;
background: transparent;
min-height: 32px;
width: 160px;
min-height: 34px;
width: 150px;
}
.grupos {
@media (min-width: 900px) {
.ajustes {
grid-template-columns: 250px minmax(0, 1fr);
}
.menu {
position: sticky;
top: calc(var(--topo-altura) + var(--e-4));
flex-direction: column;
align-items: stretch;
gap: var(--e-4);
overflow: visible;
}
.busca input {
width: 100%;
}
.grupo-menu {
display: grid;
gap: var(--e-5);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
gap: 2px;
}
.rotulo-grupo {
display: block;
padding: 0 var(--e-2) var(--e-1);
font-size: var(--t-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--texto-3);
}
.item {
padding: var(--e-2);
border: 0;
border-radius: var(--raio-sm);
background: transparent;
white-space: normal;
}
.item:hover {
background: var(--superficie-2);
}
.item.ativo {
background: var(--superficie);
box-shadow: var(--sombra);
}
.txt {
display: grid;
gap: 1px;
min-width: 0;
}
.item small {
display: block;
color: var(--texto-3);
font-size: var(--t-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.campos {
display: grid;
gap: var(--e-2);
max-width: 640px;
}
fieldset {
border: 0;
margin: 0;
margin: 0 0 var(--e-4);
padding: 0;
display: grid;
gap: var(--e-2);
@@ -317,6 +499,7 @@
gap: var(--e-3);
padding-top: var(--e-3);
border-top: 1px solid var(--borda);
max-width: 560px;
}
.ok {
color: var(--ok);
@@ -328,6 +511,7 @@
display: grid;
gap: var(--e-2);
margin: 0;
max-width: 480px;
}
.info div {
display: flex;
@@ -341,9 +525,11 @@
dd {
margin: 0;
}
@media (min-width: 1200px) {
.ajustes {
grid-template-columns: 2fr 1fr;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>
+2 -2
View File
@@ -162,7 +162,7 @@ const pirAtivo = $derived(
{/snippet}
</Linha>
{/if}
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" />
</Grupo></div>
<div class="c-gestos"><Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
@@ -195,7 +195,7 @@ const pirAtivo = $derived(
{#snippet acessorio()}<PresencaPir />{/snippet}
</Linha>
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
<Linha icone={Cable} cor="#8e8e93" rotulo="Ligações da placa" detalhe="Em que pino vai cada fio do PIR e dos servos" href="#/ligacoes" />
<Linha icone={Cable} cor="#8e8e93" rotulo="Ligações da placa" detalhe="Em que pino vai cada fio do PIR e dos servos" href="#/ajustes/ligacoes" />
{#if estado.feed.length}
<ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)}
+1 -1
View File
@@ -128,7 +128,7 @@
{#snippet acoes()}
{#if conversa}
<Selo tom="destaque">{conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'} · {conversa.modelo}</Selo>
<a class="pequeno-texto" href="#/ajustes">trocar</a>
<a class="pequeno-texto" href="#/ajustes/ia">trocar</a>
{/if}
{/snippet}