Compare commits

..

No commits in common. "94835a427b56cb705da96c0006ef3996123628bd" and "90506ca894cf5f403ca6438698383fc5a87c6877" have entirely different histories.

18 changed files with 281 additions and 1487 deletions

View File

@ -58,78 +58,19 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
### Removido
- N/A (primeira versão)
## [1.1.1] - 2024-05-21
## [1.1.0] - 2024-05-20
### Adicionado
- Componente `TextCaseToggle` para alternar entre maiúsculas e minúsculas
- Componente `AdaptiveText` para renderização adaptativa de texto
- Hook `useUppercasePreference` para gerenciar preferências de texto
- Suporte a texto adaptativo em exercícios fônicos
- Suporte a texto maiúsculo para alfabetização infantil
- Componente de alternância de caixa de texto
- Sistema de persistência de preferências
- Destaque silábico interativo para apoio à decodificação
### Modificado
- Atualização do layout do dashboard para incluir controle de texto
- Integração do sistema de texto adaptativo em componentes existentes
- Melhorias na acessibilidade dos componentes de texto
- Todas as páginas principais para usar texto adaptativo
- Componentes de exercícios para suportar transformação de texto
### Técnico
- Refatoração dos componentes de texto para suportar transformação dinâmica
- Otimização do sistema de preferências do usuário
- Melhorias na performance de renderização de texto
## [1.1.0] - 2024-03-21
### Adicionado
- Novo recurso "Modo Foco" para melhorar a experiência de leitura
- Ativação automática ao iniciar gravação
- Desativação automática ao parar gravação
- Interface adaptativa com foco no texto
- Controles de acessibilidade (tamanho da fonte, espaçamento)
- Destaque automático de palavras durante a leitura
### Técnico
- Integração entre componentes `AudioRecorder` e `StoryPage` para gerenciamento do Modo Foco
- Adição de novos props no componente `AudioRecorder`:
- `onFocusModeToggle`
- `focusModeActive`
- `onRecordingStart`
- `onRecordingStop`
- Otimização de código com remoção de variáveis não utilizadas
### Modificado
- Atualizado o componente `AudioRecorder` para incluir tipagem correta e melhor gerenciamento de estado
- Corrigido o gerenciamento de gravações no `StoryPage` com inicialização adequada de métricas
- Melhorado o tratamento de erros e feedback do usuário durante a gravação
- Otimizado o fluxo de upload e processamento de áudio
### Técnico
- Adicionada interface `StoryRecording` com todas as propriedades necessárias
- Corrigido tipo do callback `onAudioUploaded` no `AudioRecorder`
- Removidos imports não utilizados e variáveis redundantes
- Implementada lógica de fallback para usuários não autenticados
### Adicionado
- Suporte para conversão de áudio WebM para MP3
- Feedback visual durante o processamento do áudio
- Inicialização de métricas zeradas para novas gravações
## [1.2.0] - 2024-03-21
### Adicionado
- Novo Modo Foco para leitura e gravação
- Estilos específicos para o Modo Foco
- Timer de gravação no Modo Foco
- Transições suaves entre modos
- Controles flutuantes durante o Modo Foco
### Modificado
- Componente AudioRecorder atualizado para suportar Modo Foco
- Interface do StoryPage reorganizada para Modo Foco
- Comportamento de gravação integrado com Modo Foco
- Melhorias na experiência do usuário durante a leitura
### Técnico
- Novo arquivo CSS para estilos do Modo Foco
- Interface FocusMode para gerenciamento de estado
- Callbacks de início e fim de gravação
- Sistema de transição entre modos normal e foco
- Otimização de performance para transições suaves
- Nova coluna na tabela students
- Hook para gerenciamento de estado
- Otimizações de performance

View File

@ -1,25 +0,0 @@
## Geração por Voz
### Como usar:
1. Clique no ícone de microfone
2. Fale sua descrição por 15-120 segundos
3. Confira a transcrição
4. Ajuste se necessário
5. Envie para gerar a história
### Requisitos:
- Navegador moderno (Chrome, Edge, Safari 14+)
- Microfone habilitado
- Conexão estável
## Segurança
- Gravações temporárias são excluídas após 1h
- Transcrições são validadas contra conteúdo sensível
- Dados de áudio não são armazenados permanentemente
## Limitações Conhecidas
- Acento pode afetar precisão da transcrição
- Ruído ambiente pode interferir na qualidade
- Suporte limitado a sotaques regionais

View File

@ -1,129 +1,46 @@
import React, { useState, useEffect } from 'react';
import { ChevronUp, ChevronDown, Play, Pause } from 'lucide-react';
interface WordHighlighterProps {
text: string; // Texto completo
highlightedWords: string[]; // Palavras para destacar (ex: palavras difíceis)
difficultWords: string[]; // Palavras que o aluno teve dificuldade
onWordClick: (word: string) => void; // Função para quando clicar na palavra
highlightSpeed?: number; // palavras por minuto
initialFontSize?: number;
}
export function WordHighlighter({
text,
highlightedWords,
difficultWords,
onWordClick,
highlightSpeed = 60,
initialFontSize = 18
onWordClick
}: WordHighlighterProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [currentWordIndex, setCurrentWordIndex] = useState(0);
const [fontSize, setFontSize] = useState(initialFontSize);
// Divide o texto em palavras mantendo a pontuação
const words = text.split(/(\s+)/).filter(word => word.trim().length > 0);
useEffect(() => {
if (!isPlaying) return;
const intervalTime = (60 / highlightSpeed) * 1000;
const interval = setInterval(() => {
setCurrentWordIndex((prevIndex) => {
if (prevIndex >= words.length - 1) {
setIsPlaying(false);
return prevIndex;
}
return prevIndex + 1;
});
}, intervalTime);
return () => clearInterval(interval);
}, [isPlaying, highlightSpeed, words.length]);
const handleFontSizeChange = (delta: number) => {
setFontSize(prev => Math.min(Math.max(12, prev + delta), 32));
};
const togglePlayPause = () => {
if (!isPlaying) {
setCurrentWordIndex(0);
}
setIsPlaying(!isPlaying);
};
return (
<div className="space-y-4">
{/* Controles */}
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<button
onClick={() => handleFontSizeChange(-2)}
className="p-2 rounded-lg hover:bg-gray-100"
aria-label="Diminuir fonte"
<div className="leading-relaxed text-lg space-y-4">
{words.map((word, i) => {
// Remove pontuação para comparação
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/, '');
// Determina o estilo baseado no tipo da palavra
const isHighlighted = highlightedWords.includes(cleanWord);
const isDifficult = difficultWords.includes(cleanWord);
return (
<span
key={i}
onClick={() => onWordClick(word)}
className={`
inline-block mx-1 px-1 rounded cursor-pointer transition-all
hover:scale-110
${isHighlighted ? 'bg-yellow-200 hover:bg-yellow-300' : ''}
${isDifficult ? 'bg-red-100 hover:bg-red-200' : ''}
hover:bg-gray-100
`}
title="Clique para ver mais informações"
>
<ChevronDown className="h-5 w-5" />
</button>
<span className="text-sm font-medium">{fontSize}px</span>
<button
onClick={() => handleFontSizeChange(2)}
className="p-2 rounded-lg hover:bg-gray-100"
aria-label="Aumentar fonte"
>
<ChevronUp className="h-5 w-5" />
</button>
</div>
<button
onClick={togglePlayPause}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-100 text-purple-700 hover:bg-purple-200"
>
{isPlaying ? (
<>
<Pause className="h-4 w-4" />
Pausar Leitura
</>
) : (
<>
<Play className="h-4 w-4" />
Iniciar Leitura
</>
)}
</button>
</div>
{/* Texto */}
<div
className="leading-relaxed space-y-4"
style={{ fontSize: `${fontSize}px` }}
>
{words.map((word, i) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/, '');
const isHighlighted = highlightedWords.includes(cleanWord);
const isDifficult = difficultWords.includes(cleanWord);
const isCurrentWord = i === currentWordIndex && isPlaying;
return (
<span
key={i}
onClick={() => onWordClick(word)}
className={`
inline-block mx-1 px-1 rounded cursor-pointer transition-all
hover:scale-110
${isHighlighted ? 'bg-yellow-200 hover:bg-yellow-300' : ''}
${isDifficult ? 'bg-red-100 hover:bg-red-200' : ''}
${isCurrentWord ? 'bg-purple-200 scale-110' : ''}
hover:bg-gray-100
`}
title="Clique para ver mais informações"
>
{word}
</span>
);
})}
</div>
{word}
</span>
);
})}
</div>
);
}

