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:
@@ -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>
|
||||
Reference in New Issue
Block a user