feat(web): migra Conversa e Reacoes para o painel novo
- Conversa: chat empurrado por SSE, indicador de "pensando", gesto da resposta com repetir, voz com aviso claro quando o contexto nao e seguro e leitura em voz alta so de respostas novas - Memoria unificada: lembrancas da conversa + rotina/preferencias em abas; apagar tudo com confirmacao inline (sem confirm nativo) - Reacoes: biblioteca com miniaturas estaticas (sem iframe por cartao), busca, mostrar no celular, feedback, editar em <dialog>, duplicar, ativar e excluir com Desfazer no toast; criar com IA; diario; grade de gestos com estrela para favoritos; uma unica previa lateral - treinamento recarrega por evento SSE ao terminar, sem polling - corrige icones encolhidos em botoes .pequeno.icone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" generics="T extends string">
|
||||
let { abas, ativa = $bindable(), rotulo }: { abas: { id: T; titulo: string; contador?: number }[]; ativa: T; rotulo: string } =
|
||||
$props();
|
||||
|
||||
function tecla(e: KeyboardEvent, i: number) {
|
||||
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (!d) return;
|
||||
const prox = abas[(i + d + abas.length) % abas.length];
|
||||
ativa = prox.id;
|
||||
(e.currentTarget as HTMLElement).parentElement?.querySelector<HTMLElement>(`[data-aba="${prox.id}"]`)?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="abas" role="tablist" aria-label={rotulo}>
|
||||
{#each abas as aba, i (aba.id)}
|
||||
<button
|
||||
role="tab"
|
||||
data-aba={aba.id}
|
||||
aria-selected={ativa === aba.id}
|
||||
tabindex={ativa === aba.id ? 0 : -1}
|
||||
onclick={() => (ativa = aba.id)}
|
||||
onkeydown={(e) => tecla(e, i)}
|
||||
>
|
||||
{aba.titulo}
|
||||
{#if aba.contador !== undefined}<span class="contador">{aba.contador}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.abas {
|
||||
display: flex;
|
||||
gap: var(--e-1);
|
||||
border-bottom: 1px solid var(--borda);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: var(--e-2) var(--e-3);
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
background: transparent;
|
||||
color: var(--texto-3);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button[aria-selected='true'] {
|
||||
color: var(--texto);
|
||||
border-bottom-color: var(--destaque);
|
||||
}
|
||||
.contador {
|
||||
font-size: var(--t-xs);
|
||||
padding: 0 6px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie-3);
|
||||
color: var(--texto-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { SIMBOLO_EMOJI } from '../treinamento.svelte';
|
||||
|
||||
// Prévia estática de uma reação (sem iframe): figurinha SVG, emoji ou olhos do 1º passo.
|
||||
let { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
|
||||
const passo = $derived(receita?.passos?.[0]);
|
||||
const simbolo = $derived(receita?.emoji?.caractere || SIMBOLO_EMOJI[receita?.simbolo] || '');
|
||||
</script>
|
||||
|
||||
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
|
||||
{#if receita?.figurinha?.formas}
|
||||
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}>
|
||||
{#each receita.figurinha.formas as forma, i (i)}
|
||||
<path d={forma.caminho} fill={forma.cor} />
|
||||
{/each}
|
||||
</svg>
|
||||
{:else if receita?.emoji}
|
||||
<span class="emoji">{receita.emoji.caractere}</span>
|
||||
{:else}
|
||||
<svg viewBox="0 0 100 60" width={tamanho} height={tamanho * 0.6}>
|
||||
{#if passo?.olhos === 'feliz'}
|
||||
<path d="M18 38 Q30 18 42 38 M58 38 Q70 18 82 38" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
|
||||
{:else if passo?.olhos === 'triste'}
|
||||
<path d="M18 26 Q30 44 42 26 M58 26 Q70 44 82 26" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
|
||||
{:else}
|
||||
{@const ry = passo?.olhos === 'surpreso' ? 20 : 16}
|
||||
<ellipse cx="30" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
|
||||
<ellipse cx="70" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
|
||||
{/if}
|
||||
</svg>
|
||||
{#if simbolo}<span class="simbolo">{simbolo}</span>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.miniatura {
|
||||
position: relative;
|
||||
width: var(--t);
|
||||
height: var(--t);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--raio);
|
||||
background: #05080c;
|
||||
flex: none;
|
||||
}
|
||||
.emoji {
|
||||
font-size: calc(var(--t) * 0.55);
|
||||
line-height: 1;
|
||||
}
|
||||
.simbolo {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 0;
|
||||
font-size: calc(var(--t) * 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { X } from '@lucide/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// <dialog> nativo: foco preso, Esc e fundo vêm de graça.
|
||||
let { aberto = $bindable(false), titulo, children }: { aberto: boolean; titulo: string; children: Snippet } = $props();
|
||||
let dialogo: HTMLDialogElement;
|
||||
|
||||
$effect(() => {
|
||||
if (aberto && !dialogo.open) dialogo.showModal();
|
||||
else if (!aberto && dialogo.open) dialogo.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialogo}
|
||||
onclose={() => (aberto = false)}
|
||||
onclick={(e) => e.target === dialogo && (aberto = false)}
|
||||
aria-label={titulo}
|
||||
>
|
||||
<div class="conteudo">
|
||||
<header>
|
||||
<h2>{titulo}</h2>
|
||||
<button class="btn fantasma icone pequeno" aria-label="Fechar" onclick={() => (aberto = false)}><X size={18} /></button>
|
||||
</header>
|
||||
{#if aberto}{@render children()}{/if}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog {
|
||||
width: min(480px, calc(100vw - 2 * var(--e-4)));
|
||||
padding: 0;
|
||||
border: 1px solid var(--borda-forte);
|
||||
border-radius: var(--raio-lg);
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
box-shadow: var(--sombra-flutuante);
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.conteudo {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
padding: var(--e-5);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--t-lg);
|
||||
}
|
||||
</style>
|
||||
@@ -101,7 +101,7 @@ img, video, canvas, iframe { max-width: 100%; }
|
||||
.btn.fantasma:hover:not(:disabled) { background: var(--superficie-2); }
|
||||
.btn.icone { padding: 0; width: 40px; }
|
||||
.btn.pequeno { min-height: 32px; padding: 0 var(--e-3); font-size: var(--t-xs); }
|
||||
.btn.pequeno.icone { width: 32px; }
|
||||
.btn.pequeno.icone { width: 32px; padding: 0; }
|
||||
|
||||
/* ---------- formulários ---------- */
|
||||
.campo { display: grid; gap: var(--e-1); }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Biblioteca de reações, rotinas e diário (/treinamento/*). Sem polling: recarrega ao abrir a
|
||||
* tela, depois de cada ação e quando o servidor avisa (SSE 'treinamento') que terminou de criar.
|
||||
*/
|
||||
import { pedir, postar } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import { ouvir } from './estado.svelte';
|
||||
|
||||
export interface ReacaoBiblioteca {
|
||||
id: number;
|
||||
horario: number;
|
||||
receita: any;
|
||||
feedback: -1 | 0 | 1;
|
||||
ativa: boolean;
|
||||
}
|
||||
|
||||
export interface StatusTreinamento {
|
||||
ocupado: boolean;
|
||||
erro: string;
|
||||
automatico: boolean;
|
||||
rotina: { id: number; texto: string }[];
|
||||
diario: { id: number; horario: number; papel: string; texto: string }[];
|
||||
resumos: { id: number; horario: number; texto: string }[];
|
||||
emojis: string[];
|
||||
desfazer: [string] | null;
|
||||
reacoes: ReacaoBiblioteca[];
|
||||
prompt_orientacao: string;
|
||||
ultimo_prompt_figurinha: string;
|
||||
}
|
||||
|
||||
class Treinamento {
|
||||
status = $state<StatusTreinamento | null>(null);
|
||||
carregando = $state(false);
|
||||
|
||||
async carregar() {
|
||||
this.carregando = true;
|
||||
try {
|
||||
this.status = await pedir<StatusTreinamento>('/treinamento/status');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
} finally {
|
||||
this.carregando = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Executa uma ação e recarrega; devolve true se deu certo (erro vira toast). */
|
||||
async acao(rota: string, dados: object = {}, sucesso?: string) {
|
||||
try {
|
||||
await postar('/treinamento/' + rota, dados);
|
||||
if (sucesso) avisos.ok(sucesso);
|
||||
await this.carregar();
|
||||
return true;
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const treinamento = new Treinamento();
|
||||
|
||||
let estavaOcupado = false;
|
||||
ouvir('treinamento', (d: { ocupado: boolean; erro: string }) => {
|
||||
if (treinamento.status) treinamento.status = { ...treinamento.status, ocupado: d.ocupado, erro: d.erro };
|
||||
if (estavaOcupado && !d.ocupado) {
|
||||
if (d.erro) avisos.mostrar('Gemini: ' + d.erro, 'erro');
|
||||
else avisos.ok('Novas reações prontas.');
|
||||
treinamento.carregar();
|
||||
}
|
||||
estavaOcupado = d.ocupado;
|
||||
});
|
||||
|
||||
export const SIMBOLO_EMOJI: Record<string, string> = { ideia: '💡', cafe: '☕', musica: '🎵', coracao: '❤️', estrela: '⭐' };
|
||||
Reference in New Issue
Block a user