mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-18 22:37:51 +00:00
- Adiciona tipagem para cover na interface Story - Atualiza queries para usar story_pages como capa - Usa página 1 como capa padrão das histórias - Otimiza carregamento de imagens com parâmetros
452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { supabase } from '../../lib/supabase';
|
|
import { AudioRecorder } from '../../components/story/AudioRecorder';
|
|
import type { Story } from '../../types/database';
|
|
import { StoryMetrics } from '../../components/story/StoryMetrics';
|
|
import type { MetricsData } from '../../components/story/StoryMetrics';
|
|
|
|
interface StoryRecording {
|
|
id: string;
|
|
fluency_score: number;
|
|
pronunciation_score: number;
|
|
accuracy_score: number;
|
|
comprehension_score: number;
|
|
words_per_minute: number;
|
|
pause_count: number;
|
|
error_count: number;
|
|
self_corrections: number;
|
|
strengths: string[];
|
|
improvements: string[];
|
|
suggestions: string;
|
|
created_at: string;
|
|
processed_at: string | null;
|
|
}
|
|
|
|
function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
|
|
const metrics = [
|
|
{ label: 'Fluência', value: recording.fluency_score, color: 'text-blue-600' },
|
|
{ label: 'Pronúncia', value: recording.pronunciation_score, color: 'text-green-600' },
|
|
{ label: 'Precisão', value: recording.accuracy_score, color: 'text-purple-600' },
|
|
{ label: 'Compreensão', value: recording.comprehension_score, color: 'text-orange-600' }
|
|
];
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
{/* Cabeçalho sempre visível */}
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="w-full p-4 text-left hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-sm text-gray-500">
|
|
{new Date(recording.created_at).toLocaleDateString('pt-BR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}
|
|
</span>
|
|
{isExpanded ? (
|
|
<ChevronUp className="w-5 h-5 text-gray-400" />
|
|
) : (
|
|
<ChevronDown className="w-5 h-5 text-gray-400" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Grid de métricas */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
{metrics.map((metric) => (
|
|
<div key={metric.label} className="flex flex-col">
|
|
<span className={`text-sm font-medium ${metric.color}`}>
|
|
{metric.label}
|
|
</span>
|
|
<span className="text-lg font-semibold">
|
|
{metric.value}%
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</button>
|
|
|
|
{/* Conteúdo expandido */}
|
|
{isExpanded && (
|
|
<div className="px-4 pb-4 border-t border-gray-100">
|
|
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
|
|
<div>
|
|
<span className="text-gray-500">Palavras por minuto:</span>
|
|
<span className="ml-2 font-medium">{recording.words_per_minute}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">Pausas:</span>
|
|
<span className="ml-2 font-medium">{recording.pause_count}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">Erros:</span>
|
|
<span className="ml-2 font-medium">{recording.error_count}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">Autocorreções:</span>
|
|
<span className="ml-2 font-medium">{recording.self_corrections}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
<div>
|
|
<h5 className="text-sm font-medium text-green-600 mb-1">Pontos Fortes</h5>
|
|
<ul className="list-disc list-inside text-sm text-gray-600">
|
|
{recording.strengths.map((strength, i) => (
|
|
<li key={i}>{strength}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<h5 className="text-sm font-medium text-orange-600 mb-1">Pontos para Melhorar</h5>
|
|
<ul className="list-disc list-inside text-sm text-gray-600">
|
|
{recording.improvements.map((improvement, i) => (
|
|
<li key={i}>{improvement}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<h5 className="text-sm font-medium text-blue-600 mb-1">Sugestões</h5>
|
|
<p className="text-sm text-gray-600">{recording.suggestions}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ImageWithLoading({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState(false);
|
|
|
|
return (
|
|
<div className="relative aspect-video bg-gray-100">
|
|
{isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
|
|
</div>
|
|
)}
|
|
<img
|
|
src={src}
|
|
alt={alt}
|
|
loading="lazy"
|
|
className={`w-full h-full object-cover transition-opacity duration-300 ${
|
|
isLoading ? 'opacity-0' : 'opacity-100'
|
|
} ${className}`}
|
|
onLoad={() => setIsLoading(false)}
|
|
onError={() => {
|
|
setError(true);
|
|
setIsLoading(false);
|
|
}}
|
|
/>
|
|
{error && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
|
<p className="text-gray-500">Erro ao carregar imagem</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StoryPage() {
|
|
const navigate = useNavigate();
|
|
const { id } = useParams();
|
|
const [story, setStory] = React.useState<Story | null>(null);
|
|
const [currentPage, setCurrentPage] = React.useState(0);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
|
|
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
|
|
const [metrics, setMetrics] = React.useState<MetricsData | null>(null);
|
|
const [loadingMetrics, setLoadingMetrics] = React.useState(true);
|
|
|
|
React.useEffect(() => {
|
|
const fetchStory = async () => {
|
|
try {
|
|
if (!id) throw new Error('ID da história não fornecido');
|
|
|
|
// Buscar história e suas páginas
|
|
const { data: storyData, error: storyError } = await supabase
|
|
.from('stories')
|
|
.select(`
|
|
*,
|
|
story_pages (
|
|
id,
|
|
page_number,
|
|
text,
|
|
image_url
|
|
)
|
|
`)
|
|
.eq('id', id)
|
|
.single();
|
|
|
|
if (storyError) throw storyError;
|
|
|
|
// Ordenar páginas por número
|
|
const orderedPages = storyData.story_pages.sort((a: { page_number: number }, b: { page_number: number }) => a.page_number - b.page_number);
|
|
setStory({
|
|
...storyData,
|
|
content: {
|
|
pages: orderedPages.map((page: { text: string; image_url: string }) => ({
|
|
text: page.text,
|
|
image: page.image_url
|
|
}))
|
|
}
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error('Erro ao carregar história:', err);
|
|
setError('Não foi possível carregar a história');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchStory();
|
|
}, [id]);
|
|
|
|
React.useEffect(() => {
|
|
const fetchMetrics = async () => {
|
|
if (!story?.id) return;
|
|
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('reading_metrics')
|
|
.select('*')
|
|
.eq('story_id', story.id)
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
setMetrics(data);
|
|
} catch (err) {
|
|
console.error('Erro ao carregar métricas:', err);
|
|
} finally {
|
|
setLoadingMetrics(false);
|
|
}
|
|
};
|
|
|
|
fetchMetrics();
|
|
}, [story?.id]);
|
|
|
|
React.useEffect(() => {
|
|
const fetchRecordings = async () => {
|
|
if (!story?.id) return;
|
|
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('story_recordings')
|
|
.select('*')
|
|
.eq('story_id', story.id)
|
|
.eq('status', 'completed')
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (error) throw error;
|
|
setRecordings(data || []);
|
|
} catch (err) {
|
|
console.error('Erro ao carregar gravações:', err);
|
|
} finally {
|
|
setLoadingRecordings(false);
|
|
}
|
|
};
|
|
|
|
fetchRecordings();
|
|
}, [story?.id]);
|
|
|
|
const handleShare = async () => {
|
|
if (navigator.share) {
|
|
try {
|
|
await navigator.share({
|
|
title: story?.title,
|
|
text: 'Confira minha história!',
|
|
url: window.location.href
|
|
});
|
|
} catch (err) {
|
|
console.error('Erro ao compartilhar:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getLatestRecording = () => recordings[0];
|
|
|
|
const formatMetricsData = (recording: StoryRecording) => ({
|
|
metrics: {
|
|
fluency: recording.fluency_score,
|
|
pronunciation: recording.pronunciation_score,
|
|
accuracy: recording.accuracy_score,
|
|
comprehension: recording.comprehension_score
|
|
},
|
|
feedback: {
|
|
strengths: recording.strengths,
|
|
improvements: recording.improvements,
|
|
suggestions: recording.suggestions
|
|
},
|
|
details: {
|
|
wordsPerMinute: recording.words_per_minute,
|
|
pauseCount: recording.pause_count,
|
|
errorCount: recording.error_count,
|
|
selfCorrections: recording.self_corrections
|
|
}
|
|
});
|
|
|
|
// Pré-carregar próxima imagem
|
|
useEffect(() => {
|
|
const nextImageUrl = story?.content?.pages?.[currentPage + 1]?.image;
|
|
if (nextImageUrl) {
|
|
const nextImage = new Image();
|
|
nextImage.src = nextImageUrl;
|
|
}
|
|
}, [currentPage, story]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="animate-pulse">
|
|
<div className="h-96 bg-gray-200 rounded-xl mb-8" />
|
|
<div className="h-20 bg-gray-200 rounded-xl" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !story) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<div className="text-red-500 mb-4">{error}</div>
|
|
<button
|
|
onClick={() => navigate('/aluno/historias')}
|
|
className="text-purple-600 hover:text-purple-700"
|
|
>
|
|
Voltar para histórias
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-6">
|
|
<button
|
|
onClick={() => navigate('/aluno/historias')}
|
|
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
Voltar para histórias
|
|
</button>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={handleShare}
|
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
|
|
>
|
|
<Share2 className="h-5 w-5" />
|
|
Compartilhar
|
|
</button>
|
|
<button
|
|
onClick={() => setIsPlaying(!isPlaying)}
|
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
|
|
>
|
|
<Volume2 className="h-5 w-5" />
|
|
{isPlaying ? 'Pausar' : 'Ouvir'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dashboard de métricas */}
|
|
{loadingRecordings ? (
|
|
<div className="animate-pulse">
|
|
<div className="h-48 bg-gray-100 rounded-lg mb-6" />
|
|
</div>
|
|
) : recordings.length > 0 ? (
|
|
<StoryMetrics
|
|
data={formatMetricsData(getLatestRecording())}
|
|
isLoading={false}
|
|
/>
|
|
) : (
|
|
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
|
|
<p className="text-gray-600">
|
|
Você ainda não tem gravações para esta história.
|
|
Faça sua primeira gravação para ver suas métricas!
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Histórico de gravações */}
|
|
{recordings.length > 1 && (
|
|
<div className="mb-6">
|
|
<h3 className="text-lg font-medium mb-4">Histórico de Gravações</h3>
|
|
<div className="space-y-4">
|
|
{recordings.slice(1).map((recording) => (
|
|
<RecordingHistoryCard
|
|
key={recording.id}
|
|
recording={recording}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
{/* Imagem da página atual */}
|
|
{story?.content?.pages?.[currentPage]?.image && (
|
|
<ImageWithLoading
|
|
src={story.content.pages[currentPage].image}
|
|
alt={`Página ${currentPage + 1}`}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
|
|
<div className="p-6">
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">{story?.title}</h1>
|
|
|
|
{/* Texto da página atual */}
|
|
<p className="text-lg text-gray-700 mb-8">
|
|
{story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
|
|
</p>
|
|
|
|
{/* Gravador de áudio */}
|
|
<AudioRecorder
|
|
storyId={story.id}
|
|
studentId={story.student_id}
|
|
onAudioUploaded={(audioUrl) => {
|
|
console.log('Áudio gravado:', audioUrl);
|
|
}}
|
|
/>
|
|
|
|
{/* Navegação entre páginas */}
|
|
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
|
<button
|
|
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
|
|
disabled={currentPage === 0}
|
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
Anterior
|
|
</button>
|
|
|
|
<span className="text-sm text-gray-500">
|
|
Página {currentPage + 1} de {story.content.pages.length}
|
|
</span>
|
|
|
|
<button
|
|
onClick={() => setCurrentPage(prev => Math.min(story.content.pages.length - 1, prev + 1))}
|
|
disabled={currentPage === story.content.pages.length - 1}
|
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
|
>
|
|
Próxima
|
|
<ArrowRight className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|