[OQI-002] feat: Add education progress and assessment components
- CourseProgressTracker: Comprehensive progress visualization - LearningPathVisualizer: Visual roadmap for learning paths - VideoProgressPlayer: Enhanced video player with bookmarks and controls - AssessmentSummaryCard: Quiz results analysis and review Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7ac32466be
commit
cbb6637966
465
src/modules/education/components/AssessmentSummaryCard.tsx
Normal file
465
src/modules/education/components/AssessmentSummaryCard.tsx
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
/**
|
||||||
|
* AssessmentSummaryCard Component
|
||||||
|
* Comprehensive quiz/assessment results and analysis
|
||||||
|
* OQI-002: Modulo Educativo
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Trophy,
|
||||||
|
Award,
|
||||||
|
Target,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
BarChart3,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Lightbulb,
|
||||||
|
BookOpen,
|
||||||
|
AlertCircle,
|
||||||
|
Star,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export interface QuestionResult {
|
||||||
|
id: string;
|
||||||
|
questionText: string;
|
||||||
|
category: string;
|
||||||
|
userAnswer: string;
|
||||||
|
correctAnswer: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
explanation?: string;
|
||||||
|
timeSpentSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssessmentResult {
|
||||||
|
assessmentId: string;
|
||||||
|
title: string;
|
||||||
|
score: number;
|
||||||
|
passingScore: number;
|
||||||
|
passed: boolean;
|
||||||
|
totalQuestions: number;
|
||||||
|
correctAnswers: number;
|
||||||
|
incorrectAnswers: number;
|
||||||
|
skippedAnswers: number;
|
||||||
|
timeSpentMinutes: number;
|
||||||
|
timeLimitMinutes?: number;
|
||||||
|
attemptNumber: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
completedAt: string;
|
||||||
|
xpEarned: number;
|
||||||
|
questions: QuestionResult[];
|
||||||
|
categoryScores?: Record<string, { correct: number; total: number }>;
|
||||||
|
previousAttempts?: { score: number; date: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssessmentSummaryCardProps {
|
||||||
|
result: AssessmentResult;
|
||||||
|
classAverage?: number;
|
||||||
|
onRetake?: () => void;
|
||||||
|
onDownloadReport?: () => void;
|
||||||
|
onViewCertificate?: () => void;
|
||||||
|
onReviewQuestion?: (questionId: string) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssessmentSummaryCard: React.FC<AssessmentSummaryCardProps> = ({
|
||||||
|
result,
|
||||||
|
classAverage,
|
||||||
|
onRetake,
|
||||||
|
onDownloadReport,
|
||||||
|
onViewCertificate,
|
||||||
|
onReviewQuestion,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const [showQuestions, setShowQuestions] = useState(false);
|
||||||
|
const [expandedQuestionId, setExpandedQuestionId] = useState<string | null>(null);
|
||||||
|
const [filterType, setFilterType] = useState<'all' | 'correct' | 'incorrect'>('all');
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
const avgTimePerQuestion = result.timeSpentMinutes * 60 / result.totalQuestions;
|
||||||
|
const accuracy = (result.correctAnswers / result.totalQuestions) * 100;
|
||||||
|
|
||||||
|
// Find weakest category
|
||||||
|
let weakestCategory = '';
|
||||||
|
let lowestScore = 100;
|
||||||
|
if (result.categoryScores) {
|
||||||
|
Object.entries(result.categoryScores).forEach(([cat, scores]) => {
|
||||||
|
const catScore = (scores.correct / scores.total) * 100;
|
||||||
|
if (catScore < lowestScore) {
|
||||||
|
lowestScore = catScore;
|
||||||
|
weakestCategory = cat;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate score trend
|
||||||
|
let scoreTrend = 0;
|
||||||
|
if (result.previousAttempts && result.previousAttempts.length > 0) {
|
||||||
|
const lastAttempt = result.previousAttempts[result.previousAttempts.length - 1];
|
||||||
|
scoreTrend = result.score - lastAttempt.score;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accuracy,
|
||||||
|
avgTimePerQuestion,
|
||||||
|
weakestCategory,
|
||||||
|
weakestCategoryScore: lowestScore,
|
||||||
|
scoreTrend,
|
||||||
|
};
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
const filteredQuestions = useMemo(() => {
|
||||||
|
return result.questions.filter(q => {
|
||||||
|
if (filterType === 'correct') return q.isCorrect;
|
||||||
|
if (filterType === 'incorrect') return !q.isCorrect;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [result.questions, filterType]);
|
||||||
|
|
||||||
|
const formatTime = (minutes: number) => {
|
||||||
|
if (minutes < 1) return `${Math.round(minutes * 60)}s`;
|
||||||
|
return `${Math.floor(minutes)}m ${Math.round((minutes % 1) * 60)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScoreColor = (score: number, passing: number) => {
|
||||||
|
if (score >= passing) return 'text-green-400';
|
||||||
|
if (score >= passing * 0.8) return 'text-yellow-400';
|
||||||
|
return 'text-red-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScoreBg = (score: number, passing: number) => {
|
||||||
|
if (score >= passing) return 'from-green-500/20 to-emerald-500/20 border-green-500/30';
|
||||||
|
if (score >= passing * 0.8) return 'from-yellow-500/20 to-amber-500/20 border-yellow-500/30';
|
||||||
|
return 'from-red-500/20 to-rose-500/20 border-red-500/30';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${result.passed ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||||
|
{result.passed ? (
|
||||||
|
<Trophy className="w-5 h-5 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">{result.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Intento {result.attemptNumber}
|
||||||
|
{result.maxAttempts && ` de ${result.maxAttempts}`}
|
||||||
|
{' • '}
|
||||||
|
{new Date(result.completedAt).toLocaleDateString('es-ES')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onDownloadReport && (
|
||||||
|
<button
|
||||||
|
onClick={onDownloadReport}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Descargar reporte"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score Display */}
|
||||||
|
<div className={`p-6 rounded-xl bg-gradient-to-r ${getScoreBg(result.score, result.passingScore)} border mb-6`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className={`text-5xl font-bold ${getScoreColor(result.score, result.passingScore)}`}>
|
||||||
|
{result.score}%
|
||||||
|
</span>
|
||||||
|
{metrics.scoreTrend !== 0 && (
|
||||||
|
<span className={`flex items-center text-sm ${metrics.scoreTrend > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{metrics.scoreTrend > 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||||
|
{metrics.scoreTrend > 0 ? '+' : ''}{metrics.scoreTrend}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
{result.passed ? 'Aprobado' : 'No aprobado'} • Minimo: {result.passingScore}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
{result.passed && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Award className="w-6 h-6 text-yellow-400" />
|
||||||
|
<span className="text-yellow-400 font-medium">+{result.xpEarned} XP</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{classAverage && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Promedio de la clase: <span className="text-white">{classAverage}%</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||||
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-green-400">{result.correctAnswers}</div>
|
||||||
|
<div className="text-xs text-gray-500">Correctas</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-red-400">{result.incorrectAnswers}</div>
|
||||||
|
<div className="text-xs text-gray-500">Incorrectas</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<Clock className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white">{formatTime(result.timeSpentMinutes)}</div>
|
||||||
|
<div className="text-xs text-gray-500">Tiempo total</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<Target className="w-4 h-4 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white">{metrics.accuracy.toFixed(0)}%</div>
|
||||||
|
<div className="text-xs text-gray-500">Precision</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Performance */}
|
||||||
|
{result.categoryScores && Object.keys(result.categoryScores).length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm text-gray-400 mb-3 flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
Rendimiento por Categoria
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(result.categoryScores).map(([category, scores]) => {
|
||||||
|
const percentage = (scores.correct / scores.total) * 100;
|
||||||
|
return (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-gray-300">{category}</span>
|
||||||
|
<span className={getScoreColor(percentage, result.passingScore)}>
|
||||||
|
{scores.correct}/{scores.total} ({percentage.toFixed(0)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
percentage >= result.passingScore ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Weak Areas */}
|
||||||
|
{metrics.weakestCategory && metrics.weakestCategoryScore < result.passingScore && (
|
||||||
|
<div className="mb-6 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Lightbulb className="w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-yellow-400 font-medium">Area de mejora identificada</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Tu puntaje mas bajo fue en <span className="text-white">{metrics.weakestCategory}</span>{' '}
|
||||||
|
({metrics.weakestCategoryScore.toFixed(0)}%). Te recomendamos revisar este tema.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Questions Review */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQuestions(!showQuestions)}
|
||||||
|
className="w-full flex items-center justify-between p-3 bg-gray-900/50 rounded-lg hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-white font-medium">Revisar Respuestas</span>
|
||||||
|
</div>
|
||||||
|
{showQuestions ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showQuestions && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{(['all', 'correct', 'incorrect'] as const).map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter}
|
||||||
|
onClick={() => setFilterType(filter)}
|
||||||
|
className={`px-3 py-1 rounded text-xs ${
|
||||||
|
filterType === filter
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filter === 'all' ? 'Todas' : filter === 'correct' ? 'Correctas' : 'Incorrectas'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Questions List */}
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{filteredQuestions.map((q, index) => {
|
||||||
|
const isExpanded = expandedQuestionId === q.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
className={`p-3 rounded-lg border transition-all ${
|
||||||
|
q.isCorrect
|
||||||
|
? 'bg-green-500/5 border-green-500/20'
|
||||||
|
: 'bg-red-500/5 border-red-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => setExpandedQuestionId(isExpanded ? null : q.id)}
|
||||||
|
className="flex items-start gap-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className={`p-1 rounded-full ${q.isCorrect ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||||
|
{q.isCorrect ? (
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white text-sm">{q.questionText}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs text-gray-500">{q.category}</span>
|
||||||
|
<span className="text-xs text-gray-600">•</span>
|
||||||
|
<span className="text-xs text-gray-500">{q.timeSpentSeconds}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-700/50 text-sm">
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-gray-500">Tu respuesta: </span>
|
||||||
|
<span className={q.isCorrect ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{q.userAnswer}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!q.isCorrect && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-gray-500">Respuesta correcta: </span>
|
||||||
|
<span className="text-green-400">{q.correctAnswer}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{q.explanation && (
|
||||||
|
<div className="mt-2 p-2 bg-gray-800/50 rounded text-gray-400 text-xs">
|
||||||
|
<strong>Explicacion:</strong> {q.explanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Previous Attempts Chart */}
|
||||||
|
{result.previousAttempts && result.previousAttempts.length > 0 && (
|
||||||
|
<div className="mb-6 p-4 bg-gray-900/50 rounded-lg">
|
||||||
|
<h4 className="text-sm text-gray-400 mb-3 flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
Historial de Intentos
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-end gap-2 h-20">
|
||||||
|
{result.previousAttempts.map((attempt, index) => {
|
||||||
|
const height = (attempt.score / 100) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 flex flex-col items-center"
|
||||||
|
title={`${attempt.score}% - ${new Date(attempt.date).toLocaleDateString()}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-t ${
|
||||||
|
attempt.score >= result.passingScore ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ height: `${height}%` }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500 mt-1">#{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex-1 flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-t ${
|
||||||
|
result.score >= result.passingScore ? 'bg-blue-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ height: `${result.score}%` }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-blue-400 mt-1">Actual</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!result.passed && onRetake && (
|
||||||
|
<button
|
||||||
|
onClick={onRetake}
|
||||||
|
disabled={result.maxAttempts !== undefined && result.attemptNumber >= result.maxAttempts}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{result.passed && onViewCertificate && (
|
||||||
|
<button
|
||||||
|
onClick={onViewCertificate}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-gradient-to-r from-yellow-600 to-amber-600 hover:from-yellow-500 hover:to-amber-500 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Award className="w-4 h-4" />
|
||||||
|
Ver Certificado
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssessmentSummaryCard;
|
||||||
310
src/modules/education/components/CourseProgressTracker.tsx
Normal file
310
src/modules/education/components/CourseProgressTracker.tsx
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* CourseProgressTracker Component
|
||||||
|
* Comprehensive progress visualization at the course level
|
||||||
|
* OQI-002: Modulo Educativo
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
CheckCircle,
|
||||||
|
Circle,
|
||||||
|
Clock,
|
||||||
|
Play,
|
||||||
|
Lock,
|
||||||
|
Trophy,
|
||||||
|
Target,
|
||||||
|
TrendingUp,
|
||||||
|
Download,
|
||||||
|
ChevronRight,
|
||||||
|
Zap,
|
||||||
|
Calendar,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export interface ModuleProgress {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
lessonsTotal: number;
|
||||||
|
lessonsCompleted: number;
|
||||||
|
quizScore?: number;
|
||||||
|
quizPassed?: boolean;
|
||||||
|
timeSpentMinutes: number;
|
||||||
|
estimatedMinutes: number;
|
||||||
|
status: 'locked' | 'available' | 'in_progress' | 'completed';
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseProgressData {
|
||||||
|
courseId: string;
|
||||||
|
courseTitle: string;
|
||||||
|
totalLessons: number;
|
||||||
|
completedLessons: number;
|
||||||
|
totalQuizzes: number;
|
||||||
|
passedQuizzes: number;
|
||||||
|
overallProgress: number;
|
||||||
|
xpEarned: number;
|
||||||
|
xpTotal: number;
|
||||||
|
timeSpentMinutes: number;
|
||||||
|
estimatedTotalMinutes: number;
|
||||||
|
startedAt?: string;
|
||||||
|
lastAccessedAt?: string;
|
||||||
|
modules: ModuleProgress[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourseProgressTrackerProps {
|
||||||
|
progress: CourseProgressData;
|
||||||
|
onModuleClick?: (moduleId: string) => void;
|
||||||
|
onDownloadReport?: () => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CourseProgressTracker: React.FC<CourseProgressTrackerProps> = ({
|
||||||
|
progress,
|
||||||
|
onModuleClick,
|
||||||
|
onDownloadReport,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const sortedModules = useMemo(() => {
|
||||||
|
return [...progress.modules].sort((a, b) => a.order - b.order);
|
||||||
|
}, [progress.modules]);
|
||||||
|
|
||||||
|
const completedModules = progress.modules.filter(m => m.status === 'completed').length;
|
||||||
|
const totalModules = progress.modules.length;
|
||||||
|
|
||||||
|
const formatTime = (minutes: number) => {
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: ModuleProgress['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className="w-5 h-5 text-green-400" />;
|
||||||
|
case 'in_progress':
|
||||||
|
return <Play className="w-5 h-5 text-blue-400" />;
|
||||||
|
case 'locked':
|
||||||
|
return <Lock className="w-5 h-5 text-gray-500" />;
|
||||||
|
default:
|
||||||
|
return <Circle className="w-5 h-5 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBg = (status: ModuleProgress['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-green-500/10 border-green-500/30';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'bg-blue-500/10 border-blue-500/30';
|
||||||
|
case 'locked':
|
||||||
|
return 'bg-gray-800/50 border-gray-700 opacity-60';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-800/50 border-gray-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const estimatedCompletion = useMemo(() => {
|
||||||
|
const remainingMinutes = progress.estimatedTotalMinutes - progress.timeSpentMinutes;
|
||||||
|
if (remainingMinutes <= 0) return 'Completable ahora';
|
||||||
|
const days = Math.ceil(remainingMinutes / 60 / 2); // Assuming 2 hours/day
|
||||||
|
return `~${days} ${days === 1 ? 'dia' : 'dias'} restantes`;
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||||
|
<Target className="w-5 h-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Progreso del Curso</h3>
|
||||||
|
<p className="text-xs text-gray-500">{progress.courseTitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onDownloadReport && (
|
||||||
|
<button
|
||||||
|
onClick={onDownloadReport}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Descargar reporte"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Progress Bar */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-400">Progreso General</span>
|
||||||
|
<span className="text-lg font-bold text-white">{progress.overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-600 to-green-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${progress.overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||||
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<BookOpen className="w-4 h-4 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white">
|
||||||
|
{progress.completedLessons}/{progress.totalLessons}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Lecciones</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<Trophy className="w-4 h-4 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white">
|
||||||
|
{progress.passedQuizzes}/{progress.totalQuizzes}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Quizzes</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<Clock className="w-4 h-4 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white">{formatTime(progress.timeSpentMinutes)}</div>
|
||||||
|
<div className="text-xs text-gray-500">Tiempo</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<Zap className="w-4 h-4 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white">{progress.xpEarned}</div>
|
||||||
|
<div className="text-xs text-gray-500">XP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Info */}
|
||||||
|
{(progress.startedAt || progress.lastAccessedAt) && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-900/50 rounded-lg mb-4 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-gray-500" />
|
||||||
|
{progress.startedAt && (
|
||||||
|
<span className="text-gray-400">
|
||||||
|
Iniciado: <span className="text-white">{formatDate(progress.startedAt)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-gray-400">{estimatedCompletion}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Module List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm text-gray-400 mb-2">
|
||||||
|
Modulos ({completedModules}/{totalModules})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedModules.map((module, index) => {
|
||||||
|
const moduleProgress =
|
||||||
|
module.lessonsTotal > 0
|
||||||
|
? Math.round((module.lessonsCompleted / module.lessonsTotal) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={module.id}
|
||||||
|
onClick={() => module.status !== 'locked' && onModuleClick?.(module.id)}
|
||||||
|
className={`p-3 rounded-lg border transition-all ${getStatusBg(module.status)} ${
|
||||||
|
module.status !== 'locked' ? 'cursor-pointer hover:border-gray-600' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Status Icon */}
|
||||||
|
<div className="flex-shrink-0">{getStatusIcon(module.status)}</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`font-medium truncate ${
|
||||||
|
module.status === 'locked' ? 'text-gray-500' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index + 1}. {module.title}
|
||||||
|
</span>
|
||||||
|
{module.status !== 'locked' && (
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{module.status !== 'locked' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{module.lessonsCompleted}/{module.lessonsTotal} lecciones
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">{moduleProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
module.status === 'completed' ? 'bg-green-500' : 'bg-blue-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${moduleProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quiz Badge */}
|
||||||
|
{module.quizScore !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-xs ${
|
||||||
|
module.quizPassed
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-red-500/20 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Quiz: {module.quizScore}%
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{formatTime(module.timeSpentMinutes)} / {formatTime(module.estimatedMinutes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completion Message */}
|
||||||
|
{progress.overallProgress === 100 && (
|
||||||
|
<div className="mt-4 p-4 bg-gradient-to-r from-green-500/10 to-emerald-500/10 border border-green-500/30 rounded-lg text-center">
|
||||||
|
<Trophy className="w-8 h-8 text-yellow-400 mx-auto mb-2" />
|
||||||
|
<p className="text-green-400 font-medium">Curso Completado!</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Has ganado {progress.xpEarned} XP</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseProgressTracker;
|
||||||
418
src/modules/education/components/LearningPathVisualizer.tsx
Normal file
418
src/modules/education/components/LearningPathVisualizer.tsx
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
/**
|
||||||
|
* LearningPathVisualizer Component
|
||||||
|
* Visual roadmap showing recommended learning paths
|
||||||
|
* OQI-002: Modulo Educativo
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Map,
|
||||||
|
BookOpen,
|
||||||
|
CheckCircle,
|
||||||
|
Circle,
|
||||||
|
Lock,
|
||||||
|
Play,
|
||||||
|
Star,
|
||||||
|
Clock,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Target,
|
||||||
|
Zap,
|
||||||
|
ArrowRight,
|
||||||
|
Trophy,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export interface PathNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: 'course' | 'module' | 'milestone';
|
||||||
|
status: 'locked' | 'available' | 'in_progress' | 'completed';
|
||||||
|
progress?: number;
|
||||||
|
xpReward: number;
|
||||||
|
estimatedMinutes: number;
|
||||||
|
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
||||||
|
prerequisites?: string[];
|
||||||
|
description?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LearningPath {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
totalXp: number;
|
||||||
|
earnedXp: number;
|
||||||
|
totalCourses: number;
|
||||||
|
completedCourses: number;
|
||||||
|
estimatedHours: number;
|
||||||
|
nodes: PathNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LearningPathVisualizerProps {
|
||||||
|
path: LearningPath;
|
||||||
|
currentNodeId?: string;
|
||||||
|
onNodeClick?: (nodeId: string) => void;
|
||||||
|
onStartNext?: () => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LearningPathVisualizer: React.FC<LearningPathVisualizerProps> = ({
|
||||||
|
path,
|
||||||
|
currentNodeId,
|
||||||
|
onNodeClick,
|
||||||
|
onStartNext,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||||
|
const [viewMode, setViewMode] = useState<'timeline' | 'grid'>('timeline');
|
||||||
|
|
||||||
|
const overallProgress = useMemo(() => {
|
||||||
|
return Math.round((path.completedCourses / path.totalCourses) * 100);
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
|
const nextAvailableNode = useMemo(() => {
|
||||||
|
return path.nodes.find(n => n.status === 'available');
|
||||||
|
}, [path.nodes]);
|
||||||
|
|
||||||
|
const currentInProgress = useMemo(() => {
|
||||||
|
return path.nodes.find(n => n.status === 'in_progress');
|
||||||
|
}, [path.nodes]);
|
||||||
|
|
||||||
|
const toggleNode = (nodeId: string) => {
|
||||||
|
const newExpanded = new Set(expandedNodes);
|
||||||
|
if (newExpanded.has(nodeId)) {
|
||||||
|
newExpanded.delete(nodeId);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(nodeId);
|
||||||
|
}
|
||||||
|
setExpandedNodes(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: PathNode['status'], size = 5) => {
|
||||||
|
const sizeClass = `w-${size} h-${size}`;
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className={`${sizeClass} text-green-400`} />;
|
||||||
|
case 'in_progress':
|
||||||
|
return <Play className={`${sizeClass} text-blue-400`} />;
|
||||||
|
case 'locked':
|
||||||
|
return <Lock className={`${sizeClass} text-gray-500`} />;
|
||||||
|
default:
|
||||||
|
return <Circle className={`${sizeClass} text-gray-400`} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDifficultyBadge = (difficulty: PathNode['difficulty']) => {
|
||||||
|
const colors = {
|
||||||
|
beginner: 'bg-green-500/20 text-green-400',
|
||||||
|
intermediate: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
advanced: 'bg-red-500/20 text-red-400',
|
||||||
|
};
|
||||||
|
const labels = {
|
||||||
|
beginner: 'Principiante',
|
||||||
|
intermediate: 'Intermedio',
|
||||||
|
advanced: 'Avanzado',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${colors[difficulty]}`}>
|
||||||
|
{labels[difficulty]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (minutes: number) => {
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours}h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-500/20 rounded-lg">
|
||||||
|
<Map className="w-5 h-5 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">{path.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{path.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!compact && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('timeline')}
|
||||||
|
className={`px-3 py-1 rounded text-xs ${
|
||||||
|
viewMode === 'timeline'
|
||||||
|
? 'bg-purple-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Timeline
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`px-3 py-1 rounded text-xs ${
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-purple-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Grid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Progress */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{path.completedCourses}/{path.totalCourses} cursos
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400 flex items-center gap-1">
|
||||||
|
<Zap className="w-4 h-4 text-yellow-400" />
|
||||||
|
{path.earnedXp}/{path.totalXp} XP
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-white">{overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-purple-600 to-pink-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current/Next Action */}
|
||||||
|
{(currentInProgress || nextAvailableNode) && (
|
||||||
|
<div className="mb-6 p-4 bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-500/30 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400 mb-1">
|
||||||
|
{currentInProgress ? 'Continuar con' : 'Siguiente paso'}
|
||||||
|
</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{(currentInProgress || nextAvailableNode)?.title}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatTime((currentInProgress || nextAvailableNode)!.estimatedMinutes)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
+{(currentInProgress || nextAvailableNode)!.xpReward} XP
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onNodeClick?.((currentInProgress || nextAvailableNode)!.id)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{currentInProgress ? 'Continuar' : 'Comenzar'}
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Path Visualization */}
|
||||||
|
{viewMode === 'timeline' ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{path.nodes.map((node, index) => {
|
||||||
|
const isExpanded = expandedNodes.has(node.id);
|
||||||
|
const isCurrent = node.id === currentNodeId;
|
||||||
|
const isLast = index === path.nodes.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={node.id} className="relative">
|
||||||
|
{/* Connector Line */}
|
||||||
|
{!isLast && (
|
||||||
|
<div
|
||||||
|
className={`absolute left-[19px] top-10 w-0.5 h-[calc(100%-16px)] ${
|
||||||
|
node.status === 'completed' ? 'bg-green-500' : 'bg-gray-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node */}
|
||||||
|
<div
|
||||||
|
onClick={() => node.status !== 'locked' && onNodeClick?.(node.id)}
|
||||||
|
className={`relative flex items-start gap-3 p-3 rounded-lg transition-all ${
|
||||||
|
node.status === 'locked'
|
||||||
|
? 'opacity-50'
|
||||||
|
: 'cursor-pointer hover:bg-gray-800/50'
|
||||||
|
} ${isCurrent ? 'bg-blue-500/10 border border-blue-500/30' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Status Icon */}
|
||||||
|
<div
|
||||||
|
className={`relative z-10 p-2 rounded-full ${
|
||||||
|
node.status === 'completed'
|
||||||
|
? 'bg-green-500/20'
|
||||||
|
: node.status === 'in_progress'
|
||||||
|
? 'bg-blue-500/20'
|
||||||
|
: 'bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{node.type === 'milestone' ? (
|
||||||
|
<Trophy className="w-5 h-5 text-yellow-400" />
|
||||||
|
) : (
|
||||||
|
getStatusIcon(node.status)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
node.status === 'locked' ? 'text-gray-500' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{node.title}
|
||||||
|
</span>
|
||||||
|
{getDifficultyBadge(node.difficulty)}
|
||||||
|
</div>
|
||||||
|
{node.status !== 'locked' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleNode(node.id);
|
||||||
|
}}
|
||||||
|
className="p-1 text-gray-500 hover:text-white"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatTime(node.estimatedMinutes)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
{node.xpReward} XP
|
||||||
|
</span>
|
||||||
|
{node.progress !== undefined && node.progress > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3" />
|
||||||
|
{node.progress}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar for In Progress */}
|
||||||
|
{node.status === 'in_progress' && node.progress !== undefined && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full"
|
||||||
|
style={{ width: `${node.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded Details */}
|
||||||
|
{isExpanded && node.description && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-700">
|
||||||
|
<p className="text-sm text-gray-400">{node.description}</p>
|
||||||
|
{node.prerequisites && node.prerequisites.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-xs text-gray-500">Prerequisitos:</span>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{node.prerequisites.map((prereq) => (
|
||||||
|
<span
|
||||||
|
key={prereq}
|
||||||
|
className="px-2 py-0.5 bg-gray-700 text-gray-300 rounded text-xs"
|
||||||
|
>
|
||||||
|
{prereq}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Grid View */
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{path.nodes.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
onClick={() => node.status !== 'locked' && onNodeClick?.(node.id)}
|
||||||
|
className={`p-4 rounded-lg border transition-all ${
|
||||||
|
node.status === 'locked'
|
||||||
|
? 'bg-gray-800/30 border-gray-700 opacity-50'
|
||||||
|
: node.status === 'completed'
|
||||||
|
? 'bg-green-500/10 border-green-500/30 cursor-pointer hover:border-green-500/50'
|
||||||
|
: node.status === 'in_progress'
|
||||||
|
? 'bg-blue-500/10 border-blue-500/30 cursor-pointer hover:border-blue-500/50'
|
||||||
|
: 'bg-gray-800/50 border-gray-700 cursor-pointer hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
{node.type === 'milestone' ? (
|
||||||
|
<Trophy className="w-6 h-6 text-yellow-400" />
|
||||||
|
) : (
|
||||||
|
getStatusIcon(node.status, 6)
|
||||||
|
)}
|
||||||
|
{getDifficultyBadge(node.difficulty)}
|
||||||
|
</div>
|
||||||
|
<h4 className={`font-medium mb-1 ${node.status === 'locked' ? 'text-gray-500' : 'text-white'}`}>
|
||||||
|
{node.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span>{formatTime(node.estimatedMinutes)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{node.xpReward} XP</span>
|
||||||
|
</div>
|
||||||
|
{node.status === 'in_progress' && node.progress !== undefined && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full"
|
||||||
|
style={{ width: `${node.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completion Badge */}
|
||||||
|
{overallProgress === 100 && (
|
||||||
|
<div className="mt-6 p-4 bg-gradient-to-r from-yellow-500/10 to-amber-500/10 border border-yellow-500/30 rounded-lg text-center">
|
||||||
|
<Trophy className="w-10 h-10 text-yellow-400 mx-auto mb-2" />
|
||||||
|
<p className="text-yellow-400 font-bold">Path Completado!</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Has dominado {path.title}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LearningPathVisualizer;
|
||||||
554
src/modules/education/components/VideoProgressPlayer.tsx
Normal file
554
src/modules/education/components/VideoProgressPlayer.tsx
Normal file
@ -0,0 +1,554 @@
|
|||||||
|
/**
|
||||||
|
* VideoProgressPlayer Component
|
||||||
|
* Enhanced video player with advanced progress tracking
|
||||||
|
* OQI-002: Modulo Educativo
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
Settings,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Rewind,
|
||||||
|
FastForward,
|
||||||
|
Bookmark,
|
||||||
|
BookmarkCheck,
|
||||||
|
MessageSquare,
|
||||||
|
Repeat,
|
||||||
|
ChevronDown,
|
||||||
|
Clock,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export interface VideoBookmark {
|
||||||
|
id: string;
|
||||||
|
time: number;
|
||||||
|
label: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoNote {
|
||||||
|
id: string;
|
||||||
|
time: number;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoProgressPlayerProps {
|
||||||
|
src: string;
|
||||||
|
poster?: string;
|
||||||
|
title?: string;
|
||||||
|
duration?: number;
|
||||||
|
initialProgress?: number;
|
||||||
|
bookmarks?: VideoBookmark[];
|
||||||
|
notes?: VideoNote[];
|
||||||
|
onProgressUpdate?: (progress: number, currentTime: number) => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onBookmarkAdd?: (time: number, label: string) => void;
|
||||||
|
onBookmarkRemove?: (bookmarkId: string) => void;
|
||||||
|
onNoteAdd?: (time: number, content: string) => void;
|
||||||
|
autoResumePosition?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAYBACK_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||||
|
|
||||||
|
const VideoProgressPlayer: React.FC<VideoProgressPlayerProps> = ({
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
title,
|
||||||
|
duration: initialDuration,
|
||||||
|
initialProgress = 0,
|
||||||
|
bookmarks = [],
|
||||||
|
notes = [],
|
||||||
|
onProgressUpdate,
|
||||||
|
onComplete,
|
||||||
|
onBookmarkAdd,
|
||||||
|
onBookmarkRemove,
|
||||||
|
onNoteAdd,
|
||||||
|
autoResumePosition = true,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const progressRef = useRef<HTMLDivElement>(null);
|
||||||
|
const updateIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(initialDuration || 0);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||||
|
const [watchedPercentage, setWatchedPercentage] = useState(initialProgress);
|
||||||
|
const [isCompleted, setIsCompleted] = useState(false);
|
||||||
|
const [loopStart, setLoopStart] = useState<number | null>(null);
|
||||||
|
const [loopEnd, setLoopEnd] = useState<number | null>(null);
|
||||||
|
const [isLooping, setIsLooping] = useState(false);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [newBookmarkLabel, setNewBookmarkLabel] = useState('');
|
||||||
|
const [showBookmarkInput, setShowBookmarkInput] = useState(false);
|
||||||
|
|
||||||
|
// Format time as MM:SS or HH:MM:SS
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const hrs = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hrs > 0) {
|
||||||
|
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle video metadata loaded
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
setDuration(videoRef.current.duration);
|
||||||
|
if (autoResumePosition && initialProgress > 0) {
|
||||||
|
const resumeTime = (initialProgress / 100) * videoRef.current.duration;
|
||||||
|
videoRef.current.currentTime = resumeTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle time update
|
||||||
|
const handleTimeUpdate = useCallback(() => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
const current = videoRef.current.currentTime;
|
||||||
|
setCurrentTime(current);
|
||||||
|
|
||||||
|
// Calculate watched percentage
|
||||||
|
const percentage = (current / videoRef.current.duration) * 100;
|
||||||
|
setWatchedPercentage(Math.max(watchedPercentage, percentage));
|
||||||
|
|
||||||
|
// Check for loop
|
||||||
|
if (isLooping && loopEnd !== null && current >= loopEnd) {
|
||||||
|
videoRef.current.currentTime = loopStart || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for completion (95% watched)
|
||||||
|
if (percentage >= 95 && !isCompleted) {
|
||||||
|
setIsCompleted(true);
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
}, [watchedPercentage, isLooping, loopStart, loopEnd, isCompleted, onComplete]);
|
||||||
|
|
||||||
|
// Periodic progress sync
|
||||||
|
useEffect(() => {
|
||||||
|
updateIntervalRef.current = setInterval(() => {
|
||||||
|
if (videoRef.current && isPlaying) {
|
||||||
|
onProgressUpdate?.(watchedPercentage, videoRef.current.currentTime);
|
||||||
|
}
|
||||||
|
}, 10000); // Sync every 10 seconds
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (updateIntervalRef.current) {
|
||||||
|
clearInterval(updateIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, watchedPercentage, onProgressUpdate]);
|
||||||
|
|
||||||
|
// Play/Pause toggle
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seek to position
|
||||||
|
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!videoRef.current || !progressRef.current) return;
|
||||||
|
|
||||||
|
const rect = progressRef.current.getBoundingClientRect();
|
||||||
|
const percentage = (e.clientX - rect.left) / rect.width;
|
||||||
|
const newTime = percentage * duration;
|
||||||
|
videoRef.current.currentTime = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip forward/backward
|
||||||
|
const skip = (seconds: number) => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
videoRef.current.currentTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle mute
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
videoRef.current.muted = !isMuted;
|
||||||
|
setIsMuted(!isMuted);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Change volume
|
||||||
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newVolume = parseFloat(e.target.value);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = newVolume;
|
||||||
|
}
|
||||||
|
setVolume(newVolume);
|
||||||
|
setIsMuted(newVolume === 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Change playback speed
|
||||||
|
const handleSpeedChange = (speed: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.playbackRate = speed;
|
||||||
|
}
|
||||||
|
setPlaybackSpeed(speed);
|
||||||
|
setShowSettings(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle fullscreen
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
const container = videoRef.current?.parentElement?.parentElement;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!isFullscreen) {
|
||||||
|
container.requestFullscreen?.();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen?.();
|
||||||
|
}
|
||||||
|
setIsFullscreen(!isFullscreen);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add bookmark
|
||||||
|
const handleAddBookmark = () => {
|
||||||
|
if (!newBookmarkLabel.trim()) return;
|
||||||
|
onBookmarkAdd?.(currentTime, newBookmarkLabel);
|
||||||
|
setNewBookmarkLabel('');
|
||||||
|
setShowBookmarkInput(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jump to bookmark
|
||||||
|
const jumpToBookmark = (time: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = time;
|
||||||
|
setCurrentTime(time);
|
||||||
|
}
|
||||||
|
setShowBookmarks(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set loop points
|
||||||
|
const handleSetLoopStart = () => {
|
||||||
|
setLoopStart(currentTime);
|
||||||
|
if (loopEnd !== null && currentTime >= loopEnd) {
|
||||||
|
setLoopEnd(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetLoopEnd = () => {
|
||||||
|
if (loopStart !== null && currentTime > loopStart) {
|
||||||
|
setLoopEnd(currentTime);
|
||||||
|
setIsLooping(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLoop = () => {
|
||||||
|
setLoopStart(null);
|
||||||
|
setLoopEnd(null);
|
||||||
|
setIsLooping(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement) return;
|
||||||
|
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case ' ':
|
||||||
|
case 'k':
|
||||||
|
e.preventDefault();
|
||||||
|
togglePlay();
|
||||||
|
break;
|
||||||
|
case 'arrowleft':
|
||||||
|
case 'j':
|
||||||
|
skip(-10);
|
||||||
|
break;
|
||||||
|
case 'arrowright':
|
||||||
|
case 'l':
|
||||||
|
skip(10);
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
toggleMute();
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
toggleFullscreen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [currentTime, isPlaying, isMuted]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative bg-black rounded-xl overflow-hidden ${compact ? '' : 'aspect-video'}`}
|
||||||
|
onMouseEnter={() => setShowControls(true)}
|
||||||
|
onMouseLeave={() => setShowControls(false)}
|
||||||
|
>
|
||||||
|
{/* Video Element */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
poster={poster}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
onEnded={() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
if (!isLooping) {
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={togglePlay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title Overlay */}
|
||||||
|
{title && showControls && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/80 to-transparent">
|
||||||
|
<h3 className="text-white font-medium">{title}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls Overlay */}
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4 transition-opacity ${
|
||||||
|
showControls ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div
|
||||||
|
ref={progressRef}
|
||||||
|
onClick={handleSeek}
|
||||||
|
className="relative h-1.5 bg-gray-600 rounded-full cursor-pointer mb-3 group"
|
||||||
|
>
|
||||||
|
{/* Watched Progress */}
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-gray-500 rounded-full"
|
||||||
|
style={{ width: `${watchedPercentage}%` }}
|
||||||
|
/>
|
||||||
|
{/* Current Progress */}
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-blue-500 rounded-full"
|
||||||
|
style={{ width: `${(currentTime / duration) * 100}%` }}
|
||||||
|
/>
|
||||||
|
{/* Loop Region */}
|
||||||
|
{loopStart !== null && loopEnd !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-yellow-500/30"
|
||||||
|
style={{
|
||||||
|
left: `${(loopStart / duration) * 100}%`,
|
||||||
|
width: `${((loopEnd - loopStart) / duration) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Bookmarks */}
|
||||||
|
{bookmarks.map((bookmark) => (
|
||||||
|
<div
|
||||||
|
key={bookmark.id}
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 w-2 h-2 bg-yellow-400 rounded-full cursor-pointer hover:scale-150 transition-transform"
|
||||||
|
style={{ left: `${(bookmark.time / duration) * 100}%` }}
|
||||||
|
title={bookmark.label}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
jumpToBookmark(bookmark.time);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Hover tooltip */}
|
||||||
|
<div className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 pointer-events-none">
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control Buttons */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Play/Pause */}
|
||||||
|
<button onClick={togglePlay} className="p-2 text-white hover:bg-white/20 rounded-lg">
|
||||||
|
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Skip Back */}
|
||||||
|
<button onClick={() => skip(-10)} className="p-2 text-white hover:bg-white/20 rounded-lg">
|
||||||
|
<Rewind className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Skip Forward */}
|
||||||
|
<button onClick={() => skip(10)} className="p-2 text-white hover:bg-white/20 rounded-lg">
|
||||||
|
<FastForward className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="flex items-center gap-1 group">
|
||||||
|
<button onClick={toggleMute} className="p-2 text-white hover:bg-white/20 rounded-lg">
|
||||||
|
{isMuted || volume === 0 ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-0 group-hover:w-20 transition-all duration-200 accent-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Display */}
|
||||||
|
<span className="text-white text-sm ml-2">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Bookmark Button */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBookmarks(!showBookmarks)}
|
||||||
|
className="p-2 text-white hover:bg-white/20 rounded-lg"
|
||||||
|
>
|
||||||
|
{bookmarks.length > 0 ? (
|
||||||
|
<BookmarkCheck className="w-4 h-4 text-yellow-400" />
|
||||||
|
) : (
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showBookmarks && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700 p-2 z-50">
|
||||||
|
<div className="text-xs text-gray-400 mb-2">Bookmarks</div>
|
||||||
|
{bookmarks.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-500 py-2">No bookmarks</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||||
|
{bookmarks.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
onClick={() => jumpToBookmark(b.time)}
|
||||||
|
className="flex items-center justify-between p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-white text-sm">{b.label}</span>
|
||||||
|
<span className="text-xs text-gray-400">{formatTime(b.time)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 pt-2 border-t border-gray-700">
|
||||||
|
{showBookmarkInput ? (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newBookmarkLabel}
|
||||||
|
onChange={(e) => setNewBookmarkLabel(e.target.value)}
|
||||||
|
placeholder="Bookmark name..."
|
||||||
|
className="flex-1 px-2 py-1 bg-gray-900 border border-gray-700 rounded text-xs text-white"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAddBookmark()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddBookmark}
|
||||||
|
className="px-2 py-1 bg-blue-600 text-white rounded text-xs"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBookmarkInput(true)}
|
||||||
|
className="w-full text-left px-2 py-1 text-xs text-blue-400 hover:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
+ Add bookmark at {formatTime(currentTime)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loop Button */}
|
||||||
|
<button
|
||||||
|
onClick={isLooping ? clearLoop : handleSetLoopStart}
|
||||||
|
className={`p-2 rounded-lg ${
|
||||||
|
isLooping ? 'text-yellow-400 bg-yellow-500/20' : 'text-white hover:bg-white/20'
|
||||||
|
}`}
|
||||||
|
title={isLooping ? 'Clear loop' : 'Set loop start'}
|
||||||
|
>
|
||||||
|
<Repeat className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className="p-2 text-white hover:bg-white/20 rounded-lg"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showSettings && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 w-40 bg-gray-800 rounded-lg shadow-xl border border-gray-700 p-2 z-50">
|
||||||
|
<div className="text-xs text-gray-400 mb-2">Playback Speed</div>
|
||||||
|
{PLAYBACK_SPEEDS.map((speed) => (
|
||||||
|
<button
|
||||||
|
key={speed}
|
||||||
|
onClick={() => handleSpeedChange(speed)}
|
||||||
|
className={`w-full text-left px-3 py-1.5 rounded text-sm flex items-center justify-between ${
|
||||||
|
playbackSpeed === speed
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{speed}x
|
||||||
|
{playbackSpeed === speed && <Check className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fullscreen */}
|
||||||
|
<button onClick={toggleFullscreen} className="p-2 text-white hover:bg-white/20 rounded-lg">
|
||||||
|
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completion Badge */}
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="absolute top-4 right-4 flex items-center gap-2 px-3 py-1.5 bg-green-500/20 border border-green-500/30 rounded-full">
|
||||||
|
<Check className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-green-400 text-sm">Completado</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard Shortcuts Hint */}
|
||||||
|
{showControls && !compact && (
|
||||||
|
<div className="absolute bottom-20 right-4 text-xs text-gray-500">
|
||||||
|
Space: Play/Pause • J/L: ±10s • M: Mute • F: Fullscreen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoProgressPlayer;
|
||||||
@ -1,9 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Education Components Index
|
* Education Components Index
|
||||||
* Export all education-related components
|
* Export all education-related components
|
||||||
|
* OQI-002: Modulo Educativo
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Certificate & Review Components
|
||||||
export { default as CertificatePreview } from './CertificatePreview';
|
export { default as CertificatePreview } from './CertificatePreview';
|
||||||
export { default as CourseReviews } from './CourseReviews';
|
export { default as CourseReviews } from './CourseReviews';
|
||||||
export { default as LessonNotes } from './LessonNotes';
|
export { default as LessonNotes } from './LessonNotes';
|
||||||
export { default as RecommendedCourses } from './RecommendedCourses';
|
export { default as RecommendedCourses } from './RecommendedCourses';
|
||||||
|
|
||||||
|
// Progress & Learning Path Components
|
||||||
|
export { default as CourseProgressTracker } from './CourseProgressTracker';
|
||||||
|
export type { ModuleProgress, CourseProgressData } from './CourseProgressTracker';
|
||||||
|
export { default as LearningPathVisualizer } from './LearningPathVisualizer';
|
||||||
|
export type { PathNode, LearningPath } from './LearningPathVisualizer';
|
||||||
|
|
||||||
|
// Video & Assessment Components
|
||||||
|
export { default as VideoProgressPlayer } from './VideoProgressPlayer';
|
||||||
|
export type { VideoBookmark, VideoNote } from './VideoProgressPlayer';
|
||||||
|
export { default as AssessmentSummaryCard } from './AssessmentSummaryCard';
|
||||||
|
export type { QuestionResult, AssessmentResult } from './AssessmentSummaryCard';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user