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
|
### Modificado
|
||||||
- Aprimorado fluxo de exclusão de histórias para garantir remoção completa de recursos
|
- Aprimorado fluxo de exclusão de histórias para garantir remoção completa de recursos
|
||||||
- Adicionada confirmação visual durante processo de deleção
|
- 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 { getOptimizedImageUrl } from '../../lib/imageUtils';
|
||||||
import { convertWebmToMp3 } from '../../utils/audioConverter';
|
import { convertWebmToMp3 } from '../../utils/audioConverter';
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import { ExerciseSuggestions } from '../../components/learning/ExerciseSuggestions';
|
||||||
|
|
||||||
interface StoryRecording {
|
interface StoryRecording {
|
||||||
id: string;
|
id: string;
|
||||||
@ -775,6 +776,23 @@ export function StoryPage() {
|
|||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -29,6 +29,7 @@ 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';
|
import { TestWordHighlighter } from './pages/TestWordHighlighter';
|
||||||
|
import { ExercisePage } from './pages/student-dashboard/ExercisePage';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -197,5 +198,9 @@ export const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: '/para-educadores',
|
path: '/para-educadores',
|
||||||
element: <EducationalForParents />,
|
element: <EducationalForParents />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/aluno/historias/:id/exercicios/:type',
|
||||||
|
element: <ExercisePage />
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@ -12,6 +12,7 @@ interface StoryPrompt {
|
|||||||
character_id: string;
|
character_id: string;
|
||||||
setting_id: string;
|
setting_id: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = [
|
const ALLOWED_ORIGINS = [
|
||||||
@ -29,6 +30,28 @@ function getOptimizedImageUrl(originalUrl: string, width = 800): string {
|
|||||||
return originalUrl;
|
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) => {
|
serve(async (req) => {
|
||||||
const origin = req.headers.get('origin') || '';
|
const origin = req.headers.get('origin') || '';
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
@ -106,15 +129,46 @@ serve(async (req) => {
|
|||||||
- Ambientado em ${setting.title}
|
- Ambientado em ${setting.title}
|
||||||
- Personagem principal baseado em ${character.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:
|
Formato da resposta:
|
||||||
{
|
{
|
||||||
"title": "Título da História",
|
"title": "Título da História",
|
||||||
"pages": [
|
"content": {
|
||||||
{
|
"pages": [
|
||||||
"text": "Texto da página",
|
{
|
||||||
"image_prompt": "Descrição para gerar a imagem"
|
"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)
|
console.log('[GPT] Prompt construído:', prompt)
|
||||||
@ -137,25 +191,25 @@ serve(async (req) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
console.log('[GPT] História gerada:', completion.choices[0].message)
|
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
|
// 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');
|
throw new Error('Formato inválido retornado pela IA');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[DALL-E] Iniciando geração de imagens...')
|
console.log('[DALL-E] Iniciando geração de imagens...')
|
||||||
const pages = await Promise.all(
|
const pages = await Promise.all(
|
||||||
storyContent.pages.map(async (page: any, index: number) => {
|
storyContent.content.pages.map(async (page, index) => {
|
||||||
console.log(`[DALL-E] Gerando imagem ${index + 1}/${storyContent.pages.length}...`)
|
console.log(`[DALL-E] Gerando imagem ${index + 1}/${storyContent.content.pages.length}...`)
|
||||||
|
|
||||||
// Gerar imagem com DALL-E
|
// Gerar imagem com DALL-E
|
||||||
const imageResponse = await openai.images.generate({
|
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,
|
n: 1,
|
||||||
size: "1024x1024",
|
size: "1024x1024",
|
||||||
model: "dall-e-3",
|
model: "dall-e-3"
|
||||||
style: "natural"
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Baixar a imagem do URL do DALL-E
|
// Baixar a imagem do URL do DALL-E
|
||||||
@ -244,7 +298,7 @@ serve(async (req) => {
|
|||||||
story_id: record.id,
|
story_id: record.id,
|
||||||
original_prompt: prompt,
|
original_prompt: prompt,
|
||||||
ai_response: completion.choices[0].message.content,
|
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}`);
|
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}`);
|
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(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user