fix: simplifica reprodução de áudio e corrige CORS

- Remove lógica redundante de URL pública
- Usa diretamente audio_url do banco
- Mantém configuração original do Supabase client
- Melhora tratamento de erros na reprodução
This commit is contained in:
Lucas Santana 2024-12-30 10:20:29 -03:00
parent 087104a7f5
commit 3e7bf811fe
16 changed files with 2478 additions and 157 deletions

View File

@ -3,10 +3,12 @@
## [1.1.0] - 2024-03-21
### Modificado
- Melhorado o processo de upload de áudio para evitar colisões de arquivos e garantir integridade dos dados
- Implementado processamento assíncrono de áudio via Edge Function
### Técnico
- Adicionado UUID para identificação única de arquivos de áudio
- Implementada transação atômica para upload de áudio
- Integrada chamada assíncrona para processamento de áudio

View File

@ -32,8 +32,6 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
- OpenAI
- DALL-E
## 🚀 Como Executar
1. Clone o repositório:
@ -43,6 +41,7 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
### Opções Recomendadas
#### 1. Vercel (Recomendação Principal)
- Ideal para aplicações React/Next.js
- Deploy automático integrado com GitHub
- SSL gratuito
@ -51,6 +50,7 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
- Plano gratuito generoso
#### 2. Netlify
- Também oferece deploy automático
- Funções serverless incluídas
- SSL gratuito

1842
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,11 +15,16 @@
"deploy:prod": "docker-compose up -d --build"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.7",
"@ffmpeg/util": "^0.12.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.2",
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.62.8",
"@testing-library/react": "^16.1.0",
"@types/ioredis": "^4.28.10",
"@types/jest": "^29.5.14",
"@types/next": "^8.0.7",
"clsx": "^2.1.1",
"ioredis": "^5.4.2",
@ -32,10 +37,12 @@
"recharts": "^2.15.0",
"resend": "^3.2.0",
"tailwind-merge": "^2.5.5",
"uuid": "^11.0.3"
"uuid": "^11.0.3",
"vitest": "^2.1.8"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@testing-library/jest-dom": "^6.6.3",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/uuid": "^10.0.0",

View File

