[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
|
||||
* Export all education-related components
|
||||
* OQI-002: Modulo Educativo
|
||||
*/
|
||||
|
||||
// Certificate & Review Components
|
||||
export { default as CertificatePreview } from './CertificatePreview';
|
||||
export { default as CourseReviews } from './CourseReviews';
|
||||
export { default as LessonNotes } from './LessonNotes';
|
||||
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