mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-16 21:37:51 +00:00
feat: implementa Modo Foco para grava����o de leitura - Adiciona ativa����o/desativa����o autom��tica, integra AudioRecorder, interface adaptativa e atualiza CHANGELOG
This commit is contained in:
parent
e5204e0430
commit
dadcb048bb
46
CHANGELOG.md
46
CHANGELOG.md
@ -78,6 +78,23 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
||||
|
||||
## [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
|
||||
@ -95,23 +112,24 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
||||
- Feedback visual durante o processamento do áudio
|
||||
- Inicialização de métricas zeradas para novas gravações
|
||||
|
||||
## [1.2.0] - 2024-05-22
|
||||
## [1.2.0] - 2024-03-21
|
||||
|
||||
### Adicionado
|
||||
- Criação de histórias por comando de voz
|
||||
- Componente `VoiceCommandButton` para gravação de áudio
|
||||
- Hook `useSpeechRecognition` para reconhecimento de voz
|
||||
- Sistema de validação de conteúdo de áudio
|
||||
- Integração com geração de histórias por IA
|
||||
- Documentação de recursos de voz
|
||||
- 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
|
||||
- Fluxo de criação de histórias para suportar entrada por voz
|
||||
- Interface do gerador de histórias com novo modo de entrada
|
||||
- Melhorias na experiência do usuário para gravação
|
||||
- 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
|
||||
- Implementação de reconhecimento de voz com Web Speech API
|
||||
- Sistema de validação de conteúdo sensível em transcrições
|
||||
- Otimização do processamento de comandos de voz
|
||||
- Melhorias na segurança do processamento de áudio
|
||||
- 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
|
||||
|
||||
@ -2,15 +2,27 @@ import React, { useState, useRef } from 'react';
|
||||
import { Mic, Square, Loader, Play, Upload } from 'lucide-react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useStudentTracking } from '../../hooks/useStudentTracking';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface AudioRecorderProps {
|
||||
storyId: string;
|
||||
studentId: string;
|
||||
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 [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@ -18,11 +30,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const { trackAudioRecorded } = useStudentTracking();
|
||||
const startTime = React.useRef<number | null>(null);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
if (!focusModeActive && onFocusModeToggle) {
|
||||
onFocusModeToggle();
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
@ -34,12 +49,14 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
mediaRecorderRef.current.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
setAudioBlob(audioBlob);
|
||||
onRecordingStop?.();
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.start();
|
||||
startTime.current = Date.now();
|
||||
setIsRecording(true);
|
||||
setError(null);
|
||||
onRecordingStart?.();
|
||||
} catch (err) {
|
||||
setError('Erro ao acessar microfone. Verifique as permissões.');
|
||||
console.error('Erro ao iniciar gravação:', err);
|
||||
@ -51,8 +68,12 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
|
||||
// Parar todas as tracks do stream
|
||||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
// Desativar modo foco ao parar a gravação
|
||||
if (focusModeActive && onFocusModeToggle) {
|
||||
onFocusModeToggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -112,7 +133,7 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
// 3. Criar o registro com a URL do áudio
|
||||
const { data: recordData, error: recordError } = await supabase
|
||||
const { error: recordError } = await supabase
|
||||
.from('story_recordings')
|
||||
.insert({
|
||||
id: fileId,
|
||||
@ -152,15 +173,23 @@ export function AudioRecorder({ storyId, studentId, onAudioUploaded }: AudioReco
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg shadow">
|
||||
<div className={cn(
|
||||
"p-4 bg-white rounded-lg shadow",
|
||||
focusModeActive && "bg-purple-50"
|
||||
)}>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
{!isRecording && !audioBlob && (
|
||||
<button
|
||||
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" />
|
||||
Iniciar Gravação
|
||||
{focusModeActive ? "Iniciar Leitura" : "Iniciar Gravação"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, ArrowRight, Share2, ChevronDown, ChevronUp, Loader2, Download, RefreshCw, Trash2, Type } 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 { supabase } from '../../lib/supabase';
|
||||
import { AudioRecorder } from '../../components/story/AudioRecorder';
|
||||
@ -36,6 +36,17 @@ interface StoryRecording {
|
||||
transcription: string;
|
||||
}
|
||||
|
||||
interface FocusMode {
|
||||
isActive: boolean;
|
||||
isRecording: boolean;
|
||||
originalState: {
|
||||
isUpperCase: boolean;
|
||||
isHighlighting: boolean;
|
||||
fontSize: number;
|
||||
imageVisible: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [isAudioSupported, setIsAudioSupported] = React.useState(true);
|
||||
@ -384,13 +395,23 @@ export function StoryPage() {
|
||||
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
|
||||
const { isHighlighted: isSyllablesEnabled, toggleHighlight: toggleSyllables } = useSyllables();
|
||||
const [fontSize, setFontSize] = useState(18);
|
||||
const [readingSpeed, setReadingSpeed] = useState(120); // 120 palavras por minuto
|
||||
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+/);
|
||||
@ -642,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) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
@ -678,6 +748,27 @@ export function StoryPage() {
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleFocusModeToggle}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
|
||||
'text-gray-600 hover:text-gray-900 border border-gray-200',
|
||||
focusMode.isActive && 'bg-purple-50 text-purple-600 border-purple-200'
|
||||
)}
|
||||
>
|
||||
{focusMode.isActive ? (
|
||||
<>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Sair do Modo Foco</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Modo Foco</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<TextCaseToggle
|
||||
isUpperCase={isUpperCase}
|
||||
onToggle={toggleUppercase}
|
||||
@ -703,9 +794,16 @@ export function StoryPage() {
|
||||
</div>
|
||||
|
||||
{/* Conteúdo Principal */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
<div className={cn(
|
||||
"bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6",
|
||||
"transition-all duration-300",
|
||||
focusMode.isActive && "bg-gray-50"
|
||||
)}>
|
||||
{/* 1. Controles de Texto */}
|
||||
<div className="border-b border-gray-200 pb-6">
|
||||
<div className={cn(
|
||||
"border-b border-gray-200 pb-6",
|
||||
focusMode.isActive && "opacity-50 pointer-events-none"
|
||||
)}>
|
||||
<TextControls
|
||||
isUpperCase={isUpperCase}
|
||||
onToggleUpperCase={toggleUppercase}
|
||||
@ -774,25 +872,29 @@ export function StoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. Imagem da História */}
|
||||
<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>
|
||||
{/* 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">Gravação de Áudio</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{focusMode.isActive ? "Modo Foco Ativado" : "Gravação de Áudio"}
|
||||
</h2>
|
||||
<AudioRecorder
|
||||
storyId={story.id}
|
||||
studentId={session?.user?.id || ''}
|
||||
@ -816,7 +918,16 @@ export function StoryPage() {
|
||||
transcription: ''
|
||||
};
|
||||
setRecordings(prev => [newRecording, ...prev]);
|
||||
|
||||
// Desativar modo foco após finalizar gravação
|
||||
if (focusMode.isActive) {
|
||||
handleFocusModeToggle();
|
||||
}
|
||||
}}
|
||||
onRecordingStart={handleRecordingStart}
|
||||
onRecordingStop={handleRecordingStop}
|
||||
focusModeActive={focusMode.isActive}
|
||||
onFocusModeToggle={handleFocusModeToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user