[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:
Adrian Flores Cortes 2026-01-25 12:07:12 -06:00
parent 7ac32466be
commit cbb6637966
5 changed files with 1761 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View File

@ -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';