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,11 +3,13 @@
|
|||||||
## [1.1.0] - 2024-03-21
|
## [1.1.0] - 2024-03-21
|
||||||
|
|
||||||
### Modificado
|
### Modificado
|
||||||
|
|
||||||
- Melhorado o processo de upload de áudio para evitar colisões de arquivos e garantir integridade dos dados
|
- 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
|
- Implementado processamento assíncrono de áudio via Edge Function
|
||||||
|
|
||||||
### Técnico
|
### Técnico
|
||||||
|
|
||||||
- Adicionado UUID para identificação única de arquivos de áudio
|
- Adicionado UUID para identificação única de arquivos de áudio
|
||||||
- Implementada transação atômica para upload de áudio
|
- Implementada transação atômica para upload de áudio
|
||||||
- Integrada chamada assíncrona para processamento de áudio
|
- Integrada chamada assíncrona para processamento de áudio
|
||||||
- Melhorado tratamento de erros no processo de upload
|
- Melhorado tratamento de erros no processo de upload
|
||||||
|
|||||||
@ -32,17 +32,16 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
|
|||||||
- OpenAI
|
- OpenAI
|
||||||
- DALL-E
|
- DALL-E
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 🚀 Como Executar
|
## 🚀 Como Executar
|
||||||
|
|
||||||
1. Clone o repositório:
|
1. Clone o repositório:
|
||||||
|
|
||||||
## 🚀 Deploy
|
## 🚀 Deploy
|
||||||
|
|
||||||
### Opções Recomendadas
|
### Opções Recomendadas
|
||||||
|
|
||||||
#### 1. Vercel (Recomendação Principal)
|
#### 1. Vercel (Recomendação Principal)
|
||||||
|
|
||||||
- Ideal para aplicações React/Next.js
|
- Ideal para aplicações React/Next.js
|
||||||
- Deploy automático integrado com GitHub
|
- Deploy automático integrado com GitHub
|
||||||
- SSL gratuito
|
- SSL gratuito
|
||||||
@ -51,6 +50,7 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
|
|||||||
- Plano gratuito generoso
|
- Plano gratuito generoso
|
||||||
|
|
||||||
#### 2. Netlify
|
#### 2. Netlify
|
||||||
|
|
||||||
- Também oferece deploy automático
|
- Também oferece deploy automático
|
||||||
- Funções serverless incluídas
|
- Funções serverless incluídas
|
||||||
- SSL gratuito
|
- 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"
|
"deploy:prod": "docker-compose up -d --build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ffmpeg/ffmpeg": "^0.12.7",
|
||||||
|
"@ffmpeg/util": "^0.12.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.2",
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@supabase/supabase-js": "^2.39.7",
|
"@supabase/supabase-js": "^2.39.7",
|
||||||
"@tanstack/react-query": "^5.62.8",
|
"@tanstack/react-query": "^5.62.8",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/next": "^8.0.7",
|
"@types/next": "^8.0.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
@ -32,10 +37,12 @@
|
|||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"resend": "^3.2.0",
|
"resend": "^3.2.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"uuid": "^11.0.3"
|
"uuid": "^11.0.3",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@types/react": "^18.3.17",
|
"@types/react": "^18.3.17",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@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);
|
setIsUploading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Gerar um UUID único para o arquivo
|
|
||||||
const fileId = uuidv4();
|
const fileId = uuidv4();
|
||||||
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
|
const filePath = `${studentId}/${storyId}/${fileId}.webm`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Iniciar uma transação
|
// 1. Primeiro fazer o upload do arquivo
|
||||||
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
|
|
||||||
const { error: uploadError } = await supabase.storage
|
const { error: uploadError } = await supabase.storage
|
||||||
.from('recordings')
|
.from('recordings')
|
||||||
.upload(filePath, audioBlob, {
|
.upload(filePath, audioBlob, {
|
||||||
@ -116,51 +100,46 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
|||||||
upsert: false
|
upsert: false
|
||||||
});
|
});
|
||||||
|
|
||||||
if (uploadError) {
|
if (uploadError) throw uploadError;
|
||||||
// Se o upload falhar, remover o registro do banco
|
|
||||||
await supabase
|
|
||||||
.from('story_recordings')
|
|
||||||
.delete()
|
|
||||||
.eq('id', fileId);
|
|
||||||
throw uploadError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obter URL pública
|
// 2. Obter URL pública
|
||||||
const { data: { publicUrl } } = supabase.storage
|
const { data: { publicUrl } } = supabase.storage
|
||||||
.from('recordings')
|
.from('recordings')
|
||||||
.getPublicUrl(filePath);
|
.getPublicUrl(filePath);
|
||||||
|
|
||||||
// Atualizar o registro com a URL e status
|
// 3. Criar o registro com a URL do áudio
|
||||||
const { error: updateError } = await supabase
|
const { data: recordData, error: recordError } = await supabase
|
||||||
.from('story_recordings')
|
.from('story_recordings')
|
||||||
.update({
|
.insert({
|
||||||
audio_url: publicUrl,
|
id: fileId,
|
||||||
status: 'pending_analysis'
|
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) {
|
if (recordError) throw recordError;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disparar o processamento de forma assíncrona
|
// 4. Disparar processamento
|
||||||
triggerAudioProcessing({
|
await triggerAudioProcessing({
|
||||||
id: fileId,
|
id: fileId,
|
||||||
story_id: storyId,
|
story_id: storyId,
|
||||||
student_id: studentId,
|
student_id: studentId,
|
||||||
audio_url: publicUrl,
|
audio_url: publicUrl,
|
||||||
status: 'pending_analysis'
|
status: 'pending_analysis'
|
||||||
}).catch(console.error); // Capturar erros mas não esperar pela conclusão
|
}).catch(console.error);
|
||||||
|
|
||||||
onAudioUploaded(publicUrl);
|
onAudioUploaded(publicUrl);
|
||||||
setAudioBlob(null);
|
setAudioBlob(null);
|
||||||
|
|
||||||
} catch (err) {
|
} 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.');
|
setError('Erro ao enviar áudio. Tente novamente.');
|
||||||
console.error('Erro no upload:', err);
|
console.error('Erro no upload:', err);
|
||||||
} finally {
|
} 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 React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp, Loader2 } from 'lucide-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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { AudioRecorder } from '../../components/story/AudioRecorder';
|
import { AudioRecorder } from '../../components/story/AudioRecorder';
|
||||||
@ -7,6 +7,7 @@ import type { Story } from '../../types/database';
|
|||||||
import { StoryMetrics } from '../../components/story/StoryMetrics';
|
import { StoryMetrics } from '../../components/story/StoryMetrics';
|
||||||
import type { MetricsData } from '../../components/story/StoryMetrics';
|
import type { MetricsData } from '../../components/story/StoryMetrics';
|
||||||
import { getOptimizedImageUrl } from '../../lib/imageUtils';
|
import { getOptimizedImageUrl } from '../../lib/imageUtils';
|
||||||
|
import { convertWebmToMp3 } from '../../utils/audioConverter';
|
||||||
|
|
||||||
interface StoryRecording {
|
interface StoryRecording {
|
||||||
id: string;
|
id: string;
|
||||||
@ -23,10 +24,110 @@ interface StoryRecording {
|
|||||||
suggestions: string;
|
suggestions: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
processed_at: string | null;
|
processed_at: string | null;
|
||||||
|
audio_url: string;
|
||||||
|
transcription: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
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 = [
|
const metrics = [
|
||||||
{ label: 'Fluência', value: recording.fluency_score, color: 'text-blue-600' },
|
{ 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' }
|
{ 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 (
|
return (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
{/* Cabeçalho sempre visível */}
|
{/* Cabeçalho sempre visível */}
|
||||||
@ -77,6 +199,98 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
|||||||
{/* Conteúdo expandido */}
|
{/* Conteúdo expandido */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-4 pb-4 border-t border-gray-100">
|
<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 className="grid grid-cols-2 gap-4 mt-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Palavras por minuto:</span>
|
<span className="text-gray-500">Palavras por minuto:</span>
|
||||||
@ -247,13 +461,21 @@ export function StoryPage() {
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('story_recordings')
|
.from('story_recordings')
|
||||||
.select('*')
|
.select('*, audio_url')
|
||||||
.eq('story_id', story.id)
|
.eq('story_id', story.id)
|
||||||
.eq('status', 'completed')
|
.eq('status', 'completed')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) throw error;
|
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) {
|
} catch (err) {
|
||||||
console.error('Erro ao carregar gravações:', err);
|
console.error('Erro ao carregar gravações:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -337,6 +559,7 @@ export function StoryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Cabeçalho e Navegação */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/aluno/historias')}
|
onClick={() => navigate('/aluno/historias')}
|
||||||
@ -364,41 +587,8 @@ export function StoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dashboard de métricas */}
|
{/* História Principal */}
|
||||||
{loadingRecordings ? (
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
|
||||||
<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">
|
|
||||||
{/* Imagem da página atual */}
|
{/* Imagem da página atual */}
|
||||||
{story?.content?.pages?.[currentPage]?.image && (
|
{story?.content?.pages?.[currentPage]?.image && (
|
||||||
<ImageWithLoading
|
<ImageWithLoading
|
||||||
@ -411,11 +601,11 @@ export function StoryPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">{story?.title}</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">{story?.title}</h1>
|
||||||
|
|
||||||
{/* Texto da página atual */}
|
{/* 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...'}
|
{story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -454,6 +644,43 @@ export function StoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -28,12 +28,17 @@ import { StudentClassPage } from './pages/student-dashboard/StudentClassPage';
|
|||||||
import { DemoPage } from './pages/demo/DemoPage';
|
import { DemoPage } from './pages/demo/DemoPage';
|
||||||
import { ParentsLandingPage } from './pages/landing/ParentsLandingPage';
|
import { ParentsLandingPage } from './pages/landing/ParentsLandingPage';
|
||||||
import { EducationalForParents } from './pages/landing/EducationalForParents';
|
import { EducationalForParents } from './pages/landing/EducationalForParents';
|
||||||
|
import { TestWordHighlighter } from './pages/TestWordHighlighter';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <HomePage />,
|
element: <HomePage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/teste',
|
||||||
|
element: <TestWordHighlighter />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/para-pais',
|
path: '/para-pais',
|
||||||
element: <ParentsLandingPage />,
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['@supabase/supabase-js', 'resend']
|
include: ['@supabase/supabase-js', 'resend'],
|
||||||
|
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
commonjsOptions: {
|
commonjsOptions: {
|
||||||
@ -24,5 +25,11 @@ export default defineConfig({
|
|||||||
'node-fetch': 'isomorphic-fetch'
|
'node-fetch': 'isomorphic-fetch'
|
||||||
},
|
},
|
||||||
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json']
|
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