mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-16 13:27:52 +00:00
feat: aprimora interface do exercício de formação de palavras
- Adiciona barra de progresso e feedback visual - Implementa lista de palavras encontradas - Melhora interatividade e estados visuais - Adiciona validação de palavras repetidas - Otimiza transições e animações - Mantém consistência com outros exercícios type: feat scope: exercises breaking: false
This commit is contained in:
parent
745f8de40e
commit
9840fe76b0
32
CHANGELOG.md
32
CHANGELOG.md
@ -48,3 +48,35 @@
|
||||
### Modificado
|
||||
- Aprimorado fluxo de exclusão de histórias para garantir remoção completa de recursos
|
||||
- Adicionada confirmação visual durante processo de deleção
|
||||
|
||||
## [1.4.0] - 2024-03-21
|
||||
|
||||
### Adicionado
|
||||
- Implementado sistema de exercícios de alfabetização
|
||||
- Adicionados três tipos de exercícios: formação de palavras, completar frases e treino de pronúncia
|
||||
- Criada página dedicada para exercícios
|
||||
- Implementado tracking de progresso nos exercícios
|
||||
|
||||
### Técnico
|
||||
- Criada nova rota para exercícios
|
||||
- Adicionada tabela exercise_progress
|
||||
- Implementada lógica de navegação entre exercícios
|
||||
|
||||
## [Não Publicado]
|
||||
|
||||
### Modificado
|
||||
- Melhorada a interface do exercício de Formação de Palavras
|
||||
- Adicionada barra de progresso com animação
|
||||
- Adicionado feedback visual para acertos e erros
|
||||
- Adicionada lista de palavras encontradas
|
||||
- Melhorada a interatividade dos botões de sílabas
|
||||
- Adicionados estados visuais para feedback do usuário
|
||||
- Melhorada a área de exibição da palavra atual
|
||||
- Adicionado contador de progresso
|
||||
- Melhorada a consistência visual com outros exercícios
|
||||
|
||||
### Técnico
|
||||
- Refatorado componente WordFormation para melhor gerenciamento de estado
|
||||
- Adicionada validação para palavras repetidas
|
||||
- Implementado sistema de feedback temporário
|
||||
- Otimizadas transições e animações
|
||||
|
||||
51
PROJECT_CONTEXT.md
Normal file
51
PROJECT_CONTEXT.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Story Generator - Plataforma Educacional de Leitura
|
||||
|
||||
## Visão Geral
|
||||
Plataforma educacional focada em crianças de 6-12 anos para prática e desenvolvimento de leitura, utilizando histórias geradas por IA e análise de áudio para feedback em tempo real.
|
||||
|
||||
## Principais Funcionalidades
|
||||
1. **Geração de Histórias**
|
||||
- Histórias personalizadas por IA
|
||||
- Adaptação ao nível do aluno
|
||||
- Imagens ilustrativas geradas por IA
|
||||
|
||||
2. **Sistema de Leitura**
|
||||
- Gravação de áudio da leitura
|
||||
- Análise de pronúncia e fluência
|
||||
- Destaque de palavras importantes (WordHighlighter)
|
||||
- Modal de detalhes para palavras difíceis
|
||||
|
||||
3. **Análise de Performance**
|
||||
- Métricas de leitura (fluência, pronúncia, etc.)
|
||||
- Dashboard de progresso
|
||||
- Histórico de gravações
|
||||
- Conversão de áudio WebM para MP3
|
||||
|
||||
## Arquitetura
|
||||
|
||||
### Frontend (React + TypeScript)
|
||||
- `/src/components/learning/` - Componentes educacionais
|
||||
- `/src/components/story/` - Componentes de história
|
||||
- `/src/pages/student-dashboard/` - Dashboard do aluno
|
||||
- `/src/utils/` - Utilitários (conversão de áudio, etc.)
|
||||
|
||||
### Backend (Supabase)
|
||||
- Functions:
|
||||
- `process-audio` - Análise de áudio e feedback
|
||||
- `generate-story` - Geração de histórias
|
||||
|
||||
### Storage
|
||||
- `recordings/` - Áudios das leituras
|
||||
- `story-images/` - Imagens das histórias
|
||||
|
||||
## Decisões Técnicas
|
||||
1. Uso de Supabase para backend serverless
|
||||
2. FFmpeg.js para conversão de áudio no cliente
|
||||
3. Testes com Vitest e Testing Library
|
||||
4. Tailwind CSS para estilização
|
||||
5. Radix UI para componentes acessíveis
|
||||
|
||||
## Estado Atual
|
||||
- Implementado sistema de gravação e análise de áudio
|
||||
- Desenvolvido componente WordHighlighter com testes
|
||||
- Sistema de deleção de histórias com limpeza de recursos
|
||||
185
src/components/exercises/PronunciationPractice.tsx
Normal file
185
src/components/exercises/PronunciationPractice.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { Mic, Square, Play, Loader2, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface PronunciationPracticeProps {
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export function PronunciationPractice({ words }: PronunciationPracticeProps) {
|
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [score, setScore] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
// Verificar se há palavras para praticar
|
||||
if (!words.length) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<p className="text-gray-600">
|
||||
Não há palavras para praticar neste momento.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorderRef.current.ondataavailable = (e) => {
|
||||
chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
setAudioBlob(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.start();
|
||||
setIsRecording(true);
|
||||
} catch (err) {
|
||||
console.error('Erro ao iniciar gravação:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
|
||||
const playAudio = () => {
|
||||
if (audioBlob) {
|
||||
const url = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(url);
|
||||
audio.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextWord = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Aqui você pode implementar a lógica de análise da pronúncia
|
||||
// Por enquanto, vamos apenas avançar e dar pontos
|
||||
setScore(prev => prev + 10);
|
||||
|
||||
if (currentWordIndex < words.length - 1) {
|
||||
setCurrentWordIndex(prev => prev + 1);
|
||||
setAudioBlob(null);
|
||||
} else {
|
||||
setCompleted(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar áudio:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
{/* Cabeçalho */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Treino de Pronúncia
|
||||
</h2>
|
||||
<div className="mt-2 flex justify-between items-center text-sm text-gray-500">
|
||||
<span>Palavra {currentWordIndex + 1} de {words.length}</span>
|
||||
<span>{score} pontos</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 transition-all"
|
||||
style={{ width: `${((currentWordIndex + 1) / words.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{completed ? (
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Parabéns! Você completou o exercício!
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Pontuação final: {score} pontos
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Palavra atual */}
|
||||
<div className="mb-8 text-center">
|
||||
<h3 className="text-4xl font-bold text-gray-900">
|
||||
{words[currentWordIndex]}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Controles de gravação */}
|
||||
<div className="flex justify-center gap-4 mb-8">
|
||||
{!isRecording && !audioBlob && (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-red-600 text-white rounded-lg
|
||||
hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
Gravar
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg
|
||||
hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
Parar
|
||||
</button>
|
||||
)}
|
||||
|
||||
{audioBlob && (
|
||||
<button
|
||||
onClick={playAudio}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg
|
||||
hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
Ouvir
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botão de próxima palavra */}
|
||||
{audioBlob && (
|
||||
<button
|
||||
onClick={handleNextWord}
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 bg-purple-600 text-white
|
||||
rounded-lg font-medium hover:bg-purple-700 disabled:bg-gray-300
|
||||
disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{currentWordIndex < words.length - 1 ? 'Próxima Palavra' : 'Finalizar'}
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/components/exercises/SentenceCompletion.tsx
Normal file
178
src/components/exercises/SentenceCompletion.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import React, { useState } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface SentenceCompletionProps {
|
||||
story: {
|
||||
id: string;
|
||||
content: {
|
||||
pages: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function SentenceCompletion({ story }: SentenceCompletionProps) {
|
||||
const [currentSentence, setCurrentSentence] = useState(0);
|
||||
const [userAnswer, setUserAnswer] = useState('');
|
||||
const [score, setScore] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [exerciseSentences, setExerciseSentences] = useState<Array<{
|
||||
sentence: string;
|
||||
answer: string;
|
||||
}>>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Carregar palavras e preparar sentenças
|
||||
React.useEffect(() => {
|
||||
const loadExerciseWords = async () => {
|
||||
try {
|
||||
const { data: words, error } = await supabase
|
||||
.from('story_exercise_words')
|
||||
.select('*')
|
||||
.eq('story_id', story.id)
|
||||
.eq('exercise_type', 'completion');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Extrair todas as sentenças do texto
|
||||
const allSentences = story.content.pages
|
||||
.map(page => page.text.split(/[.!?]+/))
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.map(sentence => sentence.trim());
|
||||
|
||||
// Preparar exercícios com as palavras do banco
|
||||
const exercises = allSentences
|
||||
.filter(sentence =>
|
||||
words?.some(word =>
|
||||
sentence.toLowerCase().includes(word.word.toLowerCase())
|
||||
)
|
||||
)
|
||||
.map(sentence => {
|
||||
const word = words?.find(w =>
|
||||
sentence.toLowerCase().includes(w.word.toLowerCase())
|
||||
);
|
||||
return {
|
||||
sentence: sentence.replace(
|
||||
new RegExp(word?.word || '', 'i'),
|
||||
'_____'
|
||||
),
|
||||
answer: word?.word || ''
|
||||
};
|
||||
})
|
||||
.filter(exercise => exercise.answer); // Remover exercícios sem resposta
|
||||
|
||||
setExerciseSentences(exercises);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar palavras:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadExerciseWords();
|
||||
}, [story.id, story.content.pages]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
|
||||
<div className="h-64 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!exerciseSentences.length) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<p className="text-gray-600">
|
||||
Não foi possível gerar exercícios para este texto.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentExercise = exerciseSentences[currentSentence];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const isCorrect = userAnswer.toLowerCase() === currentExercise.answer.toLowerCase();
|
||||
|
||||
if (isCorrect) {
|
||||
setScore(prev => prev + 10);
|
||||
}
|
||||
|
||||
// Avançar para próxima sentença
|
||||
if (currentSentence < exerciseSentences.length - 1) {
|
||||
setCurrentSentence(prev => prev + 1);
|
||||
setUserAnswer('');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao verificar resposta:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Complete a Frase
|
||||
</h2>
|
||||
|
||||
{/* Progresso */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-500">
|
||||
<span>Questão {currentSentence + 1} de {exerciseSentences.length}</span>
|
||||
<span>{score} pontos</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 transition-all"
|
||||
style={{ width: `${((currentSentence + 1) / exerciseSentences.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sentença atual */}
|
||||
<div className="mb-8">
|
||||
<p className="text-xl leading-relaxed text-gray-700">
|
||||
{currentExercise.sentence}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Campo de resposta */}
|
||||
<div className="mb-8">
|
||||
<input
|
||||
type="text"
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
placeholder="Digite a palavra que completa a frase..."
|
||||
className="w-full px-4 py-3 text-lg border border-gray-300 rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Botão de verificação */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!userAnswer || isSubmitting}
|
||||
className="w-full py-3 bg-purple-600 text-white rounded-lg font-medium
|
||||
hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
|
||||
) : (
|
||||
'Verificar'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
src/components/exercises/WordFormation.tsx
Normal file
267
src/components/exercises/WordFormation.tsx
Normal file
@ -0,0 +1,267 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
interface WordFormationProps {
|
||||
words: string[];
|
||||
storyId: string;
|
||||
}
|
||||
|
||||
interface SyllableWord {
|
||||
word: string;
|
||||
syllables: string[];
|
||||
}
|
||||
|
||||
export function WordFormation({ words, storyId }: WordFormationProps) {
|
||||
const [availableSyllables, setAvailableSyllables] = useState<string[]>([]);
|
||||
const [userWord, setUserWord] = useState<string>('');
|
||||
const [score, setScore] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [targetWords, setTargetWords] = useState<SyllableWord[]>([]);
|
||||
const [completedWords, setCompletedWords] = useState<string[]>([]);
|
||||
const [showFeedback, setShowFeedback] = useState<{
|
||||
type: 'success' | 'error';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWords = async () => {
|
||||
try {
|
||||
const { data: exerciseWords, error } = await supabase
|
||||
.from('story_exercise_words')
|
||||
.select('*')
|
||||
.eq('story_id', storyId)
|
||||
.eq('exercise_type', 'formation');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Dividir palavras em sílabas (simplificado)
|
||||
const wordList = exerciseWords?.map(w => ({
|
||||
word: w.word,
|
||||
syllables: dividePalavraEmSilabas(w.word)
|
||||
})) || [];
|
||||
|
||||
// Coletar todas as sílabas únicas
|
||||
const allSyllables = wordList.flatMap(w => w.syllables);
|
||||
const uniqueSyllables = [...new Set(allSyllables)];
|
||||
|
||||
setTargetWords(wordList);
|
||||
setAvailableSyllables(uniqueSyllables);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar palavras:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadWords();
|
||||
}, [storyId]);
|
||||
|
||||
const dividePalavraEmSilabas = (palavra: string): string[] => {
|
||||
// Implementação simplificada - você pode usar uma biblioteca mais robusta
|
||||
// ou implementar regras mais complexas de divisão silábica
|
||||
const silabas: string[] = [];
|
||||
let silaba = '';
|
||||
|
||||
for (let i = 0; i < palavra.length; i++) {
|
||||
const letra = palavra[i];
|
||||
silaba += letra;
|
||||
|
||||
// Regras básicas de divisão silábica
|
||||
if (i < palavra.length - 1) {
|
||||
const proximaLetra = palavra[i + 1];
|
||||
|
||||
// Se a próxima letra for uma vogal e a atual não
|
||||
if (isVogal(proximaLetra) && !isVogal(letra)) {
|
||||
silabas.push(silaba);
|
||||
silaba = '';
|
||||
}
|
||||
// Se a atual for uma vogal e a próxima uma consoante
|
||||
else if (isVogal(letra) && !isVogal(proximaLetra)) {
|
||||
silabas.push(silaba);
|
||||
silaba = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (silaba) {
|
||||
silabas.push(silaba);
|
||||
}
|
||||
|
||||
return silabas;
|
||||
};
|
||||
|
||||
const isVogal = (letra: string): boolean => {
|
||||
return /[aeiouáéíóúâêîôûãõàèìòùäëïöü]/i.test(letra);
|
||||
};
|
||||
|
||||
const handleSyllableClick = (syllable: string) => {
|
||||
setUserWord(prev => prev + syllable);
|
||||
};
|
||||
|
||||
const handleVerify = () => {
|
||||
const matchedWord = targetWords.find(
|
||||
w => w.word.toLowerCase() === userWord.toLowerCase()
|
||||
);
|
||||
|
||||
if (matchedWord && !completedWords.includes(matchedWord.word)) {
|
||||
setScore(prev => prev + 10);
|
||||
setCompletedWords(prev => [...prev, matchedWord.word]);
|
||||
setShowFeedback({
|
||||
type: 'success',
|
||||
message: 'Parabéns! Palavra correta!'
|
||||
});
|
||||
} else if (completedWords.includes(matchedWord?.word || '')) {
|
||||
setShowFeedback({
|
||||
type: 'error',
|
||||
message: 'Você já encontrou esta palavra!'
|
||||
});
|
||||
} else {
|
||||
setShowFeedback({
|
||||
type: 'error',
|
||||
message: 'Tente novamente!'
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setShowFeedback(null);
|
||||
}, 2000);
|
||||
|
||||
setUserWord('');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
|
||||
<div className="h-64 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!availableSyllables.length) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<p className="text-gray-600">
|
||||
Não há palavras para formar neste momento.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Formação de Palavras
|
||||
</h2>
|
||||
|
||||
{/* Barra de Progresso */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||
<span>
|
||||
Palavras encontradas: {completedWords.length} de {targetWords.length}
|
||||
</span>
|
||||
<span>{score} pontos</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 transition-all duration-500"
|
||||
style={{
|
||||
width: `${(completedWords.length / targetWords.length) * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Visual */}
|
||||
{showFeedback && (
|
||||
<div className={`mb-4 p-4 rounded-lg text-center font-medium
|
||||
${showFeedback.type === 'success'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{showFeedback.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sílabas Disponíveis */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Sílabas Disponíveis:
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSyllables.map((syllable, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSyllableClick(syllable)}
|
||||
className="px-4 py-2 bg-purple-100 rounded-lg
|
||||
hover:bg-purple-200 active:bg-purple-300
|
||||
transition-all transform hover:scale-105
|
||||
text-purple-900 font-medium shadow-sm"
|
||||
>
|
||||
{syllable}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palavra do Usuário */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Sua Palavra:
|
||||
</h3>
|
||||
<div className="p-4 bg-gray-50 rounded-lg min-h-[60px] text-xl
|
||||
font-medium text-gray-900 flex items-center justify-center
|
||||
border-2 border-dashed border-gray-300">
|
||||
{userWord || (
|
||||
<span className="text-gray-400">
|
||||
Clique nas sílabas para formar uma palavra
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palavras Encontradas */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Palavras Encontradas:
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{completedWords.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-green-100 text-green-800
|
||||
rounded-full font-medium"
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões de Ação */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setUserWord('')}
|
||||
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg
|
||||
hover:bg-gray-300 active:bg-gray-400 transition-colors flex-1
|
||||
font-medium shadow-sm"
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={!userWord}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg
|
||||
hover:bg-purple-700 active:bg-purple-800 transition-colors flex-1
|
||||
disabled:bg-gray-300 disabled:cursor-not-allowed
|
||||
font-medium shadow-sm"
|
||||
>
|
||||
Verificar Palavra
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/components/learning/ExerciseSuggestions.tsx
Normal file
128
src/components/learning/ExerciseSuggestions.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { BookOpen, Puzzle, Mic } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
interface ExerciseSuggestionsProps {
|
||||
storyId: string;
|
||||
storyText: string;
|
||||
readingMetrics: {
|
||||
difficultWords: string[];
|
||||
errorCount: number;
|
||||
pauseCount: number;
|
||||
fluencyScore: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExerciseWord {
|
||||
word: string;
|
||||
exercise_type: string;
|
||||
phonemes: string[] | null;
|
||||
syllable_pattern: string | null;
|
||||
}
|
||||
|
||||
export function ExerciseSuggestions({ storyId, storyText, readingMetrics }: ExerciseSuggestionsProps) {
|
||||
const navigate = useNavigate();
|
||||
const [exerciseWords, setExerciseWords] = useState<ExerciseWord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadExerciseWords = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('story_exercise_words')
|
||||
.select('*')
|
||||
.eq('story_id', storyId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (!error && data) {
|
||||
setExerciseWords(data);
|
||||
}
|
||||
};
|
||||
|
||||
loadExerciseWords();
|
||||
}, [storyId]);
|
||||
|
||||
const handleExerciseSelect = (exerciseType: string) => {
|
||||
if (!storyId) {
|
||||
console.error('ID da história não fornecido');
|
||||
return;
|
||||
}
|
||||
navigate(`/aluno/historias/${storyId}/exercicios/${exerciseType}`);
|
||||
};
|
||||
|
||||
const generateExercises = () => {
|
||||
const exercises = [
|
||||
{
|
||||
type: 'word-formation',
|
||||
title: 'Formação de Palavras',
|
||||
description: 'Monte novas palavras usando sílabas da história',
|
||||
icon: <Puzzle className="w-6 h-6" />,
|
||||
words: exerciseWords
|
||||
.filter(w => w.exercise_type === 'formation')
|
||||
.map(w => w.word),
|
||||
},
|
||||
{
|
||||
type: 'sentence-completion',
|
||||
title: 'Complete a História',
|
||||
description: 'Complete as frases com as palavras corretas',
|
||||
icon: <BookOpen className="w-6 h-6" />,
|
||||
words: exerciseWords
|
||||
.filter(w => w.exercise_type === 'completion')
|
||||
.map(w => w.word),
|
||||
},
|
||||
{
|
||||
type: 'pronunciation-practice',
|
||||
title: 'Treino de Pronúncia',
|
||||
description: 'Pratique a pronúncia das palavras difíceis',
|
||||
icon: <Mic className="w-6 h-6" />,
|
||||
words: exerciseWords
|
||||
.filter(w => w.exercise_type === 'pronunciation')
|
||||
.map(w => w.word),
|
||||
}
|
||||
];
|
||||
|
||||
return exercises;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Exercícios Sugeridos
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{generateExercises().map((exercise) => (
|
||||
<button
|
||||
key={exercise.type}
|
||||
onClick={() => handleExerciseSelect(exercise.type)}
|
||||
className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-500 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="text-purple-600">
|
||||
{exercise.icon}
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900">
|
||||
{exercise.title}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
{exercise.description}
|
||||
</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
{exercise.type === 'word-formation' && (
|
||||
<div>
|
||||
Palavras para praticar:
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{exercise.words.map(word => (
|
||||
<span key={word} className="bg-purple-100 px-2 py-1 rounded">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
src/pages/student-dashboard/ExercisePage.tsx
Normal file
163
src/pages/student-dashboard/ExercisePage.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { WordFormation } from '../../components/exercises/WordFormation';
|
||||
import { SentenceCompletion } from '../../components/exercises/SentenceCompletion';
|
||||
import { PronunciationPractice } from '../../components/exercises/PronunciationPractice';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface ExerciseWord {
|
||||
word: string;
|
||||
exercise_type: string;
|
||||
phonemes: string[] | null;
|
||||
syllable_pattern: string | null;
|
||||
}
|
||||
|
||||
export function ExercisePage() {
|
||||
const { id: storyId, type } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [exerciseData, setExerciseData] = React.useState<any>(null);
|
||||
const [exerciseWords, setExerciseWords] = React.useState<ExerciseWord[]>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadExerciseData = async () => {
|
||||
try {
|
||||
// Adicionar log para debug
|
||||
console.log('Carregando exercício:', { storyId, type });
|
||||
|
||||
// Buscar história
|
||||
const { data: story, error: storyError } = await supabase
|
||||
.from('stories')
|
||||
.select(`
|
||||
*,
|
||||
story_recordings (
|
||||
id,
|
||||
improvements,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq('id', storyId)
|
||||
.single();
|
||||
|
||||
if (storyError) throw storyError;
|
||||
|
||||
// Mapear tipo do exercício para o valor correto no banco
|
||||
const exerciseType = type === 'pronunciation-practice'
|
||||
? 'pronunciation'
|
||||
: type === 'word-formation'
|
||||
? 'formation'
|
||||
: 'completion';
|
||||
|
||||
// Buscar palavras do exercício
|
||||
const { data: words, error: wordsError } = await supabase
|
||||
.from('story_exercise_words')
|
||||
.select('*')
|
||||
.eq('story_id', storyId)
|
||||
.eq('exercise_type', exerciseType) // Usando o tipo mapeado
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
// Adicionar log para debug
|
||||
console.log('Palavras encontradas:', words);
|
||||
|
||||
if (wordsError) throw wordsError;
|
||||
|
||||
if (!story) {
|
||||
throw new Error('História não encontrada');
|
||||
}
|
||||
|
||||
// Ordenar gravações por data e pegar a mais recente
|
||||
const latestRecording = story.story_recordings
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
|
||||
|
||||
setExerciseData({
|
||||
story,
|
||||
metrics: latestRecording,
|
||||
});
|
||||
|
||||
setExerciseWords(words || []);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar dados:', err);
|
||||
setError('Não foi possível carregar os dados do exercício');
|
||||
}
|
||||
};
|
||||
|
||||
loadExerciseData();
|
||||
}, [storyId, type]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Voltar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!exerciseData) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6" />
|
||||
<div className="h-64 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderExercise = () => {
|
||||
if (!exerciseData?.story) {
|
||||
return <div>Carregando...</div>;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'word-formation':
|
||||
return (
|
||||
<WordFormation
|
||||
words={exerciseWords.map(w => w.word)}
|
||||
storyId={storyId as string}
|
||||
/>
|
||||
);
|
||||
case 'sentence-completion':
|
||||
return (
|
||||
<SentenceCompletion
|
||||
story={exerciseData.story}
|
||||
/>
|
||||
);
|
||||
case 'pronunciation-practice':
|
||||
return (
|
||||
<PronunciationPractice
|
||||
words={exerciseWords.map(w => w.word)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div>Exercício não encontrado</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
{/* Cabeçalho */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Voltar para história
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Exercício */}
|
||||
{renderExercise()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import type { MetricsData } from '../../components/story/StoryMetrics';
|
||||
import { getOptimizedImageUrl } from '../../lib/imageUtils';
|
||||
import { convertWebmToMp3 } from '../../utils/audioConverter';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { ExerciseSuggestions } from '../../components/learning/ExerciseSuggestions';
|
||||
|
||||
interface StoryRecording {
|
||||
id: string;
|
||||
@ -775,6 +776,23 @@ export function StoryPage() {
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
|
||||
{/* Depois do StoryMetrics */}
|
||||
{recordings.length > 0 && (
|
||||
<ExerciseSuggestions
|
||||
storyId={story.id}
|
||||
storyText={story.content.pages[currentPage].text}
|
||||
readingMetrics={{
|
||||
difficultWords: getLatestRecording().improvements
|
||||
.filter(imp => imp.includes('palavra'))
|
||||
.map(imp => imp.match(/palavra "([^"]+)"/)?.[1])
|
||||
.filter(Boolean) as string[],
|
||||
errorCount: getLatestRecording().error_count,
|
||||
pauseCount: getLatestRecording().pause_count,
|
||||
fluencyScore: getLatestRecording().fluency_score
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -29,6 +29,7 @@ import { DemoPage } from './pages/demo/DemoPage';
|
||||
import { ParentsLandingPage } from './pages/landing/ParentsLandingPage';
|
||||
import { EducationalForParents } from './pages/landing/EducationalForParents';
|
||||
import { TestWordHighlighter } from './pages/TestWordHighlighter';
|
||||
import { ExercisePage } from './pages/student-dashboard/ExercisePage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@ -197,5 +198,9 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/para-educadores',
|
||||
element: <EducationalForParents />,
|
||||
},
|
||||
{
|
||||
path: '/aluno/historias/:id/exercicios/:type',
|
||||
element: <ExercisePage />
|
||||
}
|
||||
]);
|
||||
@ -12,6 +12,7 @@ interface StoryPrompt {
|
||||
character_id: string;
|
||||
setting_id: string;
|
||||
context?: string;
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
}
|
||||
|
||||
const ALLOWED_ORIGINS = [
|
||||
@ -29,6 +30,28 @@ function getOptimizedImageUrl(originalUrl: string, width = 800): string {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
interface StoryResponse {
|
||||
title: string;
|
||||
content: {
|
||||
pages: Array<{
|
||||
text: string;
|
||||
imagePrompt: string;
|
||||
keywords?: string[];
|
||||
phonemes?: string[];
|
||||
syllablePatterns?: string[];
|
||||
}>;
|
||||
};
|
||||
metadata?: {
|
||||
targetAge?: number;
|
||||
difficulty?: string;
|
||||
exerciseWords?: {
|
||||
pronunciation?: string[];
|
||||
formation?: string[];
|
||||
completion?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
const origin = req.headers.get('origin') || '';
|
||||
const corsHeaders = {
|
||||
@ -106,15 +129,46 @@ serve(async (req) => {
|
||||
- Ambientado em ${setting.title}
|
||||
- Personagem principal baseado em ${character.title}
|
||||
|
||||
Requisitos específicos para exercícios:
|
||||
1. Para o exercício de completar frases:
|
||||
- Selecione 5-8 palavras importantes do texto
|
||||
- Escolha palavras que sejam substantivos, verbos ou adjetivos
|
||||
- Evite artigos, preposições ou palavras muito simples
|
||||
- As palavras devem ser relevantes para o contexto da história
|
||||
|
||||
2. Para o exercício de formação de palavras:
|
||||
- Selecione palavras com diferentes padrões silábicos
|
||||
- Inclua palavras que possam ser divididas em sílabas
|
||||
- Priorize palavras com 2-4 sílabas
|
||||
|
||||
3. Para o exercício de pronúncia:
|
||||
- Selecione palavras que trabalhem diferentes fonemas
|
||||
- Inclua palavras com encontros consonantais
|
||||
- Priorize palavras que sejam desafiadoras para a faixa etária
|
||||
|
||||
Formato da resposta:
|
||||
{
|
||||
"title": "Título da História",
|
||||
"pages": [
|
||||
{
|
||||
"text": "Texto da página",
|
||||
"image_prompt": "Descrição para gerar a imagem"
|
||||
"content": {
|
||||
"pages": [
|
||||
{
|
||||
"text": "Texto da página com frases completas...",
|
||||
"imagePrompt": "Descrição para gerar imagem...",
|
||||
"keywords": ["palavra1", "palavra2"],
|
||||
"phonemes": ["fonema1", "fonema2"],
|
||||
"syllablePatterns": ["CV", "CVC", "CCVC"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"targetAge": number,
|
||||
"difficulty": string,
|
||||
"exerciseWords": {
|
||||
"pronunciation": ["palavra1", "palavra2"],
|
||||
"formation": ["palavra3", "palavra4"],
|
||||
"completion": ["palavra5", "palavra6"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
console.log('[GPT] Prompt construído:', prompt)
|
||||
@ -137,25 +191,25 @@ serve(async (req) => {
|
||||
})
|
||||
|
||||
console.log('[GPT] História gerada:', completion.choices[0].message)
|
||||
const storyContent = JSON.parse(completion.choices[0].message.content || '{}')
|
||||
const storyContent = JSON.parse(completion.choices[0].message.content || '{}') as StoryResponse;
|
||||
|
||||
// Validar estrutura do retorno da IA
|
||||
if (!storyContent.title || !Array.isArray(storyContent.pages)) {
|
||||
if (!storyContent.title || !storyContent.content?.pages?.length) {
|
||||
console.error('Resposta da IA:', storyContent);
|
||||
throw new Error('Formato inválido retornado pela IA');
|
||||
}
|
||||
|
||||
console.log('[DALL-E] Iniciando geração de imagens...')
|
||||
const pages = await Promise.all(
|
||||
storyContent.pages.map(async (page: any, index: number) => {
|
||||
console.log(`[DALL-E] Gerando imagem ${index + 1}/${storyContent.pages.length}...`)
|
||||
storyContent.content.pages.map(async (page, index) => {
|
||||
console.log(`[DALL-E] Gerando imagem ${index + 1}/${storyContent.content.pages.length}...`)
|
||||
|
||||
// Gerar imagem com DALL-E
|
||||
const imageResponse = await openai.images.generate({
|
||||
prompt: `${page.image_prompt}. Style: children's book illustration, colorful, educational, safe for kids.`,
|
||||
prompt: `${page.imagePrompt}. For Kids. Educational Purpose. Style inpired by DreamWorks and Pixar. Provide a high quality image. Provide a ethnic diversity.`,
|
||||
n: 1,
|
||||
size: "1024x1024",
|
||||
model: "dall-e-3",
|
||||
style: "natural"
|
||||
model: "dall-e-3"
|
||||
})
|
||||
|
||||
// Baixar a imagem do URL do DALL-E
|
||||
@ -244,7 +298,7 @@ serve(async (req) => {
|
||||
story_id: record.id,
|
||||
original_prompt: prompt,
|
||||
ai_response: completion.choices[0].message.content,
|
||||
model_used: 'gpt-4-turbo'
|
||||
model_used: 'gpt-4o-mini'
|
||||
});
|
||||
|
||||
if (genError) throw new Error(`Erro ao salvar metadados: ${genError.message}`);
|
||||
@ -261,6 +315,39 @@ serve(async (req) => {
|
||||
|
||||
if (fetchError) throw new Error(`Erro ao buscar história completa: ${fetchError.message}`);
|
||||
|
||||
// Salvar palavras dos exercícios se existirem
|
||||
if (storyContent.metadata?.exerciseWords) {
|
||||
const exerciseWords = Object.entries(storyContent.metadata.exerciseWords)
|
||||
.flatMap(([type, words]) =>
|
||||
(words || []).map(word => {
|
||||
const pageWithWord = storyContent.content.pages
|
||||
.find(p => p.text.toLowerCase().includes(word.toLowerCase()));
|
||||
|
||||
// Só incluir palavras que realmente existem no texto
|
||||
if (!pageWithWord) return null;
|
||||
|
||||
return {
|
||||
story_id: record.id,
|
||||
word,
|
||||
exercise_type: type,
|
||||
phonemes: pageWithWord?.phonemes || null,
|
||||
syllable_pattern: pageWithWord?.syllablePatterns?.[0] || null
|
||||
};
|
||||
})
|
||||
)
|
||||
.filter(Boolean); // Remove null values
|
||||
|
||||
if (exerciseWords.length > 0) {
|
||||
const { error: wordsError } = await supabase
|
||||
.from('story_exercise_words')
|
||||
.insert(exerciseWords);
|
||||
|
||||
if (wordsError) {
|
||||
console.error('Erro ao salvar palavras dos exercícios:', wordsError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user