mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-17 05:47:52 +00:00
feat: reorganiza estrutura de métricas e feedback de leitura
- Exporta interface MetricsData do StoryMetrics para reuso - Adiciona importação da interface no StoryPage - Mantém consistência de tipos entre gravações e métricas - Melhora organização do feedback em colunas - Implementa layout responsivo para diferentes tamanhos de tela
This commit is contained in:
parent
797967ca5b
commit
1132f7438d
79
n8n.js
79
n8n.js
@ -1,79 +0,0 @@
|
|||||||
// Estrutura do fluxo
|
|
||||||
[
|
|
||||||
{
|
|
||||||
// 1. Webhook Trigger
|
|
||||||
"name": "Webhook",
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
|
||||||
"parameters": {
|
|
||||||
"path": "audio-processing",
|
|
||||||
"responseMode": "lastNode"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 2. Download do Áudio do Supabase
|
|
||||||
"name": "Supabase",
|
|
||||||
"type": "n8n-nodes-base.supabase",
|
|
||||||
"parameters": {
|
|
||||||
"operation": "download",
|
|
||||||
"bucket": "audios",
|
|
||||||
"filePath": "={{$json.file_path}}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 3. Pré-processamento do Áudio (usando FFmpeg)
|
|
||||||
"name": "FFmpeg",
|
|
||||||
"type": "n8n-nodes-base.executeCommand",
|
|
||||||
"parameters": {
|
|
||||||
"command": "ffmpeg -i input.wav -af 'anlmdn,highpass=f=200,lowpass=f=3000,silenceremove=1:0:-50dB' output.wav"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 4. Transcrição (usando OpenAI Whisper)
|
|
||||||
"name": "Whisper",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"parameters": {
|
|
||||||
"url": "https://api.openai.com/v1/audio/transcriptions",
|
|
||||||
"method": "POST",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer {{$env.OPENAI_API_KEY}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 5. Análise do Texto (usando GPT-4)
|
|
||||||
"name": "GPT4Analysis",
|
|
||||||
"type": "n8n-nodes-base.openAi",
|
|
||||||
"parameters": {
|
|
||||||
"model": "gpt-4",
|
|
||||||
"prompt": `Analise a seguinte transcrição considerando:
|
|
||||||
1. Fluência (velocidade, pausas, prosódia)
|
|
||||||
2. Pronúncia (precisão fonética, clareza)
|
|
||||||
3. Erros (substituições, omissões)
|
|
||||||
4. Compreensão (coerência, autocorreção)
|
|
||||||
|
|
||||||
Transcrição: {{$node.Whisper.data.text}}
|
|
||||||
|
|
||||||
Forneça uma análise detalhada seguindo as métricas especificadas.`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 6. Salvar Resultados no Supabase
|
|
||||||
"name": "SaveResults",
|
|
||||||
"type": "n8n-nodes-base.supabase",
|
|
||||||
"parameters": {
|
|
||||||
"operation": "insert",
|
|
||||||
"table": "audio_analysis",
|
|
||||||
"data": {
|
|
||||||
"audio_path": "={{$json.file_path}}",
|
|
||||||
"transcription": "={{$node.Whisper.data.text}}",
|
|
||||||
"analysis": "={{$node.GPT4Analysis.data.choices[0].text}}",
|
|
||||||
"metrics": {
|
|
||||||
"fluency": "={{$node.GPT4Analysis.data.metrics.fluency}}",
|
|
||||||
"pronunciation": "={{$node.GPT4Analysis.data.metrics.pronunciation}}",
|
|
||||||
"errors": "={{$node.GPT4Analysis.data.metrics.errors}}",
|
|
||||||
"comprehension": "={{$node.GPT4Analysis.data.metrics.comprehension}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
123
src/components/story/RecordingHistoryCard.tsx
Normal file
123
src/components/story/RecordingHistoryCard.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion';
|
||||||
|
|
||||||
|
export function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
|
||||||
|
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' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const details = [
|
||||||
|
{ label: 'Palavras por minuto', value: recording.words_per_minute },
|
||||||
|
{ label: 'Pausas', value: recording.pause_count },
|
||||||
|
{ label: 'Erros', value: recording.error_count },
|
||||||
|
{ label: 'Autocorreções', value: recording.self_corrections }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion type="single" collapsible className="bg-white rounded-lg border border-gray-200">
|
||||||
|
<AccordionItem value={recording.id}>
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline hover:bg-gray-50">
|
||||||
|
<div className="w-full">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
|
||||||
|
<AccordionContent className="px-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Coluna 1: Detalhes Técnicos */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h5 className="text-sm font-medium text-gray-900">Detalhes Técnicos</h5>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{details.map((detail) => (
|
||||||
|
<div key={detail.label} className="bg-gray-50 p-3 rounded-lg">
|
||||||
|
<span className="text-xs text-gray-500 block">
|
||||||
|
{detail.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{detail.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coluna 2: Pontos Fortes e Melhorias */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-medium text-green-600 mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-green-600 rounded-full" />
|
||||||
|
Pontos Fortes
|
||||||
|
</h5>
|
||||||
|
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||||
|
{recording.strengths.map((strength, i) => (
|
||||||
|
<li key={i} className="leading-relaxed">{strength}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-medium text-orange-600 mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-orange-600 rounded-full" />
|
||||||
|
Pontos para Melhorar
|
||||||
|
</h5>
|
||||||
|
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||||
|
{recording.improvements.map((improvement, i) => (
|
||||||
|
<li key={i} className="leading-relaxed">{improvement}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coluna 3: Sugestões e Próximos Passos */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-medium text-blue-600 mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-blue-600 rounded-full" />
|
||||||
|
Sugestões
|
||||||
|
</h5>
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed bg-blue-50 p-3 rounded-lg">
|
||||||
|
{recording.suggestions}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-medium text-purple-600 mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-purple-600 rounded-full" />
|
||||||
|
Próxima Meta
|
||||||
|
</h5>
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed bg-purple-50 p-3 rounded-lg">
|
||||||
|
Tente alcançar {Math.min(100, recording.fluency_score + 5)}% de fluência na próxima leitura.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/components/story/StoryMetrics.tsx
Normal file
142
src/components/story/StoryMetrics.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Activity, Book, Mic, Brain } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface MetricsData {
|
||||||
|
metrics: {
|
||||||
|
fluency: number;
|
||||||
|
pronunciation: number;
|
||||||
|
accuracy: number;
|
||||||
|
comprehension: number;
|
||||||
|
};
|
||||||
|
feedback: {
|
||||||
|
strengths: string[];
|
||||||
|
improvements: string[];
|
||||||
|
suggestions: string;
|
||||||
|
};
|
||||||
|
details: {
|
||||||
|
wordsPerMinute: number;
|
||||||
|
pauseCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
selfCorrections: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoryMetricsProps {
|
||||||
|
data?: MetricsData;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoryMetrics({ data, isLoading }: StoryMetricsProps): JSX.Element {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="h-24 bg-gray-100 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-6 text-center mb-6">
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Aguardando gravação para gerar métricas de leitura...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{
|
||||||
|
label: 'Fluência',
|
||||||
|
value: data.metrics.fluency,
|
||||||
|
icon: Activity,
|
||||||
|
color: 'text-blue-600',
|
||||||
|
detail: `${data.details.wordsPerMinute} palavras/min`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pronúncia',
|
||||||
|
value: data.metrics.pronunciation,
|
||||||
|
icon: Mic,
|
||||||
|
color: 'text-green-600',
|
||||||
|
detail: `${data.details.errorCount} erros`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Precisão',
|
||||||
|
value: data.metrics.accuracy,
|
||||||
|
icon: Book,
|
||||||
|
color: 'text-purple-600',
|
||||||
|
detail: `${data.details.selfCorrections} autocorreções`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Compreensão',
|
||||||
|
value: data.metrics.comprehension,
|
||||||
|
icon: Brain,
|
||||||
|
color: 'text-orange-600',
|
||||||
|
detail: `${data.details.pauseCount} pausas`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 mb-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<div
|
||||||
|
key={metric.label}
|
||||||
|
className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<metric.icon className={`w-5 h-5 ${metric.color}`} />
|
||||||
|
<span className="text-2xl font-bold">{metric.value}%</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-600">{metric.label}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{metric.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||||
|
<h3 className="font-medium mb-4">Feedback da Leitura</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-green-600 mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-green-600 rounded-full" />
|
||||||
|
Pontos Fortes
|
||||||
|
</h4>
|
||||||
|
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||||
|
{data.feedback.strengths.map((strength, i) => (
|
||||||
|
<li key={i} className="leading-relaxed">{strength}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-orange-600 mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-orange-600 rounded-full" />
|
||||||
|
Pontos para Melhorar
|
||||||
|
</h4>
|
||||||
|
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||||
|
{data.feedback.improvements.map((improvement, i) => (
|
||||||
|
<li key={i} className="leading-relaxed">{improvement}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-blue-600 mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 bg-blue-600 rounded-full" />
|
||||||
|
Sugestões
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed">
|
||||||
|
{data.feedback.suggestions}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/ui/accordion.tsx
Normal file
54
src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const Accordion = AccordionPrimitive.Root;
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn('border-b', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AccordionItem.displayName = 'AccordionItem';
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-gray-500 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
));
|
||||||
|
AccordionTrigger.displayName = 'AccordionTrigger';
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
));
|
||||||
|
AccordionContent.displayName = 'AccordionContent';
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
@ -1,9 +1,129 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp } 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';
|
||||||
import type { Story } from '../../types/database';
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StoryPage() {
|
export function StoryPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@ -13,6 +133,10 @@ export function StoryPage() {
|
|||||||
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 [isPlaying, setIsPlaying] = React.useState(false);
|
||||||
|
const [metrics, setMetrics] = React.useState<MetricsData | undefined>();
|
||||||
|
const [loadingMetrics, setLoadingMetrics] = React.useState(true);
|
||||||
|
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
|
||||||
|
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const fetchStory = async () => {
|
const fetchStory = async () => {
|
||||||
@ -39,6 +163,53 @@ export function StoryPage() {
|
|||||||
fetchStory();
|
fetchStory();
|
||||||
}, [id]);
|
}, [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 () => {
|
const handleShare = async () => {
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
@ -53,6 +224,28 @@ export function StoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
@ -105,6 +298,40 @@ export function StoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
{/* Imagem da página atual */}
|
{/* Imagem da página atual */}
|
||||||
{story.content.pages[currentPage].image && (
|
{story.content.pages[currentPage].image && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user