mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-17 05:47:52 +00:00
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:
parent
087104a7f5
commit
3e7bf811fe
@ -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
|
||||
|
||||
@ -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
1842
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
83
src/components/learning/WordHighlighter.test.tsx
Normal file
83
src/components/learning/WordHighlighter.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
46
src/components/learning/WordHighlighter.tsx
Normal file
46
src/components/learning/WordHighlighter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
105
src/components/story/StoryReader.tsx
Normal file
105
src/components/story/StoryReader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/pages/TestWordHighlighter.tsx
Normal file
55
src/pages/TestWordHighlighter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 />,
|
||||
|
||||
41
src/stories/ExampleStory.tsx
Normal file
41
src/stories/ExampleStory.tsx
Normal 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
1
src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
42
src/utils/audioConverter.ts
Normal file
42
src/utils/audioConverter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
9
vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user