feat(eventos): menu Eventos, modo presenca e 3 dias de historico

- "Eventos" vira item do menu principal: lista por dia (Hoje, Ontem...),
  presenca e movimento em destaque, botao "Ver foto" com miniatura
- modo presenca: o PIR liga a camera do celular e acorda o Carinha; sem
  ninguem por N minutos (5 por padrao) a camera desliga, ele dorme e fica
  em silencio (sons e fala desligados enquanto dorme)
- historico guarda so os ultimos 3 dias (ajustavel de 1 a 30), com faxina
  automatica de eventos e fotos antigos
- testes da retencao e do modo presenca

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 03:51:49 -03:00
co-authored by Claude Opus 5.5
parent 6029c79645
commit 499a4ae479
13 changed files with 449 additions and 315 deletions
+2 -1
View File
@@ -1045,7 +1045,7 @@ const SONS = {
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala", /* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
os sons dos gestos ficam quietos para não embolar. */ os sons dos gestos ficam quietos para não embolar. */
function falar(texto) { function falar(texto) {
if (!AUDIO.ligado || !window.VozRobo) return; if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
const c = contextoAudio(); const c = contextoAudio();
if (!c) return; if (!c) return;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
@@ -1057,6 +1057,7 @@ function falar(texto) {
function tocarSom(gesto) { function tocarSom(gesto) {
if (!AUDIO.ligado) return false; if (!AUDIO.ligado) return false;
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false; if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0; const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
const f = SONS[gesto]; const f = SONS[gesto];
+2 -1
View File
@@ -1045,7 +1045,7 @@ const SONS = {
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala", /* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
os sons dos gestos ficam quietos para não embolar. */ os sons dos gestos ficam quietos para não embolar. */
function falar(texto) { function falar(texto) {
if (!AUDIO.ligado || !window.VozRobo) return; if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
const c = contextoAudio(); const c = contextoAudio();
if (!c) return; if (!c) return;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
@@ -1057,6 +1057,7 @@ function falar(texto) {
function tocarSom(gesto) { function tocarSom(gesto) {
if (!AUDIO.ligado) return false; if (!AUDIO.ligado) return false;
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false; if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0; const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
const f = SONS[gesto]; const f = SONS[gesto];
+79 -7
View File
@@ -4,7 +4,8 @@
Orange Pi Zero 3; outro pino com CARINHA_PIR=gpiochip1:79. Orange Pi Zero 3; outro pino com CARINHA_PIR=gpiochip1:79.
- Foto: ao registrar um evento, o servidor deixa um "pedido de foto"; o app do S10 consulta - Foto: ao registrar um evento, o servidor deixa um "pedido de foto"; o app do S10 consulta
/historico/pedido, tira a foto com a câmera frontal e envia em /historico/foto?id=N. /historico/pedido, tira a foto com a câmera frontal e envia em /historico/foto?id=N.
- Guarda os 200 eventos e as 100 fotos mais recentes. Cada sensor e a foto podem ser desligados. - Guarda só os últimos 3 dias (eventos e fotos mais antigos são apagados sozinhos), com teto de
segurança de 2000 eventos e 500 fotos. Cada sensor e a foto podem ser desligados.
""" """
import json import json
import os import os
@@ -19,8 +20,9 @@ from urllib.parse import parse_qs, urlsplit
from comum import gravar_atomico, ler_json from comum import gravar_atomico, ler_json
TIPOS = ('pir', 'movimento', 'som') TIPOS = ('pir', 'movimento', 'som')
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15) PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3,
MAX_EVENTOS, MAX_FOTOS = 200, 100 presenca=True, ausencia=5) # modo presença: o PIR liga a câmera; sem ninguém, ela desliga
MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
@@ -34,6 +36,10 @@ class Historico:
self.lock = threading.RLock() self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None self.publicar = lambda tipo, dados: None
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
self.ao_presenca = lambda: None
self.ao_ausencia = lambda: None
self.presenca = dict(estado='presente', desde=time.time(), ultimo=time.time())
self.config = dict(PADRAO) self.config = dict(PADRAO)
try: try:
salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8')) salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8'))
@@ -46,8 +52,11 @@ class Historico:
self.processo_pir = None self.processo_pir = None
with self.db() as db: with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)') db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)')
self.limpar_antigos()
if iniciar_pir: if iniciar_pir:
threading.Thread(target=self.vigiar_pir, daemon=True).start() threading.Thread(target=self.vigiar_pir, daemon=True).start()
threading.Thread(target=self.faxina, daemon=True).start()
threading.Thread(target=self.vigiar_ausencia, daemon=True).start()
@contextmanager @contextmanager
def db(self): def db(self):
@@ -66,7 +75,13 @@ class Historico:
for chave, valor in dados.items(): for chave, valor in dados.items():
if chave not in PADRAO: if chave not in PADRAO:
raise ValueError('Ajuste desconhecido: ' + chave) raise ValueError('Ajuste desconhecido: ' + chave)
if chave == 'intervalo': if chave == 'ausencia':
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 120:
raise ValueError('Ausência de 1 a 120 minutos')
elif chave == 'dias':
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
raise ValueError('Guardar de 1 a 30 dias')
elif chave == 'intervalo':
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600: if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
raise ValueError('Intervalo entre 0 e 600 s') raise ValueError('Intervalo entre 0 e 600 s')
elif not isinstance(valor, bool): elif not isinstance(valor, bool):
@@ -74,6 +89,59 @@ class Historico:
limpo[chave] = valor limpo[chave] = valor
return limpo return limpo
# ------------------------------------------------------------------ retenção
def limpar_antigos(self, agora=None):
"""Apaga eventos (e fotos) mais velhos que a retenção em dias."""
limite = (agora if agora is not None else time.time()) - self.config['dias'] * 86400
with self.lock, self.db() as db:
velhos = db.execute('SELECT id, foto FROM eventos WHERE horario < ?', (limite,)).fetchall()
for vid, foto in velhos:
self.apagar_arquivo(foto)
db.execute('DELETE FROM eventos WHERE horario < ?', (limite,))
if velhos:
self.publicar('historico', dict(expirados=len(velhos)))
return len(velhos)
# ------------------------------------------------------------------ modo presença
def marcar_presenca(self, agora=None):
agora = agora if agora is not None else time.time()
with self.lock:
self.presenca['ultimo'] = agora
voltou = self.presenca['estado'] != 'presente'
if voltou:
self.presenca.update(estado='presente', desde=agora)
if voltou and self.config['presenca']:
self.ao_presenca()
def checar_ausencia(self, agora=None):
"""Sem presença no PIR por `ausencia` minutos: desliga câmera, dorme e fica quieto."""
agora = agora if agora is not None else time.time()
if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']):
return False
with self.lock:
if self.presenca['estado'] != 'presente' or agora - self.presenca['ultimo'] < self.config['ausencia'] * 60:
return False
self.presenca.update(estado='ausente', desde=agora)
self.ao_ausencia()
self.publicar('pir', dict(ultimo=self.pir['ultimo'], presenca=dict(self.presenca)))
return True
def vigiar_ausencia(self):
while True:
time.sleep(10)
try:
self.checar_ausencia()
except Exception:
pass
def faxina(self):
while True:
time.sleep(600)
try:
self.limpar_antigos()
except Exception:
pass
# ------------------------------------------------------------------ eventos # ------------------------------------------------------------------ eventos
def registrar(self, tipo, evento='', nivel=None, agora=None): def registrar(self, tipo, evento='', nivel=None, agora=None):
"""Grava o evento se o sensor estiver ligado e fora do intervalo mínimo; devolve o id ou None.""" """Grava o evento se o sensor estiver ligado e fora do intervalo mínimo; devolve o id ou None."""
@@ -97,6 +165,7 @@ class Historico:
db.execute('DELETE FROM eventos WHERE id=?', (vid,)) db.execute('DELETE FROM eventos WHERE id=?', (vid,))
if self.config['foto']: if self.config['foto']:
self.pedido = (ident, time.monotonic()) self.pedido = (ident, time.monotonic())
self.limpar_antigos(agora)
self.publicar('historico', dict(novo=self.evento(ident))) self.publicar('historico', dict(novo=self.evento(ident)))
try: try:
self.ao_detectar(tipo) self.ao_detectar(tipo)
@@ -114,7 +183,7 @@ class Historico:
i, h, t, e, n, f = linha i, h, t, e, n, f = linha
return dict(id=i, horario=h, tipo=t, evento=e, nivel=n, foto=bool(f)) return dict(id=i, horario=h, tipo=t, evento=e, nivel=n, foto=bool(f))
def listar(self, limite=100): def listar(self, limite=500):
with self.db() as db: with self.db() as db:
linhas = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos ORDER BY id DESC LIMIT ?', (limite,)).fetchall() linhas = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos ORDER BY id DESC LIMIT ?', (limite,)).fetchall()
total = db.execute('SELECT count(*) FROM eventos').fetchone()[0] total = db.execute('SELECT count(*) FROM eventos').fetchone()[0]
@@ -192,7 +261,8 @@ class Historico:
if time.monotonic() - inicio < 2: # o pino "assenta" ao ligar o pull-down if time.monotonic() - inicio < 2: # o pino "assenta" ao ligar o pull-down
continue continue
self.pir['ultimo'] = time.time() self.pir['ultimo'] = time.time()
self.publicar('pir', dict(ultimo=self.pir['ultimo'])) # todo disparo, ao vivo self.marcar_presenca(self.pir['ultimo'])
self.publicar('pir', dict(ultimo=self.pir['ultimo'], presenca=dict(self.presenca))) # todo disparo, ao vivo
self.registrar('pir', 'presença') self.registrar('pir', 'presença')
erro = (self.processo_pir.stderr.read() or '').strip()[:200] erro = (self.processo_pir.stderr.read() or '').strip()[:200]
except OSError as e: except OSError as e:
@@ -214,7 +284,7 @@ class Historico:
def status(self): def status(self):
eventos, total = self.listar() eventos, total = self.listar()
with self.lock: with self.lock:
return dict(config=dict(self.config), pir=dict(self.pir), eventos=eventos, total=total, return dict(config=dict(self.config), pir=dict(self.pir), presenca=dict(self.presenca), eventos=eventos, total=total,
max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS) max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS)
# ------------------------------------------------------------------ HTTP # ------------------------------------------------------------------ HTTP
@@ -259,6 +329,8 @@ class Historico:
gravar_atomico(self.arquivo_config, self.config) gravar_atomico(self.arquivo_config, self.config)
if 'pir' in limpo and not limpo['pir']: if 'pir' in limpo and not limpo['pir']:
self.parar_pir() self.parar_pir()
if 'dias' in limpo:
self.limpar_antigos()
self.publicar('historico', dict(config=dict(self.config))) self.publicar('historico', dict(config=dict(self.config)))
return h.json(self.status()) or True return h.json(self.status()) or True
if rota == '/historico/apagar': if rota == '/historico/apagar':
+16 -1
View File
@@ -261,7 +261,7 @@ class Handler(SimpleHTTPRequestHandler):
if hasattr(s, "api_config"): if hasattr(s, "api_config"):
dados["api"] = s.api_config.status() dados["api"] = s.api_config.status()
if hasattr(s, "historico"): if hasattr(s, "historico"):
dados["pir"] = dict(s.historico.pir) dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
return dados return dados
def do_GET(self): def do_GET(self):
@@ -489,7 +489,22 @@ def main():
dormindo = bool(servidor.controle.estado.get("dormindo")) dormindo = bool(servidor.controle.estado.get("dormindo"))
if dormindo: if dormindo:
enfileirar("acordar") enfileirar("acordar")
def camera(ligada):
if servidor.controle.config.get("sensorCamera") != ligada:
servidor.controle.publicar("app_config", servidor.controle.atualizar({"sensorCamera": ligada}))
def alguem_chegou():
camera(True) # modo presença: o PIR liga a câmera do celular
enfileirar("acordar")
def ninguem_por_perto():
camera(False) # sem ninguém: câmera desligada, dorme e fica em silêncio
enfileirar("dormir")
servidor.historico.ao_detectar = presenca servidor.historico.ao_detectar = presenca
servidor.historico.ao_presenca = alguem_chegou
servidor.historico.ao_ausencia = ninguem_por_perto
servidor.conversa.treinamento = servidor.treinamento servidor.conversa.treinamento = servidor.treinamento
ligar_eventos(servidor) ligar_eventos(servidor)
servidor.registrar = args.log servidor.registrar = args.log
+27
View File
@@ -66,6 +66,33 @@ class HistoricoTest(unittest.TestCase):
self.assertFalse(self.h.evento(ids[0])['foto']) self.assertFalse(self.h.evento(ids[0])['foto'])
self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS) self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS)
def test_guarda_so_os_ultimos_dias(self):
agora = 10 * 86400
velho = self.h.registrar('pir', agora=agora - 4 * 86400)
self.h.guardar_foto(velho, JPEG)
novo = self.h.registrar('pir', agora=agora - 86400)
self.assertEqual(self.h.limpar_antigos(agora), 1) # 3 dias por padrão
self.assertIsNone(self.h.evento(velho))
self.assertIsNotNone(self.h.evento(novo))
self.assertFalse((Path(self.tmp.name) / 'fotos' / f'evento-{velho}.jpg').exists())
with self.assertRaises(ValueError):
self.h.validar({'dias': 0})
def test_modo_presenca(self):
chamadas = []
self.h.ao_presenca = lambda: chamadas.append('chegou')
self.h.ao_ausencia = lambda: chamadas.append('saiu')
self.h.pir['disponivel'] = True
self.h.marcar_presenca(1000)
self.assertFalse(self.h.checar_ausencia(1000 + 4 * 60)) # 4 min: ainda presente
self.assertTrue(self.h.checar_ausencia(1000 + 5 * 60 + 1)) # 5 min: ausente
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # não repete
self.h.marcar_presenca(2000) # voltou alguém
self.assertEqual(chamadas, ['saiu', 'chegou'])
self.h.config['presenca'] = False
self.h.marcar_presenca(3000)
self.assertFalse(self.h.checar_ausencia(9999))
def test_apagar_e_limpar(self): def test_apagar_e_limpar(self):
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG) a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
b = self.h.registrar('som', agora=1) b = self.h.registrar('som', agora=1)
+5 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte'; import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte'; import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte'; import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte'; import EStop from './lib/componentes/EStop.svelte';
@@ -17,19 +17,20 @@
import Movimento from './telas/Movimento.svelte'; import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte'; import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte'; import Rosto from './telas/Rosto.svelte';
import Historico from './telas/Historico.svelte'; import Eventos from './telas/Eventos.svelte';
const TELAS: Record<Rota, Component> = { const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo, 'ao-vivo': AoVivo,
eventos: Eventos,
conversa: Conversa, conversa: Conversa,
reacoes: Reacoes, reacoes: Reacoes,
movimento: Movimento, movimento: Movimento,
ajustes: Ajustes, ajustes: Ajustes,
rosto: Rosto, rosto: Rosto,
historico: Historico,
}; };
const ICONES: Record<string, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
eventos: Bell,
conversa: MessageCircle, conversa: MessageCircle,
reacoes: Smile, reacoes: Smile,
movimento: Hand, movimento: Hand,
@@ -208,7 +209,7 @@
margin: 0; margin: 0;
padding: var(--e-1); padding: var(--e-1);
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
} }
li a { li a {
display: grid; display: grid;
+3 -3
View File
@@ -35,7 +35,7 @@ class Estado {
api = $state<EstadoServidor['api'] | null>(null); api = $state<EstadoServidor['api'] | null>(null);
feed = $state<ItemFeed[]>([]); feed = $state<ItemFeed[]>([]);
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */ /** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
pir = $state<{ disponivel: boolean; ultimo: number | null } | null>(null); pir = $state<{ disponivel: boolean; ultimo: number | null; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */ /** Relógio de 1 s para derivar "online" sem esperar evento novo. */
agora = $state(Date.now()); agora = $state(Date.now());
@@ -108,7 +108,7 @@ function aplicarFoto(f: EstadoServidor) {
estado.reacao = f.reacao ?? null; estado.reacao = f.reacao ?? null;
if (f.treinamento) estado.treinamento = f.treinamento; if (f.treinamento) estado.treinamento = f.treinamento;
if (f.api) estado.api = f.api; if (f.api) estado.api = f.api;
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo }; if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, presenca: (f.pir as any).presenca };
} }
const tratadores: Record<string, (d: any) => void> = { const tratadores: Record<string, (d: any) => void> = {
@@ -142,7 +142,7 @@ const tratadores: Record<string, (d: any) => void> = {
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome); if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
}, },
treinamento: (d) => (estado.treinamento = d), treinamento: (d) => (estado.treinamento = d),
pir: (d: { ultimo: number }) => (estado.pir = { disponivel: true, ultimo: d.ultimo }), pir: (d: { ultimo: number; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, presenca: d.presenca }),
historico: (d) => { historico: (d) => {
const n = d?.novo; 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'); if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção');
+2 -1
View File
@@ -13,7 +13,8 @@ export interface EventoHistorico {
} }
export interface StatusHistorico { export interface StatusHistorico {
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number }; config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number };
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null }; pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
eventos: EventoHistorico[]; eventos: EventoHistorico[];
total: number; total: number;
-1
View File
@@ -36,7 +36,6 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [ registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })), ...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-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.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
estado.estop.ativo estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar } ? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
+4 -3
View File
@@ -1,9 +1,10 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ /** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'historico'; export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' }, { id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' }, { id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' }, { id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
@@ -13,13 +14,13 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */ /** Páginas abertas a partir de outra tela (não aparecem na navegação). */
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [ export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }, { 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 ?? ''; export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
function lerHash(): Rota { function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota; let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo'; return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
} }
+1 -1
View File
@@ -185,7 +185,7 @@
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)"> <Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
{#snippet acessorio()}<PresencaPir />{/snippet} {#snippet acessorio()}<PresencaPir />{/snippet}
</Linha> </Linha>
<Linha icone={History} cor="#30b0c7" rotulo="Histórico de detecções" detalhe="PIR, movimento e som, com foto" href="#/historico" /> <Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
{#if estado.feed.length} {#if estado.feed.length}
<ol class="feed"> <ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)} {#each estado.feed.slice(0, 12) as item (item.id)}
+308
View File
@@ -0,0 +1,308 @@
<script lang="ts">
import { Camera, Ear, Footprints, Image as ImageIcon, Moon, 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 PresencaPir from '../lib/componentes/PresencaPir.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);
// Presença e movimento em destaque; som é opcional no filtro.
type Filtro = 'principais' | 'pir' | 'movimento' | 'som' | 'todos';
let filtro = $state<Filtro>('principais');
const FILTROS: [Filtro, string][] = [
['principais', 'Presença e movimento'], ['pir', '🚶 Presença'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['todos', 'Todos'],
];
const eventos = $derived(
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'principais' ? e.tipo !== 'som' : e.tipo === filtro)),
);
// Agrupa por dia: Hoje, Ontem, depois a data.
const dias = $derived.by(() => {
const grupos: { titulo: string; itens: EventoHistorico[] }[] = [];
const hoje = new Date(estado.agora); hoje.setHours(0, 0, 0, 0);
for (const ev of eventos) {
const d = new Date(ev.horario * 1000); d.setHours(0, 0, 0, 0);
const diff = Math.round((hoje.getTime() - d.getTime()) / 86400000);
const titulo = diff === 0 ? 'Hoje' : diff === 1 ? 'Ontem' : d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
if (!grupos.length || grupos[grupos.length - 1].titulo !== titulo) grupos.push({ titulo, itens: [] });
grupos[grupos.length - 1].itens.push(ev);
}
return grupos;
});
const TITULO: Record<string, string> = { pir: 'Presença detectada', movimento: 'Movimento na câmera', som: 'Som alto' };
const DETALHE: Record<string, string> = { surpreso: 'deu um susto', acordou: 'acordou o Carinha', presença: 'sensor PIR' };
const horaCurta = (t: number) => new Date(t * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
const desde = (t: number) => {
const min = Math.max(0, Math.round((estado.agora - t * 1000) / 60000));
return min < 1 ? 'agora' : min < 60 ? `há ${min} min` : `há ${Math.round(min / 60)} h`;
};
const presenca = $derived(st?.presenca);
let aberta = $state<EventoHistorico | null>(null);
let modal = $state(false);
let confirmar = $state(false);
function verFoto(ev: EventoHistorico) {
aberta = ev;
modal = true;
}
</script>
<div class="eventos">
<header>
<div class="linha entre">
<h1>Eventos</h1>
{#if st?.config.pir}<PresencaPir />{/if}
</div>
<p class="fraco">
O que o sensor de presença e a câmera do celular perceberam, com uma foto de cada momento. Guardamos {st?.config.dias ?? 3} dias.
</p>
</header>
<div class="colunas">
<section class="lista" aria-label="Lista de eventos">
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
{#each FILTROS as [id, nome] (id)}
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id)}>{nome}</button>
{/each}
</div>
{#if !st}
<p class="fraco">Carregando eventos…</p>
{:else if !eventos.length}
<div class="vazio">
<p>Nenhum evento {filtro === 'todos' ? 'nos últimos dias' : 'com esse filtro'}.</p>
<p class="fraco pequeno-texto">Quando alguém passar na frente do sensor ou a câmera perceber movimento, aparece aqui na hora.</p>
</div>
{:else}
{#each dias as dia (dia.titulo)}
<Grupo titulo={`${dia.titulo} · ${dia.itens.length}`}>
{#each dia.itens as ev (ev.id)}
{@const info = TIPOS_EVENTO[ev.tipo]}
<div class="evento">
<span class="icone" style="--c: {info.cor}" aria-hidden="true">{info.emoji}</span>
<div class="texto">
<strong>{TITULO[ev.tipo] ?? ev.tipo}</strong>
<span class="fraco pequeno-texto">
{horaCurta(ev.horario)} · {DETALHE[ev.evento] ?? ev.evento}{ev.nivel !== null && ev.tipo !== 'pir' ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}
</span>
</div>
{#if ev.foto}
<button class="ver-foto" onclick={() => verFoto(ev)} aria-label="Ver foto de {horaCurta(ev.horario)}">
<img src={urlFoto(ev.id)} alt="" loading="lazy" />
<span>Ver foto</span>
</button>
{:else}
<span class="sem-foto fraco pequeno-texto">sem foto</span>
{/if}
<button class="btn pequeno icone fantasma" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
</div>
{/each}
</Grupo>
{/each}
{/if}
</section>
<aside class="lado">
<Grupo
titulo="Modo presença"
rodape="Alguém aparece: o PIR liga a câmera do celular e acorda o Carinha. Ninguém pelo tempo escolhido: a câmera desliga, ele dorme e fica em silêncio."
>
<Linha
icone={Footprints}
cor="#34c759"
rotulo="Câmera ligada pela presença"
detalhe={presenca ? (presenca.estado === 'presente' ? `alguém por perto · último movimento ${desde(presenca.ultimo)}` : `ninguém por perto ${desde(presenca.desde)}`) : ''}
>
{#snippet acessorio()}
<Interruptor rotulo="Câmera ligada pela presença" checked={!!st?.config.presenca} disabled={!st} onchange={(v) => historico.configurar({ presenca: v })} />
{/snippet}
</Linha>
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
</Linha>
</Grupo>
<Grupo titulo="O que registrar">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)" detalhe={st?.pir.disponivel ? 'Pino 11 · HC-SR505' : st?.pir.erro || 'indisponível'}>
{#snippet acessorio()}
<Interruptor rotulo="Sensor de presença (PIR)" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
{/snippet}
</Linha>
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera">
{#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">
{#snippet acessorio()}
<Interruptor rotulo="Som alto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
{/snippet}
</Linha>
<Linha icone={Camera} cor="#af52de" rotulo="Tirar uma foto por evento" detalhe="Câmera frontal do S10">
{#snippet acessorio()}
<Interruptor rotulo="Tirar 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 entre eventos do mesmo tipo">
<Slider rotulo="Intervalo" valor={st?.config.intervalo ?? 15} min={0} max={120} passo={5} unidade=" s" onchange={(v) => historico.configurar({ intervalo: v })} />
</Linha>
<Linha icone={ImageIcon} cor="#30b0c7" rotulo="Guardar">
<Slider rotulo="Dias de histórico" valor={st?.config.dias ?? 3} min={1} max={30} passo={1} unidade=" dias" onchange={(v) => historico.configurar({ dias: 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 eventos</button>
{/if}
</div>
</aside>
</div>
</div>
<Modal bind:aberto={modal} titulo={aberta ? `${TITULO[aberta.tipo]} · ${hora(aberta.horario)}` : 'Foto'}>
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
</Modal>
<style>
.eventos {
display: grid;
gap: var(--e-5);
}
header {
display: grid;
gap: var(--e-2);
}
header p {
max-width: 70ch;
}
.colunas {
display: grid;
gap: var(--e-5);
align-items: start;
}
.lista,
.lado {
display: grid;
gap: var(--e-5);
min-width: 0;
}
.filtros {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.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);
}
.evento {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2) var(--e-3) var(--e-2) var(--e-4);
min-height: 64px;
}
.evento + .evento {
border-top: 1px solid var(--borda);
}
.icone {
flex: none;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: color-mix(in srgb, var(--c) 18%, transparent);
font-size: 1.1rem;
}
.texto {
flex: 1;
display: grid;
min-width: 0;
}
.ver-foto {
flex: none;
display: flex;
align-items: center;
gap: var(--e-2);
padding: 3px var(--e-3) 3px 3px;
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--destaque);
font: 600 var(--t-xs) var(--fonte);
cursor: zoom-in;
}
.ver-foto img {
width: 56px;
height: 40px;
object-fit: cover;
border-radius: 7px;
}
.sem-foto {
flex: none;
}
.limpar {
display: grid;
gap: var(--e-2);
padding: 0 var(--e-2);
}
.grande {
width: 100%;
border-radius: var(--raio);
}
@media (max-width: 520px) {
.ver-foto span {
display: none;
}
.ver-foto {
padding: 3px;
}
}
@media (min-width: 1100px) {
.colunas {
grid-template-columns: 1fr 380px;
}
.lado {
position: sticky;
top: calc(var(--topo-altura) + var(--e-5));
}
}
</style>
-292
View File
@@ -1,292 +0,0 @@
<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 PresencaPir from '../lib/componentes/PresencaPir.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 · HC-SR505';
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()}
{#if st?.config.pir}<PresencaPir />{/if}
<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>