mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-18 06:17:56 +00:00
Compare commits
8 Commits
90506ca894
...
94835a427b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94835a427b | ||
|
|
dadcb048bb | ||
|
|
e5204e0430 | ||
|
|
51b8fb4088 | ||
|
|
dd9e2f4dd3 | ||
|
|
59a7adfeee | ||
|
|
ccacf76d9a | ||
|
|
c5a3017a7c |
79
CHANGELOG.md
79
CHANGELOG.md
@ -58,19 +58,78 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
|||||||
### Removido
|
### Removido
|
||||||
- N/A (primeira versão)
|
- N/A (primeira versão)
|
||||||
|
|
||||||
## [1.1.0] - 2024-05-20
|
## [1.1.1] - 2024-05-21
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
- Suporte a texto maiúsculo para alfabetização infantil
|
- Componente `TextCaseToggle` para alternar entre maiúsculas e minúsculas
|
||||||
- Componente de alternância de caixa de texto
|
- Componente `AdaptiveText` para renderização adaptativa de texto
|
||||||
- Sistema de persistência de preferências
|
- Hook `useUppercasePreference` para gerenciar preferências de texto
|
||||||
- Destaque silábico interativo para apoio à decodificação
|
- Suporte a texto adaptativo em exercícios fônicos
|
||||||
|
|
||||||
### Modificado
|
### Modificado
|
||||||
- Todas as páginas principais para usar texto adaptativo
|
- Atualização do layout do dashboard para incluir controle de texto
|
||||||
- Componentes de exercícios para suportar transformação de texto
|
- Integração do sistema de texto adaptativo em componentes existentes
|
||||||
|
- Melhorias na acessibilidade dos componentes de texto
|
||||||
|
|
||||||
### Técnico
|
### Técnico
|
||||||
- Nova coluna na tabela students
|
- Refatoração dos componentes de texto para suportar transformação dinâmica
|
||||||
- Hook para gerenciamento de estado
|
- Otimização do sistema de preferências do usuário
|
||||||
- Otimizações de performance
|
- 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
|
||||||
|
|||||||
25
docs/voice-features.md
Normal file
25
docs/voice-features.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
## 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
|
||||||
@ -1,46 +1,129 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ChevronUp, ChevronDown, Play, Pause } from 'lucide-react';
|
||||||
|
|
||||||
interface WordHighlighterProps {
|
interface WordHighlighterProps {
|
||||||
text: string; // Texto completo
|
text: string; // Texto completo
|
||||||
highlightedWords: string[]; // Palavras para destacar (ex: palavras difíceis)
|
highlightedWords: string[]; // Palavras para destacar (ex: palavras difíceis)
|
||||||
difficultWords: string[]; // Palavras que o aluno teve dificuldade
|
difficultWords: string[]; // Palavras que o aluno teve dificuldade
|
||||||
onWordClick: (word: string) => void; // Função para quando clicar na palavra
|
onWordClick: (word: string) => void; // Função para quando clicar na palavra
|
||||||
|
highlightSpeed?: number; // palavras por minuto
|
||||||
|
initialFontSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WordHighlighter({
|
export function WordHighlighter({
|
||||||
text,
|
text,
|
||||||
highlightedWords,
|
highlightedWords,
|
||||||
difficultWords,
|
difficultWords,
|
||||||
onWordClick
|
onWordClick,
|
||||||
|
highlightSpeed = 60,
|
||||||
|
initialFontSize = 18
|
||||||
}: WordHighlighterProps) {
|
}: 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
|
// Divide o texto em palavras mantendo a pontuação
|
||||||
const words = text.split(/(\s+)/).filter(word => word.trim().length > 0);
|
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 (
|
return (
|
||||||
<div className="leading-relaxed text-lg space-y-4">
|
<div className="space-y-4">
|
||||||
{words.map((word, i) => {
|
{/* Controles */}
|
||||||
// Remove pontuação para comparação
|
<div className="flex items-center gap-4 mb-4">
|
||||||
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/, '');
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
// Determina o estilo baseado no tipo da palavra
|
onClick={() => handleFontSizeChange(-2)}
|
||||||
const isHighlighted = highlightedWords.includes(cleanWord);
|
className="p-2 rounded-lg hover:bg-gray-100"
|
||||||
const isDifficult = difficultWords.includes(cleanWord);
|
aria-label="Diminuir fonte"
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
onClick={() => onWordClick(word)}
|
|
||||||
className={`
|
|
||||||
inline-block mx-1 px-1 rounded cursor-pointer transition-all
|
|
||||||
hover:scale-110
|
|
||||||
${isHighlighted ? 'bg-yellow-200 hover:bg-yellow-300' : ''}
|
|
||||||
${isDifficult ? 'bg-red-100 hover:bg-red-200' : ''}
|
|
||||||
hover:bg-gray-100
|
|
||||||
`}
|
|
||||||
title="Clique para ver mais informações"
|
|
||||||
>
|
>
|
||||||
{word}
|
<ChevronDown className="h-5 w-5" />
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2,15 +2,27 @@ import React, { useState, useRef } from 'react';
|
|||||||
import { Mic, Square, Loader, Play, Upload } from 'lucide-react';
|
import { Mic, Square, Loader, Play, Upload } from 'lucide-react';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
interface AudioRecorderProps {
|
interface AudioRecorderProps {
|
||||||
storyId: string;
|
storyId: string;
|
||||||
studentId: string;
|
studentId: string;
|
||||||
onAudioUploaded: (audioUrl: string) => void;
|
onAudioUploaded: (audioUrl: string) => void;
|
||||||
|
onRecordingStart?: () => void;
|
||||||
|
onRecordingStop?: () => void;
|
||||||
|
focusModeActive?: boolean;
|
||||||
|
onFocusModeToggle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioRecorderProps) {
|
export function AudioRecorder({
|
||||||
|
storyId,
|
||||||
|
studentId,
|
||||||
|
onAudioUploaded,
|
||||||
|
onRecordingStart,
|
||||||
|
onRecordingStop,
|
||||||
|
focusModeActive = false,
|
||||||
|
onFocusModeToggle
|
||||||
|
}: AudioRecorderProps) {
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
@ -18,11 +30,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
|||||||
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
const { trackAudioRecorded } = useStudentTracking();
|
|
||||||
const startTime = React.useRef<number | null>(null);
|
const startTime = React.useRef<number | null>(null);
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!focusModeActive && onFocusModeToggle) {
|
||||||
|
onFocusModeToggle();
|
||||||
|
}
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
mediaRecorderRef.current = new MediaRecorder(stream);
|
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
@ -34,12 +49,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
|||||||
mediaRecorderRef.current.onstop = () => {
|
mediaRecorderRef.current.onstop = () => {
|
||||||
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
setAudioBlob(audioBlob);
|
setAudioBlob(audioBlob);
|
||||||
|
onRecordingStop?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorderRef.current.start();
|
mediaRecorderRef.current.start();
|
||||||
startTime.current = Date.now();
|
startTime.current = Date.now();
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
onRecordingStart?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Erro ao acessar microfone. Verifique as permissões.');
|
setError('Erro ao acessar microfone. Verifique as permissões.');
|
||||||
console.error('Erro ao iniciar gravação:', err);
|
console.error('Erro ao iniciar gravação:', err);
|
||||||
@ -51,8 +68,12 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
|||||||
mediaRecorderRef.current.stop();
|
mediaRecorderRef.current.stop();
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
|
||||||
// Parar todas as tracks do stream
|
|
||||||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
|
// Desativar modo foco ao parar a gravação
|
||||||
|
if (focusModeActive && onFocusModeToggle) {
|
||||||
|
onFocusModeToggle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -112,7 +133,7 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
|||||||
.getPublicUrl(filePath);
|
.getPublicUrl(filePath);
|
||||||
|
|
||||||
// 3. Criar o registro com a URL do áudio
|
// 3. Criar o registro com a URL do áudio
|
||||||
const { data: recordData, error: recordError } = await supabase
|
const { error: recordError } = await supabase
|
||||||
.from('story_recordings')
|
.from('story_recordings')
|
||||||
.insert({
|
.insert({
|
||||||
id: fileId,
|
id: fileId,
|
||||||
@ -152,15 +173,23 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-white rounded-lg shadow">
|
<div className={cn(
|
||||||
|
"",
|
||||||
|
focusModeActive && "bg-purple-50"
|
||||||
|
)}>
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
{!isRecording && !audioBlob && (
|
{!isRecording && !audioBlob && (
|
||||||
<button
|
<button
|
||||||
onClick={startRecording}
|
onClick={startRecording}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
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"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Mic className="w-5 h-5" />
|
<Mic className="w-5 h-5" />
|
||||||
Iniciar Gravação
|
{focusModeActive ? "Iniciar Leitura" : "Iniciar Gravação"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { useSession } from '../../hooks/useSession';
|
import { useSession } from '../../hooks/useSession';
|
||||||
import { useStoryCategories } from '../../hooks/useStoryCategories';
|
import { useStoryCategories } from '../../hooks/useStoryCategories';
|
||||||
import { Wand2, ArrowLeft, ArrowRight } from 'lucide-react';
|
import { Wand2, ArrowLeft } from 'lucide-react';
|
||||||
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
@ -21,7 +21,7 @@ interface StoryStep {
|
|||||||
isContextStep?: boolean;
|
isContextStep?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StoryChoices {
|
export interface StoryChoices {
|
||||||
theme_id: string | null;
|
theme_id: string | null;
|
||||||
subject_id: string | null;
|
subject_id: string | null;
|
||||||
character_id: string | null;
|
character_id: string | null;
|
||||||
@ -29,26 +29,35 @@ interface StoryChoices {
|
|||||||
context?: string;
|
context?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StoryGenerator() {
|
interface StoryGeneratorProps {
|
||||||
const navigate = useNavigate();
|
initialContext?: string;
|
||||||
const { session } = useSession();
|
onContextChange: (context: string) => void;
|
||||||
const { themes, subjects, characters, settings, isLoading } = useStoryCategories();
|
inputMode: 'voice' | 'form';
|
||||||
const [step, setStep] = React.useState(1);
|
voiceTranscript: string;
|
||||||
const [choices, setChoices] = React.useState<StoryChoices>({
|
isGenerating: boolean;
|
||||||
theme_id: null,
|
setIsGenerating: (value: boolean) => void;
|
||||||
subject_id: null,
|
step: number;
|
||||||
character_id: null,
|
setStep: (value: number | ((prev: number) => number)) => void;
|
||||||
setting_id: null,
|
choices: StoryChoices;
|
||||||
context: ''
|
setChoices: React.Dispatch<React.SetStateAction<StoryChoices>>;
|
||||||
});
|
}
|
||||||
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());
|
|
||||||
|
|
||||||
|
export function StoryGenerator({
|
||||||
|
initialContext = '',
|
||||||
|
onContextChange,
|
||||||
|
inputMode,
|
||||||
|
voiceTranscript,
|
||||||
|
isGenerating,
|
||||||
|
setIsGenerating,
|
||||||
|
step,
|
||||||
|
setStep,
|
||||||
|
choices,
|
||||||
|
setChoices
|
||||||
|
}: StoryGeneratorProps) {
|
||||||
|
// 1. Obter dados da API
|
||||||
|
const { themes, subjects, characters, settings, isLoading } = useStoryCategories();
|
||||||
|
|
||||||
|
// 2. Definir steps com os dados obtidos
|
||||||
const steps: StoryStep[] = [
|
const steps: StoryStep[] = [
|
||||||
{
|
{
|
||||||
title: 'Escolha o Tema',
|
title: 'Escolha o Tema',
|
||||||
@ -71,32 +80,65 @@ export function StoryGenerator() {
|
|||||||
key: 'setting_id'
|
key: 'setting_id'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Adicione um Contexto (Opcional)',
|
title: 'Contexto da História (Opcional)',
|
||||||
isContextStep: true
|
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 currentStep = steps[step - 1];
|
||||||
const isLastStep = step === steps.length;
|
const isLastStep = step === steps.length;
|
||||||
|
|
||||||
const handleSelect = (key: keyof StoryChoices, value: string) => {
|
const handleSelect = (key: keyof StoryChoices, value: string) => {
|
||||||
setChoices(prev => ({ ...prev, [key]: value }));
|
setChoices(prev => ({ ...prev, [key]: value }));
|
||||||
if (step < steps.length) {
|
if (step < steps.length) {
|
||||||
setStep(prev => prev + 1);
|
setStep((prev: number) => 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 () => {
|
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 (!session?.user?.id) return;
|
||||||
|
|
||||||
if (!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id) {
|
if (!choices.theme_id || !choices.subject_id || !choices.character_id || !choices.setting_id) {
|
||||||
@ -118,7 +160,7 @@ export function StoryGenerator() {
|
|||||||
subject_id: choices.subject_id,
|
subject_id: choices.subject_id,
|
||||||
character_id: choices.character_id,
|
character_id: choices.character_id,
|
||||||
setting_id: choices.setting_id,
|
setting_id: choices.setting_id,
|
||||||
context: choices.context || null,
|
context: finalContext,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
content: {
|
content: {
|
||||||
prompt: choices,
|
prompt: choices,
|
||||||
@ -142,7 +184,7 @@ export function StoryGenerator() {
|
|||||||
subject: selectedSubject,
|
subject: selectedSubject,
|
||||||
character: selectedCharacter,
|
character: selectedCharacter,
|
||||||
setting: selectedSetting,
|
setting: selectedSetting,
|
||||||
context: choices.context,
|
context: finalContext,
|
||||||
generation_time: Date.now() - startTime.current,
|
generation_time: Date.now() - startTime.current,
|
||||||
word_count: 0, // será atualizado após a geração
|
word_count: 0, // será atualizado após a geração
|
||||||
student_id: session.user.id,
|
student_id: session.user.id,
|
||||||
@ -240,10 +282,10 @@ export function StoryGenerator() {
|
|||||||
{currentStep.isContextStep ? (
|
{currentStep.isContextStep ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<textarea
|
<textarea
|
||||||
value={choices.context}
|
value={initialContext}
|
||||||
onChange={handleContextChange}
|
onChange={handleContextChange}
|
||||||
placeholder="Adicione detalhes ou ideias específicas para sua história..."
|
className="w-full p-3 border rounded-lg"
|
||||||
className="w-full h-32 p-4 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
placeholder="Descreva sua história... (opcional)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -276,10 +318,16 @@ export function StoryGenerator() {
|
|||||||
</div>
|
</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 */}
|
{/* Navigation Buttons */}
|
||||||
<div className="flex justify-between pt-6">
|
<div className="flex justify-between pt-6">
|
||||||
<button
|
<button
|
||||||
onClick={handleBack}
|
onClick={() => setStep((prev: number) => prev - 1)}
|
||||||
disabled={step === 1}
|
disabled={step === 1}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { SyllableHighlighter } from '../../features/syllables/components/SyllableHighlighter';
|
import { SyllableHighlighter } from '../../features/syllables/components/SyllableHighlighter';
|
||||||
|
import { formatTextWithSyllables } from '../../features/syllables/utils/syllableSplitter';
|
||||||
|
|
||||||
interface AdaptiveTextProps extends React.HTMLAttributes<HTMLSpanElement> {
|
interface AdaptiveTextProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||||
text: string;
|
text: string;
|
||||||
@ -19,11 +20,8 @@ export const AdaptiveText = React.memo(({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: AdaptiveTextProps) => {
|
}: AdaptiveTextProps) => {
|
||||||
// Transformar o texto mantendo espaços em branco se necessário
|
const formattedText = formatTextWithSyllables(text, highlightSyllables);
|
||||||
const transformedText = React.useMemo(() => {
|
const finalText = isUpperCase ? formattedText.toUpperCase() : formattedText;
|
||||||
const transformed = isUpperCase ? text.toUpperCase() : text;
|
|
||||||
return preserveWhitespace ? transformed : transformed.trim();
|
|
||||||
}, [text, isUpperCase, preserveWhitespace]);
|
|
||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
Component,
|
Component,
|
||||||
@ -34,9 +32,7 @@ export const AdaptiveText = React.memo(({
|
|||||||
),
|
),
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
highlightSyllables ? (
|
finalText
|
||||||
<SyllableHighlighter text={transformedText} />
|
|
||||||
) : transformedText
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
250
src/components/ui/text-controls.tsx
Normal file
250
src/components/ui/text-controls.tsx
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
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">Sí-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,4 +19,17 @@ export function splitIntoSyllables(word: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return syllables.length > 0 ? syllables : [word];
|
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);
|
||||||
}
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/features/voice-commands/hooks/useSpeechRecognition.ts
Normal file
100
src/features/voice-commands/hooks/useSpeechRecognition.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/features/voice-commands/utils/audioValidator.ts
Normal file
16
src/features/voice-commands/utils/audioValidator.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// 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())
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/hooks/useSpeechRecognition.ts
Normal file
3
src/hooks/useSpeechRecognition.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function useSpeechRecognition() {
|
||||||
|
// ... implementação
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ArrowLeft, Sparkles } from 'lucide-react';
|
import { ArrowLeft, Sparkles } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { StoryGenerator } from '../../components/story/StoryGenerator';
|
import { StoryGenerator } from '../../components/story/StoryGenerator';
|
||||||
@ -6,14 +6,65 @@ import { useSession } from '../../hooks/useSession';
|
|||||||
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
|
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
|
||||||
import { AdaptiveTitle, AdaptiveParagraph, AdaptiveText } from '../../components/ui/adaptive-text';
|
import { AdaptiveTitle, AdaptiveParagraph, AdaptiveText } from '../../components/ui/adaptive-text';
|
||||||
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
|
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() {
|
export function CreateStoryPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
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 { 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) {
|
if (!session) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@ -72,7 +123,79 @@ export function CreateStoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<StoryGenerator />
|
{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}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-8 p-4 bg-purple-50 rounded-lg">
|
<div className="mt-8 p-4 bg-purple-50 rounded-lg">
|
||||||
<h3 className="text-sm font-medium text-purple-900 mb-2">
|
<h3 className="text-sm font-medium text-purple-900 mb-2">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ArrowLeft, ArrowRight, Volume2, Share2, ChevronDown, ChevronUp, Loader2, Pause, Play, Download, RefreshCw, Trash2, TextSelect } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, Share2, ChevronDown, ChevronUp, Loader2, Download, RefreshCw, Trash2, Type, Eye, EyeOff } from 'lucide-react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { AudioRecorder } from '../../components/story/AudioRecorder';
|
import { AudioRecorder } from '../../components/story/AudioRecorder';
|
||||||
@ -9,10 +9,12 @@ 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';
|
import { ExerciseSuggestions } from '../../components/learning/ExerciseSuggestions';
|
||||||
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
|
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
|
||||||
import { AdaptiveText, AdaptiveTitle, AdaptiveParagraph } from '../../components/ui/adaptive-text';
|
import { AdaptiveText } from '../../components/ui/adaptive-text';
|
||||||
import { useSession } from '../../hooks/useSession';
|
import { useSession } from '../../hooks/useSession';
|
||||||
import { useSyllables } from '../../features/syllables/hooks/useSyllables';
|
import { useSyllables } from '../../features/syllables/hooks/useSyllables';
|
||||||
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
|
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
|
||||||
|
import { TextControls } from '../../components/ui/text-controls';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
|
||||||
interface StoryRecording {
|
interface StoryRecording {
|
||||||
@ -34,9 +36,19 @@ interface StoryRecording {
|
|||||||
transcription: string;
|
transcription: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FocusMode {
|
||||||
|
isActive: boolean;
|
||||||
|
isRecording: boolean;
|
||||||
|
originalState: {
|
||||||
|
isUpperCase: boolean;
|
||||||
|
isHighlighting: boolean;
|
||||||
|
fontSize: number;
|
||||||
|
imageVisible: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
||||||
const [isAudioSupported, setIsAudioSupported] = React.useState(true);
|
const [isAudioSupported, setIsAudioSupported] = React.useState(true);
|
||||||
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||||
const [isConverting, setIsConverting] = React.useState(false);
|
const [isConverting, setIsConverting] = React.useState(false);
|
||||||
@ -72,41 +84,33 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
|||||||
readyState: audioElement.readyState,
|
readyState: audioElement.readyState,
|
||||||
src: audioElement.src
|
src: audioElement.src
|
||||||
});
|
});
|
||||||
setIsPlaying(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlayPause = async () => {
|
const handlePlayPause = async () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
try {
|
try {
|
||||||
if (isPlaying) {
|
if (audioRef.current.ended) {
|
||||||
audioRef.current.pause();
|
audioRef.current.currentTime = 0;
|
||||||
} else {
|
}
|
||||||
if (audioRef.current.ended) {
|
|
||||||
audioRef.current.currentTime = 0;
|
// Verificar se o áudio está pronto
|
||||||
}
|
if (audioRef.current.readyState === 0) {
|
||||||
|
console.log('Recarregando áudio...');
|
||||||
// Verificar se o áudio está pronto
|
await audioRef.current.load();
|
||||||
if (audioRef.current.readyState === 0) {
|
}
|
||||||
console.log('Recarregando áudio...');
|
|
||||||
await audioRef.current.load();
|
const playPromise = audioRef.current.play();
|
||||||
}
|
if (playPromise !== undefined) {
|
||||||
|
playPromise
|
||||||
const playPromise = audioRef.current.play();
|
.then(() => {
|
||||||
if (playPromise !== undefined) {
|
console.log('Reprodução iniciada com sucesso');
|
||||||
playPromise
|
})
|
||||||
.then(() => {
|
.catch(error => {
|
||||||
console.log('Reprodução iniciada com sucesso');
|
console.error('Erro ao reproduzir áudio:', error);
|
||||||
})
|
});
|
||||||
.catch(error => {
|
|
||||||
console.error('Erro ao reproduzir áudio:', error);
|
|
||||||
setIsPlaying(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao manipular áudio:', error);
|
console.error('Erro ao manipular áudio:', error);
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -150,10 +154,11 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
|||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
|
|
||||||
if (audio) {
|
if (audio) {
|
||||||
const handleEnded = () => setIsPlaying(false);
|
const handleEnded = () => {
|
||||||
|
console.log('Reprodução encerrada');
|
||||||
|
};
|
||||||
const handleError = (e: ErrorEvent) => {
|
const handleError = (e: ErrorEvent) => {
|
||||||
console.error('Erro no áudio:', e);
|
console.error('Erro no áudio:', e);
|
||||||
setIsPlaying(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
audio.addEventListener('ended', handleEnded);
|
audio.addEventListener('ended', handleEnded);
|
||||||
@ -236,17 +241,7 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
|||||||
disabled={!recording.audio_url && !mp3Url}
|
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"
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{recording.audio_url || mp3Url ? 'Ouvir' : 'Carregando...'}
|
||||||
<>
|
|
||||||
<Pause className="h-5 w-5" />
|
|
||||||
Pausar
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="h-5 w-5" />
|
|
||||||
{recording.audio_url || mp3Url ? 'Ouvir' : 'Carregando...'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -392,15 +387,99 @@ export function StoryPage() {
|
|||||||
const [currentPage, setCurrentPage] = React.useState(0);
|
const [currentPage, setCurrentPage] = React.useState(0);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
||||||
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
|
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
|
||||||
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
|
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
|
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
|
||||||
const { isHighlighted, toggleHighlight } = useSyllables();
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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(() => {
|
React.useEffect(() => {
|
||||||
const fetchStory = async () => {
|
const fetchStory = async () => {
|
||||||
@ -447,8 +526,6 @@ export function StoryPage() {
|
|||||||
fetchStory();
|
fetchStory();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const fetchRecordings = async () => {
|
const fetchRecordings = async () => {
|
||||||
if (!story?.id) return;
|
if (!story?.id) return;
|
||||||
@ -586,6 +663,55 @@ 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
@ -622,20 +748,63 @@ export function StoryPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<TextCaseToggle
|
|
||||||
isUpperCase={isUpperCase}
|
|
||||||
onToggle={toggleUppercase}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleHighlight}
|
onClick={handleFocusModeToggle}
|
||||||
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"
|
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'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<TextSelect className="h-4 w-4" />
|
{focusMode.isActive ? (
|
||||||
<span className="text-sm font-medium">
|
<>
|
||||||
{isHighlighted ? 'Desativar Sílabas' : 'Ativar Sílabas'}
|
<Eye className="h-4 w-4" />
|
||||||
</span>
|
<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>
|
</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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
{/*
|
||||||
<button
|
<button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
|
||||||
@ -643,78 +812,117 @@ export function StoryPage() {
|
|||||||
<Share2 className="h-5 w-5" />
|
<Share2 className="h-5 w-5" />
|
||||||
Compartilhar
|
Compartilhar
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* História Principal */}
|
{/* Conteúdo Principal */}
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
|
<div className={cn(
|
||||||
{/* Imagem da página atual */}
|
"bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6",
|
||||||
{story?.content?.pages?.[currentPage]?.image && (
|
"transition-all duration-300",
|
||||||
<ImageWithLoading
|
focusMode.isActive && "bg-gray-50"
|
||||||
src={story.content.pages[currentPage].image}
|
)}>
|
||||||
alt={`Página ${currentPage + 1}`}
|
{/* 1. Controles de Texto */}
|
||||||
className="w-full h-full object-cover"
|
<div className={cn(
|
||||||
/>
|
"border-b border-gray-200 pb-6",
|
||||||
)}
|
focusMode.isActive && "opacity-50 pointer-events-none"
|
||||||
|
)}>
|
||||||
<div className="p-8">
|
<TextControls
|
||||||
<AdaptiveTitle
|
|
||||||
text={story?.title || ''}
|
|
||||||
isUpperCase={isUpperCase}
|
isUpperCase={isUpperCase}
|
||||||
className="text-3xl font-bold text-gray-900 mb-6"
|
onToggleUpperCase={toggleUppercase}
|
||||||
/>
|
isSyllablesEnabled={isSyllablesEnabled}
|
||||||
|
onToggleSyllables={toggleSyllables}
|
||||||
{/* Texto da página atual */}
|
fontSize={fontSize}
|
||||||
<AdaptiveParagraph
|
onFontSizeChange={setFontSize}
|
||||||
text={story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
|
readingSpeed={readingSpeed}
|
||||||
isUpperCase={isUpperCase}
|
onReadingSpeedChange={setReadingSpeed}
|
||||||
highlightSyllables={isHighlighted}
|
letterSpacing={letterSpacing}
|
||||||
className="text-xl leading-relaxed text-gray-700 mb-8"
|
onLetterSpacingChange={setLetterSpacing}
|
||||||
|
wordSpacing={wordSpacing}
|
||||||
|
onWordSpacingChange={setWordSpacing}
|
||||||
|
lineHeight={lineHeight}
|
||||||
|
onLineHeightChange={setLineHeight}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isHighlighting={isHighlighting}
|
||||||
|
onToggleHighlight={() => setIsHighlighting(prev => !prev)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Gravador de áudio */}
|
{/* 2. Título da História */}
|
||||||
<AudioRecorder
|
<div className="border-b border-gray-200 pb-6">
|
||||||
storyId={story.id}
|
<h1 className="text-2xl font-bold text-gray-900">{story?.title}</h1>
|
||||||
studentId={story.student_id}
|
</div>
|
||||||
onAudioUploaded={(audioUrl) => {
|
|
||||||
console.log('Áudio gravado:', audioUrl);
|
{/* 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
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{renderHighlightedText(story?.content?.pages?.[currentPage]?.text || '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navegação entre páginas */}
|
{/* 4. Controles de Navegação */}
|
||||||
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
<div className="border-b border-gray-200 pb-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
|
onClick={() => setCurrentPage(prev => prev - 1)}
|
||||||
disabled={currentPage === 0}
|
disabled={currentPage === 0}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
Anterior
|
Página Anterior
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-sm text-gray-500">
|
<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>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(prev => Math.min(story.content.pages.length - 1, prev + 1))}
|
onClick={() => setCurrentPage(prev => prev + 1)}
|
||||||
disabled={currentPage === story.content.pages.length - 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"
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Próxima
|
Próxima Página
|
||||||
<ArrowRight className="h-5 w-5" />
|
<ArrowRight className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Dashboard de métricas */}
|
{/* Dashboard de métricas */}
|
||||||
|
|||||||
100
src/styles/focus-mode.css
Normal file
100
src/styles/focus-mode.css
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/* 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
Normal file
35
src/types/speech.d.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
3
src/utils/audio/audioProcessor.ts
Normal file
3
src/utils/audio/audioProcessor.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function normalizeAudio(buffer: ArrayBuffer) {
|
||||||
|
// ... implementação
|
||||||
|
}
|
||||||
@ -15,6 +15,16 @@ interface StoryPrompt {
|
|||||||
difficulty: 'easy' | 'medium' | 'hard';
|
difficulty: 'easy' | 'medium' | 'hard';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EnhancedPayload {
|
||||||
|
// Campos existentes
|
||||||
|
voice_context?: string;
|
||||||
|
audio_metadata?: {
|
||||||
|
duration: number;
|
||||||
|
sample_rate: number;
|
||||||
|
language: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = [
|
const ALLOWED_ORIGINS = [
|
||||||
'http://localhost:5173', // Vite dev server
|
'http://localhost:5173', // Vite dev server
|
||||||
'http://localhost:3000', // Caso use outro port
|
'http://localhost:3000', // Caso use outro port
|
||||||
@ -60,8 +70,8 @@ serve(async (req) => {
|
|||||||
return new Response('ok', { headers: corsHeaders })
|
return new Response('ok', { headers: corsHeaders })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { record } = await req.json()
|
const { voice_context, ...rest } = await req.json()
|
||||||
console.log('[Request]', record)
|
console.log('[Request]', rest)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const supabase = createClient(
|
const supabase = createClient(
|
||||||
@ -72,10 +82,10 @@ serve(async (req) => {
|
|||||||
|
|
||||||
console.log('[DB] Buscando categorias...')
|
console.log('[DB] Buscando categorias...')
|
||||||
const [themeResult, subjectResult, characterResult, settingResult] = await Promise.all([
|
const [themeResult, subjectResult, characterResult, settingResult] = await Promise.all([
|
||||||
supabase.from('story_themes').select('*').eq('id', record.theme_id).single(),
|
supabase.from('story_themes').select('*').eq('id', rest.theme_id).single(),
|
||||||
supabase.from('story_subjects').select('*').eq('id', record.subject_id).single(),
|
supabase.from('story_subjects').select('*').eq('id', rest.subject_id).single(),
|
||||||
supabase.from('story_characters').select('*').eq('id', record.character_id).single(),
|
supabase.from('story_characters').select('*').eq('id', rest.character_id).single(),
|
||||||
supabase.from('story_settings').select('*').eq('id', record.setting_id).single()
|
supabase.from('story_settings').select('*').eq('id', rest.setting_id).single()
|
||||||
])
|
])
|
||||||
|
|
||||||
console.log('[DB] Resultados das consultas:', {
|
console.log('[DB] Resultados das consultas:', {
|
||||||
@ -90,10 +100,10 @@ serve(async (req) => {
|
|||||||
if (characterResult.error) throw new Error(`Erro ao buscar personagem: ${characterResult.error.message}`);
|
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 (settingResult.error) throw new Error(`Erro ao buscar cenário: ${settingResult.error.message}`);
|
||||||
|
|
||||||
if (!themeResult.data) throw new Error(`Tema não encontrado: ${record.theme_id}`);
|
if (!themeResult.data) throw new Error(`Tema não encontrado: ${rest.theme_id}`);
|
||||||
if (!subjectResult.data) throw new Error(`Disciplina não encontrada: ${record.subject_id}`);
|
if (!subjectResult.data) throw new Error(`Disciplina não encontrada: ${rest.subject_id}`);
|
||||||
if (!characterResult.data) throw new Error(`Personagem não encontrado: ${record.character_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: ${record.setting_id}`);
|
if (!settingResult.data) throw new Error(`Cenário não encontrado: ${rest.setting_id}`);
|
||||||
|
|
||||||
const theme = themeResult.data;
|
const theme = themeResult.data;
|
||||||
const subject = subjectResult.data;
|
const subject = subjectResult.data;
|
||||||
@ -103,68 +113,7 @@ serve(async (req) => {
|
|||||||
console.log('[Validation] Categorias validadas com sucesso')
|
console.log('[Validation] Categorias validadas com sucesso')
|
||||||
|
|
||||||
console.log('[GPT] Construindo prompt...')
|
console.log('[GPT] Construindo prompt...')
|
||||||
const prompt = `
|
const prompt = buildPrompt(rest, voice_context);
|
||||||
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] Prompt construído:', prompt)
|
||||||
|
|
||||||
console.log('[GPT] Iniciando geração da história...')
|
console.log('[GPT] Iniciando geração da história...')
|
||||||
@ -213,7 +162,7 @@ serve(async (req) => {
|
|||||||
const imageBuffer = await imageRes.arrayBuffer()
|
const imageBuffer = await imageRes.arrayBuffer()
|
||||||
|
|
||||||
// Gerar nome único para o arquivo
|
// Gerar nome único para o arquivo
|
||||||
const fileName = `${record.id}/page-${index + 1}-${Date.now()}.png`
|
const fileName = `${rest.id}/page-${index + 1}-${Date.now()}.png`
|
||||||
|
|
||||||
// Salvar no Storage do Supabase
|
// Salvar no Storage do Supabase
|
||||||
console.log(`[Storage] Salvando imagem ${index + 1} no bucket...`)
|
console.log(`[Storage] Salvando imagem ${index + 1} no bucket...`)
|
||||||
@ -258,10 +207,10 @@ serve(async (req) => {
|
|||||||
subject_id: subject.id,
|
subject_id: subject.id,
|
||||||
character_id: character.id,
|
character_id: character.id,
|
||||||
setting_id: setting.id,
|
setting_id: setting.id,
|
||||||
context: record.context,
|
context: rest.context,
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
.eq('id', record.id)
|
.eq('id', rest.id)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@ -272,7 +221,7 @@ serve(async (req) => {
|
|||||||
.from('story_pages')
|
.from('story_pages')
|
||||||
.insert(
|
.insert(
|
||||||
pages.map((page, index) => ({
|
pages.map((page, index) => ({
|
||||||
story_id: record.id,
|
story_id: rest.id,
|
||||||
page_number: index + 1,
|
page_number: index + 1,
|
||||||
text: page.text,
|
text: page.text,
|
||||||
image_url: page.image,
|
image_url: page.image,
|
||||||
@ -286,7 +235,7 @@ serve(async (req) => {
|
|||||||
const { error: genError } = await supabase
|
const { error: genError } = await supabase
|
||||||
.from('story_generations')
|
.from('story_generations')
|
||||||
.insert({
|
.insert({
|
||||||
story_id: record.id,
|
story_id: rest.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-4o-mini'
|
||||||
@ -301,7 +250,7 @@ serve(async (req) => {
|
|||||||
*,
|
*,
|
||||||
pages:story_pages(*)
|
pages:story_pages(*)
|
||||||
`)
|
`)
|
||||||
.eq('id', record.id)
|
.eq('id', rest.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
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}`);
|
||||||
@ -318,7 +267,7 @@ serve(async (req) => {
|
|||||||
if (!pageWithWord) return null;
|
if (!pageWithWord) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
story_id: record.id,
|
story_id: rest.id,
|
||||||
word,
|
word,
|
||||||
exercise_type: type,
|
exercise_type: type,
|
||||||
phonemes: pageWithWord?.phonemes || null,
|
phonemes: pageWithWord?.phonemes || null,
|
||||||
@ -364,4 +313,77 @@ 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` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user