View File

@ -2,27 +2,15 @@ import React, { useState, useRef } from 'react';
import { Mic, Square, Loader, Play, Upload } from 'lucide-react';
import { supabase } from '../../lib/supabase';
import { v4 as uuidv4 } from 'uuid';
import { cn } from '../../lib/utils';
import { useStudentTracking } from '../../hooks/useStudentTracking';
interface AudioRecorderProps {
storyId: string;
studentId: string;
onAudioUploaded: (audioUrl: string) => void;
onRecordingStart?: () => void;
onRecordingStop?: () => void;
focusModeActive?: boolean;
onFocusModeToggle?: () => void;
}
export function AudioRecorder({
storyId,
studentId,
onAudioUploaded,
onRecordingStart,
onRecordingStop,
focusModeActive = false,
onFocusModeToggle
}: AudioRecorderProps) {
export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioRecorderProps) {
const [isRecording, setIsRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [isUploading, setIsUploading] = useState(false);
@ -30,14 +18,11 @@ export function AudioRecorder({
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const { trackAudioRecorded } = useStudentTracking();
const startTime = React.useRef<number | null>(null);
const startRecording = async () => {
try {
if (!focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream);
chunksRef.current = [];
@ -49,14 +34,12 @@ export function AudioRecorder({
mediaRecorderRef.current.onstop = () => {
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob);
onRecordingStop?.();
};
mediaRecorderRef.current.start();
startTime.current = Date.now();
setIsRecording(true);
setError(null);
onRecordingStart?.();
} catch (err) {
setError('Erro ao acessar microfone. Verifique as permissões.');
console.error('Erro ao iniciar gravação:', err);
@ -68,12 +51,8 @@ export function AudioRecorder({
mediaRecorderRef.current.stop();
setIsRecording(false);
// Parar todas as tracks do stream
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
// Desativar modo foco ao parar a gravação
if (focusModeActive && onFocusModeToggle) {
onFocusModeToggle();
}
}
};
@ -133,7 +112,7 @@ export function AudioRecorder({
.getPublicUrl(filePath);
// 3. Criar o registro com a URL do áudio
const { error: recordError } = await supabase
const { data: recordData, error: recordError } = await supabase
.from('story_recordings')
.insert({
id: fileId,
@ -173,23 +152,15 @@ export function AudioRecorder({
};
return (
<div className={cn(
"",
focusModeActive && "bg-purple-50"
)}>
<div className="p-4 bg-white rounded-lg shadow">
<div className="flex items-center gap-4 mb-4">
{!isRecording && !audioBlob && (
<button
onClick={startRecording}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg transition",
focusModeActive
? "bg-purple-600 text-white hover:bg-purple-700"
: "bg-red-600 text-white hover:bg-red-700"
)}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
>
<Mic className="w-5 h-5" />
{focusModeActive ? "Iniciar Leitura" : "Iniciar Gravação"}
Iniciar Gravação
</button>
)}

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { useSession } from '../../hooks/useSession';
import { useStoryCategories } from '../../hooks/useStoryCategories';
import { Wand2, ArrowLeft } from 'lucide-react';
import { Wand2, ArrowLeft, ArrowRight } from 'lucide-react';
import { useStudentTracking } from '../../hooks/useStudentTracking';
interface Category {
@ -21,7 +21,7 @@ interface StoryStep {
isContextStep?: boolean;
}
export interface StoryChoices {
interface StoryChoices {
theme_id: string | null;
subject_id: string | null;
character_id: string | null;
@ -29,35 +29,26 @@ export interface StoryChoices {
context?: string;
}
interface StoryGeneratorProps {
initialContext?: string;
onContextChange: (context: string) => void;
inputMode: 'voice' | 'form';
voiceTranscript: string;
isGenerating: boolean;
setIsGenerating: (value: boolean) => void;
step: number;
setStep: (value: number | ((prev: number) => number)) => void;
choices: StoryChoices;
setChoices: React.Dispatch<React.SetStateAction<StoryChoices>>;
}
export function StoryGenerator({
initialContext = '',
onContextChange,
inputMode,
voiceTranscript,
isGenerating,
setIsGenerating,
step,
setStep,
choices,
setChoices
}: StoryGeneratorProps) {
// 1. Obter dados da API
export function StoryGenerator() {
const navigate = useNavigate();
const { session } = useSession();
const { themes, subjects, characters, settings, isLoading } = useStoryCategories();
const [step, setStep] = React.useState(1);
const [choices, setChoices] = React.useState<StoryChoices>({
theme_id: null,
subject_id: null,
character_id: null,
setting_id: null,
context: ''
});
const [isGenerating, setIsGenerating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [generationStatus, setGenerationStatus] = React.useState<
'idle' | 'creating' | 'generating-images' | 'saving'
>('idle');
const { trackStoryGenerated } = useStudentTracking();
const startTime = React.useRef(Date.now());
// 2. Definir steps com os dados obtidos
const steps: StoryStep[] = [
{
title: 'Escolha o Tema',
@ -80,65 +71,32 @@ export function StoryGenerator({
key: 'setting_id'
},
{
title: 'Contexto da História (Opcional)',
title: 'Adicione um Contexto (Opcional)',
isContextStep: true
}
];
// 3. useEffect que depende dos dados
React.useEffect(() => {
if (inputMode === 'voice' && voiceTranscript && themes) {
setStep(steps.length);
setChoices(prev => ({
...prev,
theme_id: 'auto',
subject_id: 'auto',
character_id: 'auto',
setting_id: 'auto'
}));
}
}, [inputMode, voiceTranscript, steps.length, themes, setStep, setChoices]);
React.useEffect(() => {
setChoices(prev => ({
...prev,
context: inputMode === 'voice' ? voiceTranscript : initialContext
}));
}, [voiceTranscript, initialContext, inputMode]);
const handleContextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onContextChange(e.target.value);
};
const navigate = useNavigate();
const { session } = useSession();
const [error, setError] = React.useState<string | null>(null);
const [generationStatus, setGenerationStatus] = React.useState<
'idle' | 'creating' | 'generating-images' | 'saving'
>('idle');
const { trackStoryGenerated } = useStudentTracking();
const startTime = React.useRef(Date.now());
const currentStep = steps[step - 1];
const isLastStep = step === steps.length;
const handleSelect = (key: keyof StoryChoices, value: string) => {
setChoices(prev => ({ ...prev, [key]: value }));
if (step < steps.length) {
setStep((prev: number) => prev + 1);
setStep(prev => prev + 1);
}
};
const handleContextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setChoices(prev => ({ ...prev, context: event.target.value }));
};
const handleBack = () => {
if (step > 1) {
setStep(prev => prev - 1);
}
};
const handleGenerate = async () => {
// Validação apenas para modo voz
if (inputMode === 'voice' && !voiceTranscript) {
setError('Grave uma descrição por voz antes de enviar');
return;
}
// Contexto é opcional no formulário
const finalContext = inputMode === 'voice' ? voiceTranscript : initialContext;
if (!session?.user?.id) return;
if (!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id) {
@ -160,7 +118,7 @@ export function StoryGenerator({
subject_id: choices.subject_id,
character_id: choices.character_id,
setting_id: choices.setting_id,
context: finalContext,
context: choices.context || null,
status: 'draft',
content: {
prompt: choices,
@ -184,7 +142,7 @@ export function StoryGenerator({
subject: selectedSubject,
character: selectedCharacter,
setting: selectedSetting,
context: finalContext,
context: choices.context,
generation_time: Date.now() - startTime.current,
word_count: 0, // será atualizado após a geração
student_id: session.user.id,
@ -282,10 +240,10 @@ export function StoryGenerator({
{currentStep.isContextStep ? (
<div className="space-y-4">
<textarea
value={initialContext}
value={choices.context}
onChange={handleContextChange}
className="w-full p-3 border rounded-lg"
placeholder="Descreva sua história... (opcional)"
placeholder="Adicione detalhes ou ideias específicas para sua história..."
className="w-full h-32 p-4 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
/>
</div>
) : (
@ -318,16 +276,10 @@ export function StoryGenerator({
</div>
)}
{choices.theme_id === 'auto' && (
<div className="mb-4 p-3 bg-blue-50 text-blue-600 rounded-lg">
Configurações automáticas selecionadas com base na descrição por voz
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-6">
<button
onClick={() => setStep((prev: number) => prev - 1)}
onClick={handleBack}
disabled={step === 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 disabled:opacity-50"
>

View File

@ -1,7 +1,6 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { SyllableHighlighter } from '../../features/syllables/components/SyllableHighlighter';
import { formatTextWithSyllables } from '../../features/syllables/utils/syllableSplitter';
interface AdaptiveTextProps extends React.HTMLAttributes<HTMLSpanElement> {
text: string;
@ -20,8 +19,11 @@ export const AdaptiveText = React.memo(({
className,
...props
}: AdaptiveTextProps) => {
const formattedText = formatTextWithSyllables(text, highlightSyllables);
const finalText = isUpperCase ? formattedText.toUpperCase() : formattedText;
// Transformar o texto mantendo espaços em branco se necessário
const transformedText = React.useMemo(() => {
const transformed = isUpperCase ? text.toUpperCase() : text;
return preserveWhitespace ? transformed : transformed.trim();
}, [text, isUpperCase, preserveWhitespace]);
return React.createElement(
Component,
@ -32,7 +34,9 @@ export const AdaptiveText = React.memo(({
),
...props
},
finalText
highlightSyllables ? (
<SyllableHighlighter text={transformedText} />
) : transformedText
);
});

View File

@ -1,250 +0,0 @@
import React from 'react';
import {
Type,
ChevronUp,
ChevronDown,
Play,
Pause,
Timer,
ArrowLeftRight,
MoveVertical
} from 'lucide-react';
import { cn } from '../../lib/utils';
interface TextControlsProps {
isUpperCase: boolean;
onToggleUpperCase: () => void;
isSyllablesEnabled: boolean;
onToggleSyllables: () => void;
fontSize: number;
onFontSizeChange: (size: number) => void;
readingSpeed: number;
onReadingSpeedChange: (speed: number) => void;
letterSpacing: number;
onLetterSpacingChange: (spacing: number) => void;
wordSpacing: number;
onWordSpacingChange: (spacing: number) => void;
lineHeight: number;
onLineHeightChange: (height: number) => void;
isLoading?: boolean;
className?: string;
isHighlighting?: boolean;
onToggleHighlight?: () => void;
}
export function TextControls({
isUpperCase,
onToggleUpperCase,
isSyllablesEnabled,
onToggleSyllables,
fontSize,
onFontSizeChange,
readingSpeed,
onReadingSpeedChange,
letterSpacing,
onLetterSpacingChange,
wordSpacing,
onWordSpacingChange,
lineHeight,
onLineHeightChange,
isLoading = false,
className,
isHighlighting = false,
onToggleHighlight
}: TextControlsProps) {
const handleFontSizeChange = (delta: number) => {
const newSize = Math.min(Math.max(12, fontSize + delta), 32);
onFontSizeChange(newSize);
};
const handleReadingSpeedChange = (delta: number) => {
const newSpeed = Math.min(Math.max(30, readingSpeed + delta), 300);
onReadingSpeedChange(newSpeed);
};
const handleLetterSpacingChange = (delta: number) => {
const newSpacing = Math.min(Math.max(0, letterSpacing + delta), 10);
onLetterSpacingChange(newSpacing);
};
const handleWordSpacingChange = (delta: number) => {
const newSpacing = Math.min(Math.max(0, wordSpacing + delta), 20);
onWordSpacingChange(newSpacing);
};
const handleLineHeightChange = (delta: number) => {
const newHeight = Math.min(Math.max(1, lineHeight + delta), 3);
onLineHeightChange(newHeight);
};
return (
<div className={cn("space-y-4", className)}>
{/* Primeira Seção: Controles Principais */}
<div className="flex items-center gap-3 flex-wrap">
{/* Controle de Maiúsculas */}
<button
onClick={onToggleUpperCase}
disabled={isLoading}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
'border border-gray-200',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
title={isUpperCase ? 'Mudar para minúsculas' : 'MUDAR PARA MAIÚSCULAS'}
>
<Type className="h-4 w-4" />
<span className="text-sm font-medium select-none">
{isUpperCase ? 'Aa: Minúsculas' : 'AA: MAIÚSCULAS'}
</span>
</button>
{/* Controle de Sílabas */}
<button
onClick={onToggleSyllables}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
'text-gray-600 hover:text-gray-900',
'border border-gray-200',
isSyllablesEnabled ? 'bg-purple-50 text-purple-600 border-purple-200' : 'hover:bg-gray-100'
)}
>
<span className="text-sm font-medium">-la-bas</span>
</button>
{/* Word Highlighter */}
<button
onClick={onToggleHighlight}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
'text-gray-600 hover:text-gray-900',
'border border-gray-200',
isHighlighting ? 'bg-blue-50 text-blue-600 border-blue-200' : 'hover:bg-gray-100'
)}
>
{isHighlighting ? (
<>
<Pause className="h-4 w-4" />
<span className="text-sm font-medium">Pausar</span>
</>
) : (
<>
<Play className="h-4 w-4" />
<span className="text-sm font-medium">Destacar Palavras</span>
</>
)}
</button>
</div>
{/* Segunda Seção: Ajustes de Texto */}
<div className="flex items-center gap-3 flex-wrap">
{/* Controle de Tamanho da Fonte */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleFontSizeChange(-2)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir fonte"
>
<ChevronDown className="h-4 w-4" />
</button>
<span className="text-sm font-medium px-2">{fontSize}px</span>
<button
onClick={() => handleFontSizeChange(2)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar fonte"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
{/* Controle de Velocidade de Leitura */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleReadingSpeedChange(-10)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir velocidade"
>
<ChevronDown className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 px-2">
<Timer className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">{readingSpeed} ppm</span>
</div>
<button
onClick={() => handleReadingSpeedChange(10)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar velocidade"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
{/* Controle de Espaçamento entre Letras */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleLetterSpacingChange(-0.5)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir espaçamento entre letras"
>
<ChevronDown className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 px-2">
<Type className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">{letterSpacing}px</span>
</div>
<button
onClick={() => handleLetterSpacingChange(0.5)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar espaçamento entre letras"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
{/* Controle de Espaçamento entre Palavras */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleWordSpacingChange(-1)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir espaçamento entre palavras"
>
<ChevronDown className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 px-2">
<ArrowLeftRight className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">{wordSpacing}px</span>
</div>
<button
onClick={() => handleWordSpacingChange(1)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar espaçamento entre palavras"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
{/* Controle de Altura da Linha */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleLineHeightChange(-0.1)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir altura da linha"
>
<ChevronDown className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 px-2">
<MoveVertical className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">{lineHeight.toFixed(1)}</span>
</div>
<button
onClick={() => handleLineHeightChange(0.1)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar altura da linha"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}

View File

@ -20,16 +20,3 @@ export function splitIntoSyllables(word: string): string[] {
return syllables.length > 0 ? syllables : [word];
}
export function highlightSyllables(text: string): string {
const words = text.split(/\s+/);
return words.map(word => {
const syllables = splitIntoSyllables(word);
return syllables.join('-');
}).join(' ');
}
export function formatTextWithSyllables(text: string, shouldHighlight: boolean): string {
if (!shouldHighlight) return text;
return highlightSyllables(text);
}

View File

@ -1,93 +0,0 @@
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
import { Mic, Waves } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useEffect } from 'react';
interface VoiceCommandButtonProps {
className?: string;
onTranscriptUpdate: (transcript: string) => void;
onStart?: () => void;
onStop?: () => void;
disabled?: boolean;
}
export function VoiceCommandButton({
className,
onTranscriptUpdate,
onStart,
onStop,
disabled = false
}: VoiceCommandButtonProps) {
const {
transcript,
start,
stop,
status,
error,
isSupported
} = useSpeechRecognition();
useEffect(() => {
if (transcript) {
onTranscriptUpdate(transcript);
}
}, [transcript, onTranscriptUpdate]);
const handleStart = () => {
onStart?.();
start();
};
const handleStop = () => {
onStop?.();
stop();
};
useEffect(() => {
if (status === 'recording') {
onTranscriptUpdate(''); // Limpar contexto ao iniciar nova gravação
}
}, [status, onTranscriptUpdate]);
if (!isSupported) {
return (
<div className="p-3 bg-yellow-50 text-yellow-700 rounded-lg text-sm">
Seu navegador não suporta gravação por voz
</div>
);
}
return (
<div className="relative group">
<button
onClick={status === 'recording' ? handleStop : handleStart}
className={cn(
'flex items-center gap-3 px-4 py-2 rounded-lg transition-all',
status === 'recording'
? 'bg-red-100 text-red-600 hover:bg-red-200 shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
aria-label={status === 'recording' ? "Parar gravação" : "Iniciar gravação"}
disabled={disabled}
>
<div className="relative">
<Mic className="h-5 w-5" />
{status === 'recording' && (
<Waves className="absolute -top-2 -right-2 h-4 w-4 animate-pulse text-red-500" />
)}
</div>
<span className="text-sm font-medium">
{status === 'recording' ? 'Gravando...' : 'Gravar por Voz'}
</span>
</button>
{error && (
<div className="absolute top-full mt-2 p-2 bg-red-50 text-red-600 text-sm rounded-lg shadow-lg border border-red-100">
{error}
</div>
)}
</div>
);
}

View File

@ -1,100 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { SpeechRecognition } from '../../../types/speech';
type RecognitionState = 'idle' | 'recording' | 'processing' | 'error';
interface UseSpeechRecognitionReturn {
transcript: string;
start: () => void;
stop: () => void;
reset: () => void;
status: RecognitionState;
error: string | null;
isSupported: boolean;
}
export function useSpeechRecognition(): UseSpeechRecognitionReturn {
const [status, setStatus] = useState<RecognitionState>('idle');
const [transcript, setTranscript] = useState('');
const [error, setError] = useState<string | null>(null);
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
const isSupported = typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
useEffect(() => {
if (!isSupported) {
setError('Reconhecimento de voz não é suportado neste navegador');
return;
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.lang = 'pt-BR';
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onstart = () => setStatus('recording');
recognition.onend = () => setStatus('idle');
recognition.onerror = (event) => {
setStatus('error');
setError(`Erro na gravação: ${event.error}`);
};
recognition.onresult = (event) => {
const result = event.results[0][0].transcript;
setTranscript(prev => prev + ' ' + result);
setStatus('processing');
setTimeout(() => setStatus('idle'), 2000);
};
setRecognition(recognition);
}, [isSupported]);
const start = useCallback(() => {
if (!recognition || status === 'recording') return;
setTranscript('');
setError(null);
try {
recognition.start();
} catch (err) {
setError('Não foi possível iniciar a gravação');
setStatus('error');
}
}, [recognition, status]);
const stop = useCallback(() => {
if (recognition && status === 'recording') {
recognition.stop();
}
}, [recognition, status]);
const reset = useCallback(() => {
setTranscript('');
setError(null);
setStatus('idle');
}, []);
useEffect(() => {
if (status === 'recording') {
const timeout = setTimeout(() => {
stop();
setError('Tempo máximo de gravação atingido (2 minutos)');
}, 120_000);
return () => clearTimeout(timeout);
}
}, [status, stop]);
return {
transcript,
start,
stop,
reset,
status,
error,
isSupported
};
}

View File

@ -1,16 +0,0 @@
// Lista de termos bloqueados expandida
const blockedTerms = [
'senha', 'token', 'cartão', 'crédito',
'cpf', 'rg', 'endereço', 'telefone'
];
export function validateAudioContent(transcript: string): boolean {
const cleanTranscript = transcript
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
return !blockedTerms.some(term =>
cleanTranscript.includes(term.normalize('NFD').toLowerCase())
);
}

View File

@ -1,3 +0,0 @@
export function useSpeechRecognition() {
// ... implementação
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { ArrowLeft, Sparkles } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { StoryGenerator } from '../../components/story/StoryGenerator';
@ -6,65 +6,14 @@ import { useSession } from '../../hooks/useSession';
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
import { AdaptiveTitle, AdaptiveParagraph, AdaptiveText } from '../../components/ui/adaptive-text';
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
import { useSpeechRecognition } from '@/features/voice-commands/hooks/useSpeechRecognition';
import { VoiceCommandButton } from '@/features/voice-commands/components/VoiceCommandButton';
import type { StoryChoices } from '@/components/story/StoryGenerator';
export function CreateStoryPage() {
const navigate = useNavigate();
const { session } = useSession();
const [error, setError] = React.useState<string | null>(null);
const {
transcript: voiceTranscript,
start: startRecording,
stop: stopRecording,
status: recordingStatus,
error: voiceError,
isSupported: isVoiceSupported
} = useSpeechRecognition();
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
const [inputMode, setInputMode] = useState<'voice' | 'form'>('form');
const [storyContext, setStoryContext] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [step, setStep] = useState(1);
const [choices, setChoices] = useState<StoryChoices>({
theme_id: null,
subject_id: null,
character_id: null,
setting_id: null,
context: ''
});
// Manipuladores para gravação de voz
const handleStartRecording = () => {
setError(null);
startRecording();
};
const handleStopRecording = () => {
stopRecording();
};
// Atualizar status da interface baseado no status da gravação
useEffect(() => {
if (recordingStatus === 'recording') {
setInputMode('voice');
}
}, [recordingStatus]);
useEffect(() => {
if (inputMode === 'voice' && voiceTranscript) {
setStep(5);
setInputMode('voice');
}
}, [voiceTranscript, inputMode]);
if (!session) {
return (
<div className="text-center py-12">
@ -123,79 +72,7 @@ export function CreateStoryPage() {
</div>
)}
{voiceError && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-lg">
<AdaptiveText
text={voiceError}
isUpperCase={isUpperCase}
/>
</div>
)}
<div className="space-y-4">
{!isVoiceSupported && (
<div className="p-3 bg-yellow-50 text-yellow-700 rounded-lg mb-4">
Seu navegador não suporta gravação por voz
</div>
)}
</div>
<div className="mb-4 flex items-center gap-2">
<span className="text-sm font-medium">
Modo atual:
</span>
<span className="px-2 py-1 rounded-full text-xs bg-purple-100 text-purple-800">
{inputMode === 'voice' ? 'Voz' : 'Formulário'}
</span>
</div>
<div className="mb-8 space-y-4">
<div className="bg-purple-50 p-4 rounded-xl border border-purple-100">
<h3 className="text-sm font-medium text-purple-800 mb-3">
Descreva sua história por voz
</h3>
<VoiceCommandButton
onTranscriptUpdate={(transcript) => {
setInputMode('voice');
setStoryContext(transcript);
}}
onStart={handleStartRecording}
onStop={handleStopRecording}
disabled={isGenerating}
className="w-full justify-center py-3"
/>
{voiceTranscript && (
<div className="mt-4 p-3 bg-white rounded-lg border border-gray-200">
<p className="text-sm text-gray-700">{voiceTranscript}</p>
</div>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex-1 h-px bg-gray-200" />
<span className="text-sm text-gray-500">ou</span>
<div className="flex-1 h-px bg-gray-200" />
</div>
</div>
<StoryGenerator
initialContext={inputMode === 'voice' ? voiceTranscript : storyContext}
onContextChange={(newContext) => {
if (inputMode === 'form') {
setStoryContext(newContext);
}
}}
inputMode={inputMode}
voiceTranscript={voiceTranscript || ''}
isGenerating={isGenerating}
setIsGenerating={setIsGenerating}
step={step}
setStep={setStep}
choices={choices}
setChoices={setChoices}
/>
<StoryGenerator />
<div className="mt-8 p-4 bg-purple-50 rounded-lg">
<h3 className="text-sm font-medium text-purple-900 mb-2">

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, ArrowRight, Share2, ChevronDown, ChevronUp, Loader2, Download, RefreshCw, Trash2, Type, Eye, EyeOff } from 'lucide-react';
import { ArrowLeft, ArrowRight, Volume2, Share2, ChevronDown, ChevronUp, Loader2, Pause, Play, Download, RefreshCw, Trash2, TextSelect } from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { AudioRecorder } from '../../components/story/AudioRecorder';
@ -9,12 +9,10 @@ import { convertWebmToMp3 } from '../../utils/audioConverter';
import * as Dialog from '@radix-ui/react-dialog';
import { ExerciseSuggestions } from '../../components/learning/ExerciseSuggestions';
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
import { AdaptiveText } from '../../components/ui/adaptive-text';
import { AdaptiveText, AdaptiveTitle, AdaptiveParagraph } from '../../components/ui/adaptive-text';
import { useSession } from '../../hooks/useSession';
import { useSyllables } from '../../features/syllables/hooks/useSyllables';
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
import { TextControls } from '../../components/ui/text-controls';
import { cn } from '../../lib/utils';
interface StoryRecording {
@ -36,19 +34,9 @@ interface StoryRecording {
transcription: string;
}
interface FocusMode {
isActive: boolean;
isRecording: boolean;
originalState: {
isUpperCase: boolean;
isHighlighting: boolean;
fontSize: number;
imageVisible: boolean;
};
}
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const [isExpanded, setIsExpanded] = React.useState(false);
const [isPlaying, setIsPlaying] = React.useState(false);
const [isAudioSupported, setIsAudioSupported] = React.useState(true);
const audioRef = React.useRef<HTMLAudioElement | null>(null);
const [isConverting, setIsConverting] = React.useState(false);
@ -84,33 +72,41 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
readyState: audioElement.readyState,
src: audioElement.src
});
setIsPlaying(false);
};
const handlePlayPause = async () => {
if (audioRef.current) {
try {
if (audioRef.current.ended) {
audioRef.current.currentTime = 0;
}
if (isPlaying) {
audioRef.current.pause();
} else {
if (audioRef.current.ended) {
audioRef.current.currentTime = 0;
}
// Verificar se o áudio está pronto
if (audioRef.current.readyState === 0) {
console.log('Recarregando áudio...');
await audioRef.current.load();
}
// Verificar se o áudio está pronto
if (audioRef.current.readyState === 0) {
console.log('Recarregando áudio...');
await audioRef.current.load();
}
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
console.log('Reprodução iniciada com sucesso');
})
.catch(error => {
console.error('Erro ao reproduzir áudio:', error);
});
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
console.log('Reprodução iniciada com sucesso');
})
.catch(error => {
console.error('Erro ao reproduzir áudio:', error);
setIsPlaying(false);
});
}
}
setIsPlaying(!isPlaying);
} catch (error) {
console.error('Erro ao manipular áudio:', error);
setIsPlaying(false);
}
}
};
@ -154,11 +150,10 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const audio = audioRef.current;
if (audio) {
const handleEnded = () => {
console.log('Reprodução encerrada');
};
const handleEnded = () => setIsPlaying(false);
const handleError = (e: ErrorEvent) => {
console.error('Erro no áudio:', e);
setIsPlaying(false);
};
audio.addEventListener('ended', handleEnded);
@ -241,7 +236,17 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
disabled={!recording.audio_url && !mp3Url}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{recording.audio_url || mp3Url ? 'Ouvir' : 'Carregando...'}
{isPlaying ? (
<>
<Pause className="h-5 w-5" />
Pausar
</>
) : (
<>
<Play className="h-5 w-5" />
{recording.audio_url || mp3Url ? 'Ouvir' : 'Carregando...'}
</>
)}
</button>
)}
@ -387,99 +392,15 @@ export function StoryPage() {
const [currentPage, setCurrentPage] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [isPlaying, setIsPlaying] = React.useState(false);
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { session } = useSession();
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
const { isHighlighted: isSyllablesEnabled, toggleHighlight: toggleSyllables } = useSyllables();
const [fontSize, setFontSize] = useState(18);
const [readingSpeed, setReadingSpeed] = useState(60); // 60 palavras por minuto
const [letterSpacing, setLetterSpacing] = useState(0);
const [wordSpacing, setWordSpacing] = useState(0);
const [lineHeight, setLineHeight] = useState(1.5);
const [isHighlighting, setIsHighlighting] = useState(false);
const [currentWordIndex, setCurrentWordIndex] = useState(-1);
const highlightInterval = React.useRef<number | null>(null);
const [focusMode, setFocusMode] = useState<FocusMode>({
isActive: false,
isRecording: false,
originalState: {
isUpperCase: false,
isHighlighting: false,
fontSize: 18,
imageVisible: true
}
});
const { isHighlighted, toggleHighlight } = useSyllables();
// Função para dividir o texto em palavras
const getWords = (text: string) => text.split(/\s+/);
// Atualizar o useEffect do highlighting para usar readingSpeed
useEffect(() => {
if (isHighlighting) {
const words = getWords(story?.content?.pages?.[currentPage]?.text || '');
const intervalTime = (60 / readingSpeed) * 1000; // Converter palavras por minuto para milissegundos
highlightInterval.current = window.setInterval(() => {
setCurrentWordIndex(prev => {
if (prev >= words.length - 1) {
setIsHighlighting(false);
return -1;
}
return prev + 1;
});
}, intervalTime);
return () => {
if (highlightInterval.current) {
window.clearInterval(highlightInterval.current);
}
};
} else {
setCurrentWordIndex(-1);
if (highlightInterval.current) {
window.clearInterval(highlightInterval.current);
}
}
}, [isHighlighting, currentPage, story?.content?.pages, readingSpeed]);
// Função para renderizar o texto com destaque
const renderHighlightedText = (text: string) => {
if (!text || currentWordIndex === -1) {
return (
<AdaptiveText
text={text}
isUpperCase={isUpperCase}
highlightSyllables={isSyllablesEnabled}
/>
);
}
const words = getWords(text);
return (
<div className="leading-relaxed whitespace-pre-wrap break-words">
{words.map((word, index) => (
<span
key={index}
className={cn(
'inline-block',
'transition-colors duration-200',
index === currentWordIndex && 'bg-yellow-200 rounded px-0.5'
)}
>
<AdaptiveText
text={word}
isUpperCase={isUpperCase}
highlightSyllables={isSyllablesEnabled}
/>
{' '}
</span>
))}
</div>
);
};
React.useEffect(() => {
const fetchStory = async () => {
@ -526,6 +447,8 @@ export function StoryPage() {
fetchStory();
}, [id]);
React.useEffect(() => {
const fetchRecordings = async () => {
if (!story?.id) return;
@ -663,55 +586,6 @@ export function StoryPage() {
}
};
const handleFocusModeToggle = () => {
if (!focusMode.isActive) {
// Salvar estado atual antes de ativar o modo foco
setFocusMode(prev => ({
...prev,
isActive: true,
originalState: {
isUpperCase,
isHighlighting,
fontSize,
imageVisible: true
}
}));
// Aplicar configurações do modo foco
toggleUppercase();
setIsHighlighting(true);
setFontSize(24);
} else {
// Restaurar estado original
if (isUpperCase !== focusMode.originalState.isUpperCase) {
toggleUppercase();
}
setIsHighlighting(focusMode.originalState.isHighlighting);
setFontSize(focusMode.originalState.fontSize);
// Resetar modo foco
setFocusMode(prev => ({
...prev,
isActive: false,
isRecording: false
}));
}
};
const handleRecordingStart = () => {
setFocusMode(prev => ({
...prev,
isRecording: true
}));
};
const handleRecordingStop = () => {
setFocusMode(prev => ({
...prev,
isRecording: false
}));
};
if (loading) {
return (
<div className="animate-pulse">
@ -748,63 +622,20 @@ export function StoryPage() {
</button>
<div className="flex items-center gap-4">
<button
onClick={handleFocusModeToggle}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
'text-gray-600 hover:text-gray-900 border border-gray-200',
focusMode.isActive && 'bg-purple-50 text-purple-600 border-purple-200'
)}
>
{focusMode.isActive ? (
<>
<Eye className="h-4 w-4" />
<span className="text-sm font-medium">Sair do Modo Foco</span>
</>
) : (
<>
<EyeOff className="h-4 w-4" />
<span className="text-sm font-medium">Modo Foco</span>
</>
)}
</button>
<AudioRecorder
storyId={id || ''}
studentId={session?.user?.id || ''}
onAudioUploaded={(audioUrl) => {
const newRecording: StoryRecording = {
id: crypto.randomUUID(),
audio_url: audioUrl,
created_at: new Date().toISOString(),
processed_at: null,
fluency_score: 0,
pronunciation_score: 0,
accuracy_score: 0,
comprehension_score: 0,
words_per_minute: 0,
pause_count: 0,
error_count: 0,
self_corrections: 0,
strengths: [],
improvements: [],
suggestions: '',
transcription: ''
};
setRecordings(prev => [newRecording, ...prev]);
if (focusMode.isActive) {
handleFocusModeToggle();
}
}}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
focusModeActive={focusMode.isActive}
onFocusModeToggle={handleFocusModeToggle}
<TextCaseToggle
isUpperCase={isUpperCase}
onToggle={toggleUppercase}
isLoading={isLoading}
/>
{/*
<button
onClick={toggleHighlight}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors text-gray-600 hover:text-gray-900 hover:bg-gray-100 border border-gray-200"
>
<TextSelect className="h-4 w-4" />
<span className="text-sm font-medium">
{isHighlighted ? 'Desativar Sílabas' : 'Ativar Sílabas'}
</span>
</button>
<button
onClick={handleShare}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
@ -812,117 +643,78 @@ export function StoryPage() {
<Share2 className="h-5 w-5" />
Compartilhar
</button>
*/}
{
/* <button
onClick={() => setIsPlaying(!isPlaying)}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
>
<Volume2 className="h-5 w-5" />
{isPlaying ? 'Pausar' : 'Ouvir'}
</button>
*/}
</div>
</div>
{/* Conteúdo Principal */}
<div className={cn(
"bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6",
"transition-all duration-300",
focusMode.isActive && "bg-gray-50"
)}>
{/* 1. Controles de Texto */}
<div className={cn(
"border-b border-gray-200 pb-6",
focusMode.isActive && "opacity-50 pointer-events-none"
)}>
<TextControls
isUpperCase={isUpperCase}
onToggleUpperCase={toggleUppercase}
isSyllablesEnabled={isSyllablesEnabled}
onToggleSyllables={toggleSyllables}
fontSize={fontSize}
onFontSizeChange={setFontSize}
readingSpeed={readingSpeed}
onReadingSpeedChange={setReadingSpeed}
letterSpacing={letterSpacing}
onLetterSpacingChange={setLetterSpacing}
wordSpacing={wordSpacing}
onWordSpacingChange={setWordSpacing}
lineHeight={lineHeight}
onLineHeightChange={setLineHeight}
isLoading={isLoading}
isHighlighting={isHighlighting}
onToggleHighlight={() => setIsHighlighting(prev => !prev)}
{/* História Principal */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
{/* Imagem da página atual */}
{story?.content?.pages?.[currentPage]?.image && (
<ImageWithLoading
src={story.content.pages[currentPage].image}
alt={`Página ${currentPage + 1}`}
className="w-full h-full object-cover"
/>
</div>
)}
{/* 2. Título da História */}
<div className="border-b border-gray-200 pb-6">
<h1 className="text-2xl font-bold text-gray-900">{story?.title}</h1>
</div>
<div className="p-8">
<AdaptiveTitle
text={story?.title || ''}
isUpperCase={isUpperCase}
className="text-3xl font-bold text-gray-900 mb-6"
/>
{/* 3. Texto da História */}
<div className="border-b border-gray-200 pb-6">
<div
className="prose max-w-none overflow-hidden"
style={{
fontSize: `${fontSize}px`,
letterSpacing: `${letterSpacing}px`,
wordSpacing: `${wordSpacing}px`,
lineHeight: lineHeight
{/* Texto da página atual */}
<AdaptiveParagraph
text={story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
isUpperCase={isUpperCase}
highlightSyllables={isHighlighted}
className="text-xl leading-relaxed text-gray-700 mb-8"
/>
{/* Gravador de áudio */}
<AudioRecorder
storyId={story.id}
studentId={story.student_id}
onAudioUploaded={(audioUrl) => {
console.log('Áudio gravado:', audioUrl);
}}
>
{renderHighlightedText(story?.content?.pages?.[currentPage]?.text || '')}
</div>
</div>
/>
{/* 4. Controles de Navegação */}
<div className="border-b border-gray-200 pb-6">
<div className="flex justify-between items-center">
{/* Navegação entre páginas */}
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
<button
onClick={() => setCurrentPage(prev => prev - 1)}
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
disabled={currentPage === 0}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
<ArrowLeft className="h-5 w-5" />
Página Anterior
Anterior
</button>
<span className="text-sm text-gray-500">
Página {currentPage + 1} de {story?.content?.pages?.length}
Página {currentPage + 1} de {story.content.pages.length}
</span>
<button
onClick={() => setCurrentPage(prev => prev + 1)}
disabled={currentPage === (story?.content?.pages?.length ?? 0) - 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => setCurrentPage(prev => Math.min(story.content.pages.length - 1, prev + 1))}
disabled={currentPage === story.content.pages.length - 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
Próxima Página
Próxima
<ArrowRight className="h-5 w-5" />
</button>
</div>
</div>
{/* 5. Imagem da História - Agora com controle de visibilidade */}
{!focusMode.isActive && (
<div className="border-b border-gray-200 pb-6">
{story?.content?.pages?.[currentPage]?.image ? (
<ImageWithLoading
src={story.content.pages[currentPage].image}
alt={`Ilustração da página ${currentPage + 1}`}
className="w-full rounded-lg"
/>
) : (
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
<p className="text-gray-500">Sem imagem para esta página</p>
</div>
)}
</div>
)}
{/* 6. Controle de Gravação */}
{/*
<div>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{focusMode.isActive ? "Modo Foco Ativado" : "Gravação de Áudio"}
</h2>
</div>
</div>
*/}
</div>
{/* Dashboard de métricas */}

View File

@ -1,100 +0,0 @@
/* Estilos para o Modo Foco */
.focus-mode-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
transition: all 0.3s ease-in-out;
}
.focus-mode-active {
background-color: #f8f9fc;
min-height: 100vh;
}
.focus-mode-text {
font-size: 24px;
line-height: 1.8;
letter-spacing: 0.5px;
word-spacing: 2px;
text-align: center;
padding: 2rem;
background-color: white;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.focus-mode-controls {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 1rem;
padding: 1rem;
background-color: white;
border-radius: 9999px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 50;
}
.focus-mode-highlight {
background-color: #fef3c7;
border-radius: 0.25rem;
padding: 0 0.25rem;
transition: background-color 0.2s ease-in-out;
}
.focus-mode-timer {
position: fixed;
top: 2rem;
right: 2rem;
padding: 0.75rem 1.5rem;
background-color: white;
border-radius: 9999px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
font-size: 1.125rem;
font-weight: 500;
color: #4b5563;
z-index: 50;
}
/* Animações */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.focus-mode-transition {
animation: fadeIn 0.3s ease-out;
}
/* Responsividade */
@media (max-width: 640px) {
.focus-mode-container {
padding: 1rem;
}
.focus-mode-text {
font-size: 20px;
padding: 1rem;
}
.focus-mode-controls {
bottom: 1rem;
padding: 0.75rem;
}
.focus-mode-timer {
top: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
font-size: 1rem;
}
}

35
src/types/speech.d.ts vendored
View File

@ -1,35 +0,0 @@
interface SpeechRecognitionErrorEvent extends Event {
error: string;
}
interface SpeechRecognitionEvent extends Event {
results: {
[index: number]: {
[index: number]: {
transcript: string;
};
};
};
}
interface SpeechRecognition extends EventTarget {
continuous: boolean;
lang: string;
interimResults: boolean;
maxAlternatives: number;
start(): void;
stop(): void;
onstart: () => void;
onend: () => void;
onerror: (event: SpeechRecognitionErrorEvent) => void;
onresult: (event: SpeechRecognitionEvent) => void;
}
declare global {
interface Window {
SpeechRecognition: new () => SpeechRecognition;
webkitSpeechRecognition: new () => SpeechRecognition;
}
}
export function useSpeechRecognition(): SpeechRecognition;

View File

@ -1,3 +0,0 @@
export function normalizeAudio(buffer: ArrayBuffer) {
// ... implementação
}

View File

@ -15,16 +15,6 @@ interface StoryPrompt {
difficulty: 'easy' | 'medium' | 'hard';
}
interface EnhancedPayload {
// Campos existentes
voice_context?: string;
audio_metadata?: {
duration: number;
sample_rate: number;
language: string;
};
}
const ALLOWED_ORIGINS = [
'http://localhost:5173', // Vite dev server
'http://localhost:3000', // Caso use outro port
@ -70,8 +60,8 @@ serve(async (req) => {
return new Response('ok', { headers: corsHeaders })
}
const { voice_context, ...rest } = await req.json()
console.log('[Request]', rest)
const { record } = await req.json()
console.log('[Request]', record)
try {
const supabase = createClient(
@ -82,10 +72,10 @@ serve(async (req) => {
console.log('[DB] Buscando categorias...')
const [themeResult, subjectResult, characterResult, settingResult] = await Promise.all([
supabase.from('story_themes').select('*').eq('id', rest.theme_id).single(),
supabase.from('story_subjects').select('*').eq('id', rest.subject_id).single(),
supabase.from('story_characters').select('*').eq('id', rest.character_id).single(),
supabase.from('story_settings').select('*').eq('id', rest.setting_id).single()
supabase.from('story_themes').select('*').eq('id', record.theme_id).single(),
supabase.from('story_subjects').select('*').eq('id', record.subject_id).single(),
supabase.from('story_characters').select('*').eq('id', record.character_id).single(),
supabase.from('story_settings').select('*').eq('id', record.setting_id).single()
])
console.log('[DB] Resultados das consultas:', {
@ -100,10 +90,10 @@ serve(async (req) => {
if (characterResult.error) throw new Error(`Erro ao buscar personagem: ${characterResult.error.message}`);
if (settingResult.error) throw new Error(`Erro ao buscar cenário: ${settingResult.error.message}`);
if (!themeResult.data) throw new Error(`Tema não encontrado: ${rest.theme_id}`);
if (!subjectResult.data) throw new Error(`Disciplina não encontrada: ${rest.subject_id}`);
if (!characterResult.data) throw new Error(`Personagem não encontrado: ${rest.character_id}`);
if (!settingResult.data) throw new Error(`Cenário não encontrado: ${rest.setting_id}`);
if (!themeResult.data) throw new Error(`Tema não encontrado: ${record.theme_id}`);
if (!subjectResult.data) throw new Error(`Disciplina não encontrada: ${record.subject_id}`);
if (!characterResult.data) throw new Error(`Personagem não encontrado: ${record.character_id}`);
if (!settingResult.data) throw new Error(`Cenário não encontrado: ${record.setting_id}`);
const theme = themeResult.data;
const subject = subjectResult.data;
@ -113,7 +103,68 @@ serve(async (req) => {
console.log('[Validation] Categorias validadas com sucesso')
console.log('[GPT] Construindo prompt...')
const prompt = buildPrompt(rest, voice_context);
const prompt = `
Crie uma história educativa para crianças com as seguintes características:
Tema: ${theme.title}
Disciplina: ${subject.title}
Personagem Principal: ${character.title}
Cenário: ${setting.title}
${record.context ? `Contexto Adicional: ${record.context}` : ''}
Requisitos:
- História adequada para crianças de 6-12 anos
- Conteúdo educativo focado em ${subject.title}
- Linguagem clara e envolvente
- 3-5 páginas de conteúdo
- Cada página deve ter um texto curto e sugestão para uma imagem
- Evitar conteúdo sensível ou inadequado
- Incluir elementos de ${theme.title}
- Ambientado em ${setting.title}
- Personagem principal baseado em ${character.title}
Requisitos específicos para exercícios:
1. Para o exercício de completar frases:
- Selecione 5-8 palavras importantes do texto
- Escolha palavras que sejam substantivos, verbos ou adjetivos
- Evite artigos, preposições ou palavras muito simples
- As palavras devem ser relevantes para o contexto da história
2. Para o exercício de formação de palavras:
- Selecione palavras com diferentes padrões silábicos
- Inclua palavras que possam ser divididas em sílabas
- Priorize palavras com 2-4 sílabas
3. Para o exercício de pronúncia:
- Selecione palavras que trabalhem diferentes fonemas
- Inclua palavras com encontros consonantais
- Priorize palavras que sejam desafiadoras para a faixa etária
Formato da resposta:
{
"title": "Título da História",
"content": {
"pages": [
{
"text": "Texto da página com frases completas...",
"imagePrompt": "Descrição para gerar imagem...",
"keywords": ["palavra1", "palavra2"],
"phonemes": ["fonema1", "fonema2"],
"syllablePatterns": ["CV", "CVC", "CCVC"]
}
]
},
"metadata": {
"targetAge": number,
"difficulty": string,
"exerciseWords": {
"pronunciation": ["palavra1", "palavra2"],
"formation": ["palavra3", "palavra4"],
"completion": ["palavra5", "palavra6"]
}
}
}
`
console.log('[GPT] Prompt construído:', prompt)
console.log('[GPT] Iniciando geração da história...')
@ -162,7 +213,7 @@ serve(async (req) => {
const imageBuffer = await imageRes.arrayBuffer()
// Gerar nome único para o arquivo
const fileName = `${rest.id}/page-${index + 1}-${Date.now()}.png`
const fileName = `${record.id}/page-${index + 1}-${Date.now()}.png`
// Salvar no Storage do Supabase
console.log(`[Storage] Salvando imagem ${index + 1} no bucket...`)
@ -207,10 +258,10 @@ serve(async (req) => {
subject_id: subject.id,
character_id: character.id,
setting_id: setting.id,
context: rest.context,
context: record.context,
updated_at: new Date().toISOString()
})
.eq('id', rest.id)
.eq('id', record.id)
.select()
.single();
@ -221,7 +272,7 @@ serve(async (req) => {
.from('story_pages')
.insert(
pages.map((page, index) => ({
story_id: rest.id,
story_id: record.id,
page_number: index + 1,
text: page.text,
image_url: page.image,
@ -235,7 +286,7 @@ serve(async (req) => {
const { error: genError } = await supabase
.from('story_generations')
.insert({
story_id: rest.id,
story_id: record.id,
original_prompt: prompt,
ai_response: completion.choices[0].message.content,
model_used: 'gpt-4o-mini'
@ -250,7 +301,7 @@ serve(async (req) => {
*,
pages:story_pages(*)
`)
.eq('id', rest.id)
.eq('id', record.id)
.single();
if (fetchError) throw new Error(`Erro ao buscar história completa: ${fetchError.message}`);
@ -267,7 +318,7 @@ serve(async (req) => {
if (!pageWithWord) return null;
return {
story_id: rest.id,
story_id: record.id,
word,
exercise_type: type,
phonemes: pageWithWord?.phonemes || null,
@ -314,76 +365,3 @@ serve(async (req) => {
)
}
})
function buildPrompt(base: StoryPrompt, voice?: string) {
return `
Crie uma história educativa para crianças com as seguintes características:
Tema: ${base.theme_id}
Disciplina: ${base.subject_id}
Personagem Principal: ${base.character_id}
Cenário: ${base.setting_id}
${base.context ? `Contexto Adicional: ${base.context}` : ''}
Requisitos:
- História adequada para crianças de 6-12 anos
- Conteúdo educativo focado em ${base.subject_id}
- Linguagem clara e envolvente
- 3-5 páginas de conteúdo
- Cada página deve ter um texto curto e sugestão para uma imagem
- Evitar conteúdo sensível ou inadequado
- Incluir elementos de ${base.theme_id}
- Ambientado em ${base.setting_id}
- Personagem principal baseado em ${base.character_id}
Requisitos específicos para exercícios:
1. Para o exercício de completar frases:
- Selecione 5-8 palavras importantes do texto
- Escolha palavras que sejam substantivos, verbos ou adjetivos
- Evite artigos, preposições ou palavras muito simples
- As palavras devem ser relevantes para o contexto da história
2. Para o exercício de formação de palavras:
- Selecione palavras com diferentes padrões silábicos
- Inclua palavras que possam ser divididas em sílabas
- Priorize palavras com 2-4 sílabas
3. Para o exercício de pronúncia:
- Selecione palavras que trabalhem diferentes fonemas
- Inclua palavras com encontros consonantais
- Priorize palavras que sejam desafiadoras para a faixa etária
Formato da resposta:
{
"title": "Título da História",
"content": {
"pages": [
{
"text": "Texto da página com frases completas...",
"imagePrompt": "Descrição para gerar imagem...",
"keywords": ["palavra1", "palavra2"],
"phonemes": ["fonema1", "fonema2"],
"syllablePatterns": ["CV", "CVC", "CCVC"]
}
]
},
"metadata": {
"targetAge": number,
"difficulty": string,
"exerciseWords": {
"pronunciation": ["palavra1", "palavra2"],
"formation": ["palavra3", "palavra4"],
"completion": ["palavra5", "palavra6"]
}
}
}
${voice ? `
Contexto Adicional por Voz:
"${voice}"
Diretrizes Adicionais:
- Priorizar elementos mencionados na descrição oral
- Manter tom e estilo consistentes com a gravação
- Incluir palavras-chave identificadas` : ''}
`;
}