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,62 @@
|
||||
<script lang="ts">
|
||||
import { X } from '@lucide/svelte';
|
||||
import { avisos } from '../avisos.svelte';
|
||||
</script>
|
||||
|
||||
<div class="avisos" role="status" aria-live="polite">
|
||||
{#each avisos.lista as a (a.id)}
|
||||
<div class="aviso {a.tipo}">
|
||||
<span>{a.texto}</span>
|
||||
{#if a.acao}
|
||||
<button
|
||||
class="btn pequeno"
|
||||
onclick={() => {
|
||||
a.acao!.fazer();
|
||||
avisos.fechar(a.id);
|
||||
}}>{a.acao.rotulo}</button
|
||||
>
|
||||
{/if}
|
||||
<button class="btn fantasma pequeno icone" aria-label="Fechar aviso" onclick={() => avisos.fechar(a.id)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.avisos {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
right: var(--e-4);
|
||||
bottom: calc(var(--e-4) + env(safe-area-inset-bottom, 0px));
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
width: min(420px, calc(100vw - 2 * var(--e-4)));
|
||||
}
|
||||
.aviso {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: var(--e-2) var(--e-2) var(--e-2) var(--e-4);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
border: 1px solid var(--borda-forte);
|
||||
border-left: 4px solid var(--destaque);
|
||||
box-shadow: var(--sombra-flutuante);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.aviso span {
|
||||
flex: 1;
|
||||
}
|
||||
.ok {
|
||||
border-left-color: var(--ok);
|
||||
}
|
||||
.erro {
|
||||
border-left-color: var(--perigo);
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.avisos {
|
||||
bottom: calc(76px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
let { titulo = '', descricao = '', acoes, children, class: classe = '' }: {
|
||||
titulo?: string;
|
||||
descricao?: string;
|
||||
acoes?: Snippet;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section class="card {classe}">
|
||||
{#if titulo || acoes}
|
||||
<header>
|
||||
<div>
|
||||
{#if titulo}<h2>{titulo}</h2>{/if}
|
||||
{#if descricao}<p class="fraco pequeno-texto">{descricao}</p>{/if}
|
||||
</div>
|
||||
{#if acoes}<div class="linha">{@render acoes()}</div>{/if}
|
||||
</header>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--superficie);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-lg);
|
||||
padding: var(--e-4);
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--e-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--t-md);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.card {
|
||||
padding: var(--e-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { OctagonX, RotateCcw } from '@lucide/svelte';
|
||||
import { pararTudo, rearmar } from '../comandos.svelte';
|
||||
import { estado } from '../estado.svelte';
|
||||
</script>
|
||||
|
||||
{#if estado.estop.ativo}
|
||||
<button class="btn estop parado" onclick={rearmar} title="Motores travados pelo E-Stop. Clique para rearmar.">
|
||||
<RotateCcw size={18} /> <span>PARADO · Rearmar</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn perigo estop" onclick={pararTudo} title="Parar tudo (Shift+Espaço ou Esc duas vezes)">
|
||||
<OctagonX size={18} /> <span>Parar tudo</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.estop {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.parado {
|
||||
background: var(--aviso-suave);
|
||||
border-color: var(--aviso);
|
||||
color: var(--aviso);
|
||||
animation: pulso 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulso {
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px var(--aviso-suave);
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
.estop {
|
||||
width: 44px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { Star } from '@lucide/svelte';
|
||||
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
|
||||
|
||||
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
|
||||
let { completo = false }: { completo?: boolean } = $props();
|
||||
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
|
||||
</script>
|
||||
|
||||
<div class="gestos" class:completo>
|
||||
{#each lista as g, i (g)}
|
||||
{@const info = GESTOS_INFO[g]}
|
||||
<div class="item">
|
||||
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
|
||||
<span class="emoji" aria-hidden="true">{info.emoji}</span>
|
||||
<span class="nome">{info.nome}</span>
|
||||
{#if !completo}<kbd>{i + 1}</kbd>{/if}
|
||||
</button>
|
||||
{#if completo}
|
||||
<button
|
||||
class="estrela"
|
||||
class:ativa={favoritos.lista.includes(g)}
|
||||
aria-label={favoritos.lista.includes(g) ? `Tirar ${info.nome} dos favoritos` : `Fixar ${info.nome} nos favoritos`}
|
||||
aria-pressed={favoritos.lista.includes(g)}
|
||||
onclick={() => favoritos.alternar(g)}
|
||||
>
|
||||
<Star size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gestos {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.item {
|
||||
position: relative;
|
||||
}
|
||||
.gesto {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--e-3) var(--e-1) var(--e-2);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
|
||||
}
|
||||
.gesto:hover {
|
||||
border-color: var(--destaque);
|
||||
background: var(--destaque-suave);
|
||||
}
|
||||
.gesto:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.emoji {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.nome {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-2);
|
||||
text-align: center;
|
||||
}
|
||||
kbd {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font: 500 10px var(--fonte-mono);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.estrela {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--texto-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.estrela.ativa {
|
||||
color: var(--aviso);
|
||||
}
|
||||
.estrela.ativa :global(svg) {
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronUp } from '@lucide/svelte';
|
||||
import { estado } from '../estado.svelte';
|
||||
import RostoPrevia from './RostoPrevia.svelte';
|
||||
|
||||
// Um só mini-rosto em todas as telas (exceto Ao vivo, que já tem o rosto grande).
|
||||
let aberto = $state(true);
|
||||
try {
|
||||
aberto = localStorage.getItem('carinha_mini') !== '0';
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
function alternar() {
|
||||
aberto = !aberto;
|
||||
try {
|
||||
localStorage.setItem('carinha_mini', aberto ? '1' : '0');
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="mini" class:aberto aria-label="Rosto ao vivo">
|
||||
<button class="barra" onclick={alternar} aria-expanded={aberto}>
|
||||
<span class="ponto" class:online={estado.celularOnline} aria-hidden="true"></span>
|
||||
<span>Rosto {estado.celularOnline ? 'ao vivo' : '· celular offline'}</span>
|
||||
{#if aberto}<ChevronDown size={16} />{:else}<ChevronUp size={16} />{/if}
|
||||
</button>
|
||||
{#if aberto}
|
||||
<div class="tela"><RostoPrevia /></div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.mini {
|
||||
position: fixed;
|
||||
z-index: 55;
|
||||
right: var(--e-4);
|
||||
bottom: calc(84px + env(safe-area-inset-bottom, 0px));
|
||||
width: 220px;
|
||||
border-radius: var(--raio);
|
||||
overflow: hidden;
|
||||
background: var(--vidro);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--borda-forte);
|
||||
box-shadow: var(--sombra-flutuante);
|
||||
}
|
||||
.barra {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: var(--e-2) var(--e-3);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-xs) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.barra span:nth-child(2) {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.ponto {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--texto-3);
|
||||
}
|
||||
.ponto.online {
|
||||
background: var(--ok);
|
||||
}
|
||||
.tela {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.mini {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.mini {
|
||||
bottom: var(--e-4);
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { Search } from '@lucide/svelte';
|
||||
import { itensDaPaleta, paleta, type ItemPaleta } from '../paleta.svelte';
|
||||
|
||||
let busca = $state('');
|
||||
let ativo = $state(0);
|
||||
let campo = $state<HTMLInputElement>();
|
||||
|
||||
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
const itens = $derived.by(() => {
|
||||
const termos = normal(busca).split(/\s+/).filter(Boolean);
|
||||
return itensDaPaleta()
|
||||
.filter((i) => termos.every((t) => normal(i.grupo + ' ' + i.titulo).includes(t)))
|
||||
.slice(0, 50);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (paleta.aberta) {
|
||||
busca = '';
|
||||
ativo = 0;
|
||||
queueMicrotask(() => campo?.focus());
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
void busca;
|
||||
ativo = 0;
|
||||
});
|
||||
|
||||
function executar(i: ItemPaleta | undefined) {
|
||||
if (!i) return;
|
||||
paleta.fechar();
|
||||
i.fazer();
|
||||
}
|
||||
|
||||
function tecla(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
ativo = Math.min(itens.length - 1, ativo + 1);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
ativo = Math.max(0, ativo - 1);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Enter') {
|
||||
executar(itens[ativo]);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Escape') {
|
||||
paleta.fechar();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if paleta.aberta}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="fundo" onclick={paleta.fechar}>
|
||||
<div class="caixa" role="dialog" tabindex="-1" aria-modal="true" aria-label="Comandos" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="busca">
|
||||
<Search size={18} />
|
||||
<input
|
||||
bind:this={campo}
|
||||
bind:value={busca}
|
||||
onkeydown={tecla}
|
||||
placeholder="Gesto, rosto, tela ou ação…"
|
||||
aria-label="Buscar comando"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="paleta-lista"
|
||||
aria-activedescendant={itens[ativo] ? 'pal-' + itens[ativo].id : undefined}
|
||||
/>
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
<ul id="paleta-lista" role="listbox">
|
||||
{#each itens as item, i (item.id)}
|
||||
{#if i === 0 || itens[i - 1].grupo !== item.grupo}
|
||||
<li class="grupo" role="presentation">{item.grupo}</li>
|
||||
{/if}
|
||||
<li
|
||||
id={'pal-' + item.id}
|
||||
role="option"
|
||||
aria-selected={i === ativo}
|
||||
class:ativo={i === ativo}
|
||||
onmousemove={() => (ativo = i)}
|
||||
onclick={() => executar(item)}
|
||||
>
|
||||
<span>{item.titulo}</span>
|
||||
{#if item.dica}<kbd>{item.dica}</kbd>{/if}
|
||||
</li>
|
||||
{:else}
|
||||
<li class="vazio">Nada encontrado.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.fundo {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 95;
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-items: start;
|
||||
padding: 12vh var(--e-4) var(--e-4);
|
||||
}
|
||||
.caixa {
|
||||
width: min(560px, 100%);
|
||||
max-height: 70vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
background: var(--vidro);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid var(--borda-forte);
|
||||
border-radius: var(--raio-lg);
|
||||
box-shadow: var(--sombra-flutuante);
|
||||
overflow: hidden;
|
||||
}
|
||||
.busca {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-3) var(--e-4);
|
||||
border-bottom: 1px solid var(--borda);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.busca input {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
min-height: 36px;
|
||||
font-size: var(--t-md);
|
||||
padding: 0;
|
||||
}
|
||||
.busca input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--e-2);
|
||||
overflow: auto;
|
||||
}
|
||||
li[role='option'] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--e-2) var(--e-3);
|
||||
border-radius: var(--raio-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
li.ativo {
|
||||
background: var(--destaque-suave);
|
||||
color: var(--destaque);
|
||||
}
|
||||
.grupo {
|
||||
padding: var(--e-3) var(--e-3) var(--e-1);
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.vazio {
|
||||
padding: var(--e-4);
|
||||
color: var(--texto-3);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
kbd {
|
||||
font: 500 11px var(--fonte-mono);
|
||||
color: var(--texto-3);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Prévia do rosto: o próprio carinha.html em modo ?painel=1 (sem polling).
|
||||
* O painel repassa por postMessage o que chega no SSE, então a prévia mostra
|
||||
* exatamente as mesmas ordens que o celular recebe.
|
||||
*/
|
||||
import { onDestroy } from 'svelte';
|
||||
import { estado, ouvir } from '../estado.svelte';
|
||||
|
||||
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
|
||||
let quadro: HTMLIFrameElement;
|
||||
let pronto = $state(false);
|
||||
|
||||
function enviar(msg: unknown) {
|
||||
if (pronto) quadro?.contentWindow?.postMessage(msg, location.origin);
|
||||
}
|
||||
|
||||
function aoCarregar() {
|
||||
pronto = true;
|
||||
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
|
||||
}
|
||||
|
||||
let ultimaConversa = estado.conversa?.ultima?.id;
|
||||
const cancelar = [
|
||||
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
|
||||
ouvir('app_config', (c) => enviar({ tipo: 'config', config: c })),
|
||||
ouvir('reacao', (r) => r?.receita && enviar({ tipo: 'previa-reacao', receita: r.receita })),
|
||||
// O celular reage à resposta da conversa com acordar + gesto; a prévia imita.
|
||||
ouvir('conversa', (c) => {
|
||||
if (c.ultima && c.ultima.id !== ultimaConversa) {
|
||||
ultimaConversa = c.ultima.id;
|
||||
enviar({ tipo: 'ordem', ordem: 'acordar' });
|
||||
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
|
||||
}
|
||||
}),
|
||||
];
|
||||
onDestroy(() => cancelar.forEach((c) => c()));
|
||||
|
||||
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
|
||||
export function previa(receita: unknown) {
|
||||
enviar({ tipo: 'previa-reacao', receita });
|
||||
}
|
||||
</script>
|
||||
|
||||
<iframe
|
||||
bind:this={quadro}
|
||||
src="/carinha.html?espelho=1&painel=1&auto=0&camera=0&audio=0"
|
||||
title={titulo}
|
||||
tabindex="-1"
|
||||
onload={aoCarregar}
|
||||
></iframe>
|
||||
|
||||
<style>
|
||||
iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #000;
|
||||
border-radius: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
// Estado nunca só por cor: o selo sempre tem texto (e o ponto é decorativo).
|
||||
let { tom = 'neutro', children }: { tom?: 'ok' | 'aviso' | 'perigo' | 'neutro' | 'destaque'; children: Snippet } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<span class="selo {tom}"><span class="ponto" aria-hidden="true"></span>{@render children()}</span>
|
||||
|
||||
<style>
|
||||
.selo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--raio-pill);
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 500;
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto-2);
|
||||
border: 1px solid var(--borda);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ponto {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-suave);
|
||||
border-color: transparent;
|
||||
}
|
||||
.aviso {
|
||||
color: var(--aviso);
|
||||
background: var(--aviso-suave);
|
||||
border-color: transparent;
|
||||
}
|
||||
.perigo {
|
||||
color: var(--perigo);
|
||||
background: var(--perigo-suave);
|
||||
border-color: transparent;
|
||||
}
|
||||
.destaque {
|
||||
color: var(--destaque);
|
||||
background: var(--destaque-suave);
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
let { rotulo, valor, min, max, passo = 1, unidade = '', disabled = false, oninput, onchange }: {
|
||||
rotulo: string;
|
||||
valor: number;
|
||||
min: number;
|
||||
max: number;
|
||||
passo?: number;
|
||||
unidade?: string;
|
||||
disabled?: boolean;
|
||||
oninput?: (v: number) => void;
|
||||
onchange?: (v: number) => void;
|
||||
} = $props();
|
||||
const id = 's' + Math.random().toString(36).slice(2, 9);
|
||||
let local = $state<number | null>(null);
|
||||
const mostrado = $derived(local ?? valor);
|
||||
const casas = $derived(passo < 1 ? Math.min(2, String(passo).split('.')[1]?.length ?? 2) : 0);
|
||||
</script>
|
||||
|
||||
<div class="slider">
|
||||
<label for={id}>
|
||||
<span>{rotulo}</span>
|
||||
<output for={id} class="mono">{mostrado.toFixed(casas)}{unidade}</output>
|
||||
</label>
|
||||
<input
|
||||
{id}
|
||||
type="range"
|
||||
{min}
|
||||
{max}
|
||||
step={passo}
|
||||
value={mostrado}
|
||||
{disabled}
|
||||
style="--p: {((mostrado - min) / (max - min)) * 100}%"
|
||||
oninput={(e) => {
|
||||
local = Number(e.currentTarget.value);
|
||||
oninput?.(local);
|
||||
}}
|
||||
onchange={(e) => {
|
||||
const v = Number(e.currentTarget.value);
|
||||
local = null;
|
||||
onchange?.(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slider {
|
||||
display: grid;
|
||||
gap: var(--e-1);
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--t-sm);
|
||||
gap: var(--e-2);
|
||||
}
|
||||
output {
|
||||
color: var(--texto-2);
|
||||
}
|
||||
input {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
input::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
|
||||
}
|
||||
input::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
|
||||
}
|
||||
input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: -7px;
|
||||
border-radius: 50%;
|
||||
background: var(--texto);
|
||||
border: 3px solid var(--destaque);
|
||||
}
|
||||
input::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--texto);
|
||||
border: 3px solid var(--destaque);
|
||||
}
|
||||
input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
let { rotulo, ajuda = '', checked = false, disabled = false, onchange }: {
|
||||
rotulo: string;
|
||||
ajuda?: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
onchange: (v: boolean) => void;
|
||||
} = $props();
|
||||
const id = 't' + Math.random().toString(36).slice(2, 9);
|
||||
</script>
|
||||
|
||||
<label class="toggle" for={id}>
|
||||
<span class="texto">
|
||||
<span>{rotulo}</span>
|
||||
{#if ajuda}<small class="fraco">{ajuda}</small>{/if}
|
||||
</span>
|
||||
<input {id} type="checkbox" role="switch" {checked} {disabled} onchange={(e) => onchange(e.currentTarget.checked)} />
|
||||
<span class="trilho" aria-hidden="true"></span>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--e-3);
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.texto {
|
||||
display: grid;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
small {
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.trilho {
|
||||
flex: none;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie-3);
|
||||
border: 1px solid var(--borda-forte);
|
||||
position: relative;
|
||||
transition: background var(--transicao);
|
||||
}
|
||||
.trilho::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--texto-2);
|
||||
transition: transform var(--transicao), background var(--transicao);
|
||||
}
|
||||
input:checked + .trilho {
|
||||
background: var(--destaque);
|
||||
border-color: var(--destaque);
|
||||
}
|
||||
input:checked + .trilho::after {
|
||||
transform: translateX(18px);
|
||||
background: var(--destaque-texto);
|
||||
}
|
||||
input:focus-visible + .trilho {
|
||||
outline: 2px solid var(--destaque);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input:disabled + .trilho {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { KeyRound } from '@lucide/svelte';
|
||||
import { gravarToken } from '../api';
|
||||
import { conectar } from '../estado.svelte';
|
||||
|
||||
let token = $state('');
|
||||
async function entrar(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
gravarToken(token.trim());
|
||||
await conectar();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fundo">
|
||||
<form class="caixa" onsubmit={entrar}>
|
||||
<KeyRound size={28} />
|
||||
<h1>Token do Carinha</h1>
|
||||
<p class="fraco pequeno-texto">
|
||||
Este servidor exige um token para controlar o robô. Ele fica em <span class="mono">CARINHA_TOKEN</span> ou
|
||||
<span class="mono">seguranca.json</span> na placa e é guardado só neste navegador.
|
||||
</p>
|
||||
<label class="campo">
|
||||
<span>Token</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input type="password" bind:value={token} autocomplete="current-password" required minlength="8" autofocus />
|
||||
</label>
|
||||
<button class="btn primario" type="submit">Entrar</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fundo {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--e-4);
|
||||
background: var(--fundo);
|
||||
}
|
||||
.caixa {
|
||||
width: min(400px, 100%);
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
padding: var(--e-6);
|
||||
border-radius: var(--raio-lg);
|
||||
background: var(--superficie);
|
||||
border: 1px solid var(--borda);
|
||||
color: var(--destaque);
|
||||
}
|
||||
h1,
|
||||
p,
|
||||
label {
|
||||
color: var(--texto);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user