feat(historico): deteccoes com foto, sensor PIR na placa e tela apagando no sono
- historico.py: eventos de PIR (gpiomon no pino 11, gpiochip1 linha 70), movimento e som do celular em SQLite; ate 200 eventos e 100 fotos; cada sensor e a foto ligam/desligam; intervalo minimo por tipo - PIR acorda o Carinha quando ele esta dormindo - APK 0.8.3: o app consulta o pedido de foto e envia uma foto da camera frontal (quadro da camera de movimento ou do video ao vivo) - tela apaga depois de X minutos dormindo (preto total + brilho minimo no AMOLED); acende com som, movimento, toque, comando ou PIR - painel: tela Historico (sensores, fotos, filtros, apagar), atalho em Ao vivo e no Ctrl+K, ajustes "Apagar a tela quando dormir" - testes do historico (servico e rotas HTTP) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
import Movimento from './telas/Movimento.svelte';
|
||||
import Reacoes from './telas/Reacoes.svelte';
|
||||
import Rosto from './telas/Rosto.svelte';
|
||||
import Historico from './telas/Historico.svelte';
|
||||
|
||||
const TELAS: Record<Rota, Component> = {
|
||||
'ao-vivo': AoVivo,
|
||||
@@ -25,6 +26,7 @@
|
||||
movimento: Movimento,
|
||||
ajustes: Ajustes,
|
||||
rosto: Rosto,
|
||||
historico: Historico,
|
||||
};
|
||||
const ICONES: Record<string, Component<any>> = {
|
||||
'ao-vivo': Video,
|
||||
|
||||
@@ -55,6 +55,8 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
|
||||
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
|
||||
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
||||
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
|
||||
{ chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som, movimento, toque ou PIR', tipo: 'toggle' },
|
||||
{ chave: 'apagarTelaMin', rotulo: 'Dormindo há quanto tempo para apagar', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' min' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -139,6 +139,10 @@ const tratadores: Record<string, (d: any) => void> = {
|
||||
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
||||
},
|
||||
treinamento: (d) => (estado.treinamento = d),
|
||||
historico: (d) => {
|
||||
const n = d?.novo;
|
||||
if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção');
|
||||
},
|
||||
};
|
||||
|
||||
let fonte: EventSource | null = null;
|
||||
@@ -161,7 +165,7 @@ function abrirEventos() {
|
||||
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||
};
|
||||
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||
'reacao', 'treinamento']) {
|
||||
'reacao', 'treinamento', 'historico']) {
|
||||
fonte.addEventListener(tipo, (e) => {
|
||||
const dados = JSON.parse((e as MessageEvent).data);
|
||||
tratadores[tipo]?.(dados);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Histórico de detecções (/historico/*): recarrega ao abrir e quando o servidor avisa por SSE. */
|
||||
import { lerToken, pedir, postar } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import { ouvir } from './estado.svelte';
|
||||
|
||||
export interface EventoHistorico {
|
||||
id: number;
|
||||
horario: number;
|
||||
tipo: 'pir' | 'movimento' | 'som';
|
||||
evento: string;
|
||||
nivel: number | null;
|
||||
foto: boolean;
|
||||
}
|
||||
|
||||
export interface StatusHistorico {
|
||||
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number };
|
||||
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||
eventos: EventoHistorico[];
|
||||
total: number;
|
||||
max_eventos: number;
|
||||
max_fotos: number;
|
||||
}
|
||||
|
||||
export const TIPOS_EVENTO: Record<string, { nome: string; emoji: string; cor: string }> = {
|
||||
pir: { nome: 'Presença (PIR)', emoji: '🚶', cor: '#34c759' },
|
||||
movimento: { nome: 'Movimento na câmera', emoji: '👀', cor: '#0a84ff' },
|
||||
som: { nome: 'Som alto', emoji: '🔊', cor: '#ff9500' },
|
||||
};
|
||||
|
||||
/** URL da foto com o token na query (a tag <img> não manda cabeçalho). */
|
||||
export function urlFoto(id: number) {
|
||||
const t = lerToken();
|
||||
return `/historico/foto/evento-${id}.jpg` + (t ? '?t=' + encodeURIComponent(t) : '');
|
||||
}
|
||||
|
||||
class Historico {
|
||||
status = $state<StatusHistorico | null>(null);
|
||||
private espera = 0;
|
||||
|
||||
async carregar() {
|
||||
try {
|
||||
this.status = await pedir<StatusHistorico>('/historico/status');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async configurar(parcial: Partial<StatusHistorico['config']>) {
|
||||
const antes = this.status ? { ...this.status.config } : null;
|
||||
if (this.status) this.status = { ...this.status, config: { ...this.status.config, ...parcial } };
|
||||
try {
|
||||
this.status = await postar<StatusHistorico>('/historico/config', parcial);
|
||||
} catch (e) {
|
||||
if (this.status && antes) this.status = { ...this.status, config: antes };
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async apagar(id: number) {
|
||||
try {
|
||||
await postar('/historico/apagar', { id });
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async limpar() {
|
||||
try {
|
||||
await postar('/historico/limpar');
|
||||
avisos.ok('Histórico apagado.');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Vários avisos seguidos (evento + foto) viram uma recarga só. */
|
||||
agendar() {
|
||||
clearTimeout(this.espera);
|
||||
this.espera = window.setTimeout(() => this.carregar(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
export const historico = new Historico();
|
||||
ouvir('historico', () => historico.status && historico.agendar());
|
||||
@@ -36,6 +36,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-historico', grupo: 'Ir para', titulo: 'Histórico de detecções', fazer: () => nav.ir('historico') },
|
||||
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
|
||||
estado.estop.ativo
|
||||
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
||||
|
||||
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
|
||||
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'historico';
|
||||
|
||||
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
||||
@@ -11,7 +11,10 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||
];
|
||||
|
||||
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
|
||||
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }];
|
||||
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
|
||||
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
|
||||
{ id: 'historico', titulo: 'Histórico', volta: 'ao-vivo' },
|
||||
];
|
||||
|
||||
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Brain, Camera, Download, Eye, Hand, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
|
||||
Brain, Camera, Download, Eye, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
|
||||
} from '@lucide/svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { postar } from '../lib/api';
|
||||
@@ -181,6 +181,7 @@
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Acontecimentos">
|
||||
<Linha icone={History} cor="#34c759" rotulo="Histórico de detecções" detalhe="PIR, movimento e som, com foto" href="#/historico" />
|
||||
{#if estado.feed.length}
|
||||
<ol class="feed">
|
||||
{#each estado.feed.slice(0, 12) as item (item.id)}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Camera, Ear, Footprints, ImageOff, Timer, Trash2, Video } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||
import Linha from '../lib/componentes/Linha.svelte';
|
||||
import Modal from '../lib/componentes/Modal.svelte';
|
||||
import Slider from '../lib/componentes/Slider.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
|
||||
|
||||
onMount(() => {
|
||||
historico.carregar();
|
||||
});
|
||||
|
||||
const st = $derived(historico.status);
|
||||
type Filtro = 'todos' | 'pir' | 'movimento' | 'som' | 'foto';
|
||||
let filtro = $state<Filtro>('todos');
|
||||
const eventos = $derived(
|
||||
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'foto' ? e.foto : e.tipo === filtro)),
|
||||
);
|
||||
|
||||
const quando = (t: number) => {
|
||||
const seg = Math.max(0, Math.round((estado.agora - t * 1000) / 1000));
|
||||
if (seg < 60) return `há ${seg} s`;
|
||||
if (seg < 3600) return `há ${Math.round(seg / 60)} min`;
|
||||
if (seg < 86400) return `há ${Math.round(seg / 3600)} h`;
|
||||
return new Date(t * 1000).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
|
||||
};
|
||||
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
|
||||
const NOME_EVENTO: Record<string, string> = { surpreso: 'susto', acordou: 'acordou o Carinha', presença: 'presença' };
|
||||
|
||||
const pirTexto = $derived.by(() => {
|
||||
if (!st) return '';
|
||||
if (!st.config.pir) return 'Desligado';
|
||||
if (st.pir.disponivel) return `Pino 11 · ${st.pir.ultimo ? 'último disparo ' + quando(st.pir.ultimo) : 'aguardando presença'}`;
|
||||
return st.pir.erro || 'Indisponível neste servidor';
|
||||
});
|
||||
|
||||
let aberta = $state<EventoHistorico | null>(null);
|
||||
let modal = $state(false);
|
||||
let confirmar = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="historico">
|
||||
<a class="voltar" href="#/ao-vivo"><ArrowLeft size={16} /> Ao vivo</a>
|
||||
<header>
|
||||
<h1>Histórico de detecções</h1>
|
||||
<p class="fraco">
|
||||
Presença do sensor PIR, movimento e som percebidos pelo celular, com uma foto da câmera frontal em cada evento.
|
||||
{#if st}Guarda os {st.max_eventos} eventos e as {st.max_fotos} fotos mais recentes.{/if}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="colunas">
|
||||
<div class="lado">
|
||||
<Grupo titulo="Sensores">
|
||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor PIR da placa" detalhe={pirTexto}>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Sensor PIR da placa" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera" detalhe="Câmera do S10 como sensor">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto e susto" detalhe="Microfone do S10 (só o nível, nunca o áudio)">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Som alto e susto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
</Grupo>
|
||||
<Grupo titulo="Fotos" rodape="A foto sai da câmera de movimento do S10 ou, com o vídeo ao vivo no ar, do próprio vídeo.">
|
||||
<Linha icone={Camera} cor="#af52de" rotulo="Guardar uma foto por evento">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Guardar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo mínimo entre eventos">
|
||||
<Slider
|
||||
rotulo="Do mesmo tipo"
|
||||
valor={st?.config.intervalo ?? 15}
|
||||
min={0}
|
||||
max={120}
|
||||
passo={5}
|
||||
unidade=" s"
|
||||
onchange={(v) => historico.configurar({ intervalo: v })}
|
||||
/>
|
||||
</Linha>
|
||||
</Grupo>
|
||||
<div class="limpar">
|
||||
{#if confirmar}
|
||||
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
|
||||
<div class="linha">
|
||||
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
|
||||
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar histórico</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="lista" aria-label="Eventos">
|
||||
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
|
||||
{#each [['todos', 'Todos'], ['pir', '🚶 PIR'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['foto', '📷 Com foto']] as [id, nome] (id)}
|
||||
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id as Filtro)}>{nome}</button>
|
||||
{/each}
|
||||
{#if st}<span class="fraco pequeno-texto">{st.total} evento(s)</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if !st}
|
||||
<p class="fraco">Carregando histórico…</p>
|
||||
{:else if !eventos.length}
|
||||
<div class="vazio">
|
||||
<p>Nenhum evento {filtro === 'todos' ? 'ainda' : 'com esse filtro'}.</p>
|
||||
<p class="fraco pequeno-texto">Quando o PIR ou o celular perceber algo, o evento aparece aqui na hora, com a foto.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="grade">
|
||||
{#each eventos as ev (ev.id)}
|
||||
{@const info = TIPOS_EVENTO[ev.tipo]}
|
||||
<li class="cartao">
|
||||
<button class="foto" onclick={() => ev.foto && ((aberta = ev), (modal = true))} disabled={!ev.foto} aria-label={ev.foto ? 'Ver foto' : 'Sem foto'}>
|
||||
{#if ev.foto}
|
||||
<img src={urlFoto(ev.id)} alt="Foto do evento de {hora(ev.horario)}" loading="lazy" />
|
||||
{:else}
|
||||
<ImageOff size={22} />
|
||||
{/if}
|
||||
<span class="selo" style="--c: {info.cor}">{info.emoji} {info.nome}</span>
|
||||
</button>
|
||||
<div class="dados">
|
||||
<strong>{NOME_EVENTO[ev.evento] ?? ev.evento}</strong>
|
||||
<span class="fraco pequeno-texto" title={hora(ev.horario)}>{quando(ev.horario)}{ev.nivel !== null ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}</span>
|
||||
</div>
|
||||
<button class="btn pequeno icone fantasma apagar" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:aberto={modal} titulo={aberta ? `${TIPOS_EVENTO[aberta.tipo].nome} · ${hora(aberta.horario)}` : 'Foto'}>
|
||||
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.historico {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
}
|
||||
.voltar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--e-1);
|
||||
width: fit-content;
|
||||
color: var(--texto-2);
|
||||
text-decoration: none;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
header p {
|
||||
max-width: 70ch;
|
||||
}
|
||||
.colunas {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
align-items: start;
|
||||
}
|
||||
.lado {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
}
|
||||
.limpar {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
padding: 0 var(--e-2);
|
||||
}
|
||||
.lista {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
min-width: 0;
|
||||
}
|
||||
.filtros {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
align-items: center;
|
||||
}
|
||||
.filtros button {
|
||||
min-height: 34px;
|
||||
padding: 0 var(--e-3);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie);
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.filtros button[aria-checked='true'] {
|
||||
background: var(--destaque);
|
||||
border-color: var(--destaque);
|
||||
color: var(--destaque-texto);
|
||||
}
|
||||
.vazio {
|
||||
display: grid;
|
||||
gap: var(--e-1);
|
||||
padding: var(--e-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--borda-forte);
|
||||
border-radius: var(--raio-lg);
|
||||
}
|
||||
.grade {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
|
||||
}
|
||||
.cartao {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
padding: var(--e-2);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-lg);
|
||||
background: var(--superficie);
|
||||
}
|
||||
.foto {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
border: 0;
|
||||
border-radius: var(--raio);
|
||||
overflow: hidden;
|
||||
background: var(--fundo-2);
|
||||
color: var(--texto-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: zoom-in;
|
||||
padding: 0;
|
||||
}
|
||||
.foto:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.foto img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.selo {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
bottom: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: rgb(0 0 0 / 0.6);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-left: 3px solid var(--c);
|
||||
}
|
||||
.dados {
|
||||
display: grid;
|
||||
padding: 0 var(--e-1) var(--e-1);
|
||||
}
|
||||
.apagar {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
}
|
||||
.grande {
|
||||
width: 100%;
|
||||
border-radius: var(--raio);
|
||||
}
|
||||
@media (min-width: 1100px) {
|
||||
.colunas {
|
||||
grid-template-columns: 360px 1fr;
|
||||
}
|
||||
.lado {
|
||||
position: sticky;
|
||||
top: calc(var(--topo-altura) + var(--e-5));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user