story-generator/src/pages/student-dashboard/StoryPage.tsx
Lucas Santana db38deb0fa refactor: atualiza interface de capa das histórias
- 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
2024-12-23 18:21:32 -03:00

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>
);
}