mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-19 14:57:51 +00:00
Compare commits
No commits in common. "634fa6fb480a29720217fab33c2ff5a71bfb7676" and "745f8de40ec48dd181396838c61b3a318549d526" have entirely different histories.
634fa6fb48
...
745f8de40e
35
CHANGELOG.md
35
CHANGELOG.md
@ -48,38 +48,3 @@
|
|||||||
### 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
|
|
||||||
- Corrigidos erros de tipagem no ExercisePage
|
|
||||||
- Adicionadas interfaces para Story e StoryRecording
|
|
||||||
- Melhorada type safety no acesso aos dados
|
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,267 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoryRecording {
|
|
||||||
id: string;
|
|
||||||
created_at: string;
|
|
||||||
improvements: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Story {
|
|
||||||
id: string;
|
|
||||||
story_recordings: StoryRecording[];
|
|
||||||
// ... outros campos necessários
|
|
||||||
}
|
|
||||||
|
|
||||||
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 as Story).story_recordings
|
|
||||||
.sort((a: StoryRecording, b: StoryRecording) =>
|
|
||||||
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,7 +9,6 @@ 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;
|
||||||
@ -776,23 +775,6 @@ 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,7 +29,6 @@ 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([
|
||||||
{
|
{
|
||||||
@ -198,9 +197,5 @@ export const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: '/para-educadores',
|
path: '/para-educadores',
|
||||||
element: <EducationalForParents />,
|
element: <EducationalForParents />,
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/aluno/historias/:id/exercicios/:type',
|
|
||||||
element: <ExercisePage />
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@ -12,7 +12,6 @@ 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 = [
|
||||||
@ -30,28 +29,6 @@ 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 = {
|
||||||
@ -129,46 +106,15 @@ 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",
|
||||||
"content": {
|
"pages": [
|
||||||
"pages": [
|
{
|
||||||
{
|
"text": "Texto da página",
|
||||||
"text": "Texto da página com frases completas...",
|
"image_prompt": "Descrição para gerar a imagem"
|
||||||
"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)
|
||||||
@ -191,25 +137,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 || '{}') as StoryResponse;
|
const storyContent = JSON.parse(completion.choices[0].message.content || '{}')
|
||||||
|
|
||||||
// Validar estrutura do retorno da IA
|
// Validar estrutura do retorno da IA
|
||||||
if (!storyContent.title || !storyContent.content?.pages?.length) {
|
if (!storyContent.title || !Array.isArray(storyContent.pages)) {
|
||||||
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.content.pages.map(async (page, index) => {
|
storyContent.pages.map(async (page: any, index: number) => {
|
||||||
console.log(`[DALL-E] Gerando imagem ${index + 1}/${storyContent.content.pages.length}...`)
|
console.log(`[DALL-E] Gerando imagem ${index + 1}/${storyContent.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.imagePrompt}. For Kids. Educational Purpose. Style inpired by DreamWorks and Pixar. Provide a high quality image. Provide a ethnic diversity.`,
|
prompt: `${page.image_prompt}. Style: children's book illustration, colorful, educational, safe for kids.`,
|
||||||
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
|
||||||
@ -298,7 +244,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-4o-mini'
|
model_used: 'gpt-4-turbo'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (genError) throw new Error(`Erro ao salvar metadados: ${genError.message}`);
|
if (genError) throw new Error(`Erro ao salvar metadados: ${genError.message}`);
|
||||||
@ -315,39 +261,6 @@ 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