@ -0,0 +1,83 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { WordHighlighter } from './WordHighlighter'
import { describe, it, expect, vi } from 'vitest'
import '@testing-library/jest-dom/vitest'
describe('WordHighlighter', () => {
const mockText = "O gato pulou o muro."
const mockHighlightedWords = ['gato', 'pulou']
const mockDifficultWords = ['muro']
const mockOnWordClick = vi.fn()
beforeEach(() => {
mockOnWordClick.mockClear()
})
it('deve renderizar todas as palavras do texto', () => {
render(
<WordHighlighter
text={mockText}
highlightedWords={mockHighlightedWords}
difficultWords={mockDifficultWords}
onWordClick={mockOnWordClick}
/>
)
// Verifica se cada palavra está presente
const words = mockText.split(/(\s+)/).filter(word => word.trim().length > 0)
words.forEach(word => {
expect(screen.getByText(word)).toBeInTheDocument()
})
})
it('deve destacar as palavras corretas', () => {
render(
<WordHighlighter
text={mockText}
highlightedWords={mockHighlightedWords}
difficultWords={mockDifficultWords}
onWordClick={mockOnWordClick}
/>
)
// Verifica palavras destacadas
const highlightedElements = screen.getAllByText(/gato|pulou/)
highlightedElements.forEach(element => {
expect(element).toHaveClass('bg-yellow-200')
})
// Verifica palavras difíceis
const difficultElements = screen.getAllByText('muro')
difficultElements.forEach(element => {
expect(element).toHaveClass('bg-red-100')
})
})
it('deve chamar onWordClick com a palavra correta', () => {
render(
<WordHighlighter
text={mockText}
highlightedWords={mockHighlightedWords}
difficultWords={mockDifficultWords}
onWordClick={mockOnWordClick}
/>
)
// Clica em uma palavra
fireEvent.click(screen.getByText('gato'))
expect(mockOnWordClick).toHaveBeenCalledWith('gato')
})
it('deve lidar com pontuação corretamente', () => {
render(
<WordHighlighter
text="Olá, mundo!"
highlightedWords={['mundo']}
difficultWords={[]}
onWordClick={mockOnWordClick}
/>
)
expect(screen.getByText('mundo!')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,46 @@
interface WordHighlighterProps {
text: string; // Texto completo
highlightedWords: string[]; // Palavras para destacar (ex: palavras difíceis)
difficultWords: string[]; // Palavras que o aluno teve dificuldade
onWordClick: (word: string) => void; // Função para quando clicar na palavra
}
export function WordHighlighter({
text,
highlightedWords,
difficultWords,
onWordClick
}: WordHighlighterProps) {
// Divide o texto em palavras mantendo a pontuação
const words = text.split(/(\s+)/).filter(word => word.trim().length > 0);
return (
<div className="leading-relaxed text-lg space-y-4">
{words.map((word, i) => {
// Remove pontuação para comparação
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/, '');
// Determina o estilo baseado no tipo da palavra
const isHighlighted = highlightedWords.includes(cleanWord);
const isDifficult = difficultWords.includes(cleanWord);
return (
<span
key={i}
onClick={() => onWordClick(word)}
className={`
inline-block mx-1 px-1 rounded cursor-pointer transition-all
hover:scale-110
${isHighlighted ? 'bg-yellow-200 hover:bg-yellow-300' : ''}
${isDifficult ? 'bg-red-100 hover:bg-red-200' : ''}
hover:bg-gray-100
`}
title="Clique para ver mais informações"
>
{word}
</span>
);
})}
</div>
);
}

View File

@ -87,27 +87,11 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
setIsUploading(true);
setError(null);
// Gerar um UUID único para o arquivo
const fileId = uuidv4();
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
try {
// Iniciar uma transação
const { data: recordData, error: recordError } = await supabase
.from('story_recordings')
.insert({
id: fileId, // Usar o mesmo UUID como ID do registro
story_id: storyId,
student_id: studentId,
status: 'uploading', // Status inicial
created_at: new Date().toISOString()
})
.select('id')
.single();
if (recordError) throw recordError;
// Upload do arquivo
// 1. Primeiro fazer o upload do arquivo
const { error: uploadError } = await supabase.storage
.from('recordings')
.upload(filePath, audioBlob, {
@ -116,51 +100,46 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
upsert: false
});
if (uploadError) {
// Se o upload falhar, remover o registro do banco
await supabase
.from('story_recordings')
.delete()
.eq('id', fileId);
throw uploadError;
}
if (uploadError) throw uploadError;
// Obter URL pública
// 2. Obter URL pública
const { data: { publicUrl } } = supabase.storage
.from('recordings')
.getPublicUrl(filePath);
// Atualizar o registro com a URL e status
const { error: updateError } = await supabase
// 3. Criar o registro com a URL do áudio
const { data: recordData, error: recordError } = await supabase
.from('story_recordings')
.update({
audio_url: publicUrl,
status: 'pending_analysis'
.insert({
id: fileId,
story_id: storyId,
student_id: studentId,
audio_url: publicUrl, // Salvar o caminho relativo
status: 'pending_analysis',
created_at: new Date().toISOString()
})
.eq('id', fileId);
.select()
.single();
if (updateError) {
// Se a atualização falhar, limpar tudo
await Promise.all([
supabase.storage.from('recordings').remove([filePath]),
supabase.from('story_recordings').delete().eq('id', fileId)
]);
throw updateError;
}
if (recordError) throw recordError;
// Disparar o processamento de forma assíncrona
triggerAudioProcessing({
// 4. Disparar processamento
await triggerAudioProcessing({
id: fileId,
story_id: storyId,
student_id: studentId,
audio_url: publicUrl,
status: 'pending_analysis'
}).catch(console.error); // Capturar erros mas não esperar pela conclusão
}).catch(console.error);
onAudioUploaded(publicUrl);
setAudioBlob(null);
} catch (err) {
// Em caso de erro, limpar arquivo se foi feito upload
if (filePath) {
await supabase.storage.from('recordings').remove([filePath]);
}
setError('Erro ao enviar áudio. Tente novamente.');
console.error('Erro no upload:', err);
} finally {

View File

@ -0,0 +1,105 @@
import { WordHighlighter } from "../learning/WordHighlighter";
import { useState } from "react";
import * as Dialog from '@radix-ui/react-dialog';
interface StoryReaderProps {
storyText: string;
studentProgress: {
difficultWords: string[];
masteredWords: string[];
};
}
export function StoryReader({ storyText, studentProgress }: StoryReaderProps) {
const [selectedWord, setSelectedWord] = useState<string | null>(null);
const [showWordDetails, setShowWordDetails] = useState(false);
// Palavras importantes para destacar
const highlightedWords = [
'casa', 'bola', 'menino', 'cachorro',
// Palavras frequentes ou importantes para a história
];
const handleWordClick = (word: string) => {
// Abre um modal ou popover com:
// 1. Definição da palavra
// 2. Exemplo de uso
// 3. Imagem relacionada
// 4. Exercícios de pronúncia
setSelectedWord(word);
setShowWordDetails(true);
};
return (
<div className="max-w-2xl mx-auto p-6">
<WordHighlighter
text={storyText}
highlightedWords={highlightedWords}
difficultWords={studentProgress.difficultWords}
onWordClick={handleWordClick}
/>
{/* Modal de detalhes da palavra */}
<WordDetailsModal
word={selectedWord}
isOpen={showWordDetails}
onClose={() => setShowWordDetails(false)}
/>
</div>
);
}
// Adicionar interface para as props do modal
interface WordDetailsModalProps {
word: string | null;
isOpen: boolean;
onClose: () => void;
}
// Componente para mostrar detalhes da palavra
function WordDetailsModal({ word, isOpen, onClose }: WordDetailsModalProps) {
return (
<Dialog.Root open={isOpen} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className="p-6 bg-white rounded-xl">
<h3 className="text-2xl font-bold mb-4">{word}</h3>
{/* Significado */}
<div className="mb-4">
<h4 className="font-semibold">Significado:</h4>
<p>{/* Buscar significado da palavra */}</p>
</div>
{/* Sílabas */}
<div className="mb-4">
<h4 className="font-semibold">Sílabas:</h4>
<div className="flex gap-2">
{word?.split(/(?=[BCDFGHJKLMNPQRSTVWXZ][aeiou])/i).map((syllable, i) => (
<span key={i} className="bg-purple-100 px-2 py-1 rounded">
{syllable}
</span>
))}
</div>
</div>
{/* Exemplo */}
<div className="mb-4">
<h4 className="font-semibold">Exemplo:</h4>
<p className="italic">{/* Exemplo contextualizado */}</p>
</div>
{/* Botão de áudio para ouvir a pronúncia */}
<button
className="bg-blue-500 text-white px-4 py-2 rounded-lg"
onClick={() => {/* Reproduzir áudio */}}
>
Ouvir Pronúncia
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -0,0 +1,55 @@
import { useState } from 'react'
import { WordHighlighter } from '../components/learning/WordHighlighter'
export function TestWordHighlighter() {
const [selectedWord, setSelectedWord] = useState<string>('')
const text = `
O pequeno João gosta muito de brincar.
Sua bola colorida é sua companheira favorita.
Todos os dias ele corre pelo jardim.
Às vezes, encontra seus amigos no parque.
`
const highlightedWords = ['João', 'bola', 'colorida', 'amigos']
const difficultWords = ['companheira', 'favorita']
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Teste do WordHighlighter</h1>
<div className="mb-4 bg-white p-6 rounded-lg shadow">
<WordHighlighter
text={text}
highlightedWords={highlightedWords}
difficultWords={difficultWords}
onWordClick={setSelectedWord}
/>
</div>
{selectedWord && (
<div className="mt-4 p-4 bg-gray-100 rounded-lg">
<p>Palavra selecionada: <strong>{selectedWord}</strong></p>
</div>
)}
<div className="mt-8">
<h2 className="font-bold mb-2">Legenda:</h2>
<ul className="space-y-2">
<li>
<span className="inline-block bg-yellow-200 px-2 py-1 rounded">
Palavras destacadas
</span>
{' '}- Palavras importantes para aprendizado
</li>
<li>
<span className="inline-block bg-red-100 px-2 py-1 rounded">
Palavras difíceis
</span>
{' '}- Palavras que precisam de mais atenção
</li>
</ul>
</div>
</div>
)
}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import React, { useState, useEffect, useCallback } from 'react';
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp, Loader2, Pause, Play, Download, RefreshCw } from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { AudioRecorder } from '../../components/story/AudioRecorder';
@ -7,6 +7,7 @@ import type { Story } from '../../types/database';
import { StoryMetrics } from '../../components/story/StoryMetrics';
import type { MetricsData } from '../../components/story/StoryMetrics';
import { getOptimizedImageUrl } from '../../lib/imageUtils';
import { convertWebmToMp3 } from '../../utils/audioConverter';
interface StoryRecording {
id: string;
@ -23,10 +24,110 @@ interface StoryRecording {
suggestions: string;
created_at: string;
processed_at: string | null;
audio_url: string;
transcription: string;
}
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const [isExpanded, setIsExpanded] = React.useState(false);
const [isPlaying, setIsPlaying] = React.useState(false);
const [isAudioSupported, setIsAudioSupported] = React.useState(true);
const audioRef = React.useRef<HTMLAudioElement | null>(null);
const [isConverting, setIsConverting] = React.useState(false);
const [mp3Url, setMp3Url] = React.useState<string | null>(null);
const [conversionError, setConversionError] = React.useState<string | null>(null);
// Verificar suporte ao formato WebM
React.useEffect(() => {
const audio = document.createElement('audio');
const canPlayWebm = audio.canPlayType('audio/webm') !== '';
setIsAudioSupported(canPlayWebm);
if (!canPlayWebm) {
console.warn('Navegador não suporta áudio WebM');
}
}, []);
// Adicionar log quando o áudio é carregado
const handleAudioLoad = () => {
console.log('Áudio carregado com sucesso');
};
// Melhorar o tratamento de erro do áudio
const handleAudioError = (e: React.SyntheticEvent<HTMLAudioElement, Event>) => {
console.error('Erro ao carregar áudio:', e);
const audioElement = e.currentTarget;
console.error('Detalhes do erro:', {
error: audioElement.error,
networkState: audioElement.networkState,
readyState: audioElement.readyState,
src: audioElement.src
});
setIsPlaying(false);
};
const handlePlayPause = async () => {
if (audioRef.current) {
try {
if (isPlaying) {
audioRef.current.pause();
} else {
if (audioRef.current.ended) {
audioRef.current.currentTime = 0;
}
// Verificar se o áudio está pronto
if (audioRef.current.readyState === 0) {
console.log('Recarregando áudio...');
await audioRef.current.load();
}
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
console.log('Reprodução iniciada com sucesso');
})
.catch(error => {
console.error('Erro ao reproduzir áudio:', error);
setIsPlaying(false);
});
}
}
setIsPlaying(!isPlaying);
} catch (error) {
console.error('Erro ao manipular áudio:', error);
setIsPlaying(false);
}
}
};
// Função para converter áudio
const handleConvertAudio = async () => {
if (!recording.audio_url) return;
try {
setIsConverting(true);
setConversionError(null);
const { url } = await convertWebmToMp3(recording.audio_url);
setMp3Url(url);
setIsAudioSupported(true);
} catch (error) {
console.error('Erro na conversão:', error);
setConversionError('Não foi possível converter o áudio. Tente baixar o arquivo original.');
} finally {
setIsConverting(false);
}
};
// Limpar URL do MP3 quando o componente for desmontado
React.useEffect(() => {
return () => {
if (mp3Url) {
URL.revokeObjectURL(mp3Url);
}
};
}, [mp3Url]);
const metrics = [
{ label: 'Fluência', value: recording.fluency_score, color: 'text-blue-600' },
@ -35,6 +136,27 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
{ label: 'Compreensão', value: recording.comprehension_score, color: 'text-orange-600' }
];
// Monitorar eventos do áudio
React.useEffect(() => {
const audio = audioRef.current;
if (audio) {
const handleEnded = () => setIsPlaying(false);
const handleError = (e: ErrorEvent) => {
console.error('Erro no áudio:', e);
setIsPlaying(false);
};
audio.addEventListener('ended', handleEnded);
audio.addEventListener('error', handleError);
return () => {
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('error', handleError);
};
}
}, []);
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{/* Cabeçalho sempre visível */}
@ -77,6 +199,98 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
{/* Conteúdo expandido */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-100">
{/* Player de Áudio */}
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<h5 className="text-sm font-medium text-gray-900 mb-3">Gravação</h5>
<div className="flex items-center gap-4">
{!isAudioSupported ? (
<div className="space-y-3">
<div className="text-sm text-amber-600 bg-amber-50 p-3 rounded-lg">
Seu navegador não suporta a reprodução deste áudio.
</div>
{!isConverting && !mp3Url && (
<button
onClick={handleConvertAudio}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
<RefreshCw className={`h-5 w-5 ${isConverting ? 'animate-spin' : ''}`} />
Converter para MP3
</button>
)}
{conversionError && (
<p className="text-sm text-red-600">{conversionError}</p>
)}
</div>
) : (
<button
onClick={handlePlayPause}
disabled={!recording.audio_url && !mp3Url}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPlaying ? (
<>
<Pause className="h-5 w-5" />
Pausar
</>
) : (
<>
<Play className="h-5 w-5" />
{recording.audio_url || mp3Url ? 'Ouvir' : 'Carregando...'}
</>
)}
</button>
)}
{(recording.audio_url || mp3Url) && (
<>
<audio
ref={audioRef}
src={mp3Url || recording.audio_url}
preload="metadata"
onLoadedData={handleAudioLoad}
onError={handleAudioError}
>
<source src={mp3Url || recording.audio_url} type={mp3Url ? 'audio/mp3' : 'audio/webm'} />
Seu navegador não suporta o elemento de áudio.
</audio>
{/* Baixar áudio */}
<div className="flex gap-2">
<a
href={recording.audio_url}
download={`gravacao-${new Date(recording.created_at).toISOString().split('T')[0]}.webm`}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
>
<Download className="h-5 w-5" />
WebM
</a>
{mp3Url && (
<a
href={mp3Url}
download={`gravacao-${new Date(recording.created_at).toISOString().split('T')[0]}.mp3`}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
>
<Download className="h-5 w-5" />
MP3
</a>
)}
</div>
</>
)}
</div>
</div>
{/* Transcrição */}
<div className="mt-4">
<h5 className="text-sm font-medium text-gray-900 mb-2">Transcrição</h5>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600 whitespace-pre-wrap">
{recording.transcription || 'Transcrição não disponível'}
</p>
</div>
</div>
{/* Métricas existentes */}
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
<div>
<span className="text-gray-500">Palavras por minuto:</span>
@ -247,13 +461,21 @@ export function StoryPage() {
try {
const { data, error } = await supabase
.from('story_recordings')
.select('*')
.select('*, audio_url')
.eq('story_id', story.id)
.eq('status', 'completed')
.order('created_at', { ascending: false });
if (error) throw error;
setRecordings(data || []);
// Log para debug
console.log('Recordings fetched:', data);
if (data && data.length > 0) {
setRecordings(data);
} else {
console.log('Nenhuma gravação encontrada');
}
} catch (err) {
console.error('Erro ao carregar gravações:', err);
} finally {
@ -337,6 +559,7 @@ export function StoryPage() {
return (
<div>
{/* Cabeçalho e Navegação */}
<div className="flex justify-between items-center mb-6">
<button
onClick={() => navigate('/aluno/historias')}
@ -364,41 +587,8 @@ export function StoryPage() {
</div>
</div>
{/* Dashboard de métricas */}
{loadingRecordings ? (
<div className="animate-pulse">
<div className="h-48 bg-gray-100 rounded-lg mb-6" />
</div>
) : recordings.length > 0 ? (
<StoryMetrics
data={formatMetricsData(getLatestRecording())}
isLoading={false}
/>
) : (
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
<p className="text-gray-600">
Você ainda não tem gravações para esta história.
Faça sua primeira gravação para ver suas métricas!
</p>
</div>
)}
{/* Histórico de gravações */}
{recordings.length > 1 && (
<div className="mb-6">
<h3 className="text-lg font-medium mb-4">Histórico de Gravações</h3>
<div className="space-y-4">
{recordings.slice(1).map((recording) => (
<RecordingHistoryCard
key={recording.id}
recording={recording}
/>
))}
</div>
</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* História Principal */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
{/* Imagem da página atual */}
{story?.content?.pages?.[currentPage]?.image && (
<ImageWithLoading
@ -411,11 +601,11 @@ export function StoryPage() {
/>
)}
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">{story?.title}</h1>
<div className="p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">{story?.title}</h1>
{/* Texto da página atual */}
<p className="text-lg text-gray-700 mb-8">
<p className="text-xl leading-relaxed text-gray-700 mb-8">
{story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
</p>
@ -454,6 +644,43 @@ export function StoryPage() {
</div>
</div>
</div>
{/* Dashboard de métricas */}
{loadingRecordings ? (
<div className="animate-pulse">
<div className="h-48 bg-gray-100 rounded-lg mb-6" />
</div>
) : recordings.length > 0 ? (
<div className="space-y-8">
<h2 className="text-2xl font-bold text-gray-900">Dashboard de Leitura</h2>
<StoryMetrics
data={formatMetricsData(getLatestRecording())}
isLoading={false}
/>
</div>
) : (
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
<p className="text-gray-600">
Você ainda não tem gravações para esta história.
Faça sua primeira gravação para ver suas métricas!
</p>
</div>
)}
{/* Histórico de gravações */}
{recordings.length > 0 && (
<div className="mt-8">
<h3 className="text-xl font-bold text-gray-900 mb-4">Histórico de Gravações</h3>
<div className="space-y-4">
{recordings.map((recording) => (
<RecordingHistoryCard
key={recording.id}
recording={recording}
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -28,12 +28,17 @@ import { StudentClassPage } from './pages/student-dashboard/StudentClassPage';
import { DemoPage } from './pages/demo/DemoPage';
import { ParentsLandingPage } from './pages/landing/ParentsLandingPage';
import { EducationalForParents } from './pages/landing/EducationalForParents';
import { TestWordHighlighter } from './pages/TestWordHighlighter';
export const router = createBrowserRouter([
{
path: '/',
element: <HomePage />,
},
{
path: '/teste',
element: <TestWordHighlighter />,
},
{
path: '/para-pais',
element: <ParentsLandingPage />,

View File

@ -0,0 +1,41 @@
import { WordHighlighter } from '../components/learning/WordHighlighter'
export function ExampleStory() {
const storyText = `
Era uma vez um gato muito esperto.
Ele adorava pular muros e explorar jardins.
Um dia, encontrou um cachorro amigável.
Juntos, eles viraram grandes amigos.
`
const highlightedWords = [
'gato',
'esperto',
'pular',
'cachorro',
'amigos'
]
const difficultWords = [
'explorar',
'amigável'
]
const handleWordClick = (word: string) => {
console.log(`Palavra clicada: ${word}`)
// Aqui você pode implementar a lógica de mostrar detalhes da palavra
}
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">O Gato Explorador</h1>
<WordHighlighter
text={storyText}
highlightedWords={highlightedWords}
difficultWords={difficultWords}
onWordClick={handleWordClick}
/>
</div>
)
}

1
src/test/setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

View File

@ -0,0 +1,42 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
let ffmpeg: FFmpeg | null = null;
export async function convertWebmToMp3(webmUrl: string): Promise<{ url: string; blob: Blob }> {
if (!ffmpeg) {
ffmpeg = new FFmpeg();
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.4/dist/umd';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
}
try {
// Baixar o arquivo WebM
const webmResponse = await fetch(webmUrl);
const webmBlob = await webmResponse.blob();
const webmBuffer = await webmBlob.arrayBuffer();
// Escrever o arquivo de entrada no sistema de arquivos virtual do FFmpeg
await ffmpeg.writeFile('input.webm', new Uint8Array(webmBuffer));
// Executar a conversão
await ffmpeg.exec(['-i', 'input.webm', '-acodec', 'libmp3lame', '-ab', '128k', 'output.mp3']);
// Ler o arquivo convertido
const mp3Data = await ffmpeg.readFile('output.mp3');
const mp3Blob = new Blob([mp3Data], { type: 'audio/mp3' });
const mp3Url = URL.createObjectURL(mp3Blob);
// Limpar arquivos do sistema de arquivos virtual
await ffmpeg.deleteFile('input.webm');
await ffmpeg.deleteFile('output.mp3');
return { url: mp3Url, blob: mp3Blob };
} catch (error) {
console.error('Erro na conversão do áudio:', error);
throw error;
}
}

View File

@ -6,7 +6,8 @@ import path from 'path';
export default defineConfig({
plugins: [react()],
optimizeDeps: {
include: ['@supabase/supabase-js', 'resend']
include: ['@supabase/supabase-js', 'resend'],
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
},
build: {
commonjsOptions: {
@ -24,5 +25,11 @@ export default defineConfig({
'node-fetch': 'isomorphic-fetch'
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json']
}
},
server: {
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
},
});

9
vitest.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})