[OQI-002] feat: Add 4 advanced education components

- VideoUploadForm: Multi-step video upload with drag-drop (450 LOC)
- CreatorDashboard: Creator stats, courses, activity feed (450 LOC)
- CertificateGenerator: Templates, PDF/PNG download, sharing (453 LOC)
- LiveStreamPlayer: Live streaming with chat, reactions (480 LOC)

Updates OQI-002 progress from 30% to 40%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 14:44:05 -06:00
parent c145878c24
commit fc99c34749
5 changed files with 2198 additions and 0 deletions

View File

@ -0,0 +1,452 @@
/**
* CertificateGenerator Component
* Generate and download course completion certificates
* Epic: OQI-002 Modulo Educativo
*/
import React, { useRef, useState, useCallback } from 'react';
import {
AcademicCapIcon,
ArrowDownTrayIcon,
ShareIcon,
PrinterIcon,
CheckBadgeIcon,
DocumentDuplicateIcon,
XMarkIcon,
ArrowPathIcon,
} from '@heroicons/react/24/solid';
// Types
export interface CertificateData {
id: string;
recipientName: string;
courseName: string;
courseInstructor: string;
completionDate: Date;
courseDuration: number;
grade?: number;
credentialId: string;
issuerName: string;
issuerLogo?: string;
skills?: string[];
signatureUrl?: string;
}
export interface CertificateTemplate {
id: string;
name: string;
backgroundColor: string;
accentColor: string;
borderStyle: 'classic' | 'modern' | 'minimal';
showBadge: boolean;
showSkills: boolean;
}
interface CertificateGeneratorProps {
certificate: CertificateData;
template?: CertificateTemplate;
onDownload?: (format: 'pdf' | 'png') => void;
onShare?: (platform: 'linkedin' | 'twitter' | 'email' | 'copy') => void;
onPrint?: () => void;
isGenerating?: boolean;
compact?: boolean;
}
const DEFAULT_TEMPLATE: CertificateTemplate = {
id: 'default',
name: 'Classic',
backgroundColor: '#1f2937',
accentColor: '#3b82f6',
borderStyle: 'classic',
showBadge: true,
showSkills: true,
};
const TEMPLATES: CertificateTemplate[] = [
DEFAULT_TEMPLATE,
{
id: 'modern',
name: 'Modern',
backgroundColor: '#111827',
accentColor: '#10b981',
borderStyle: 'modern',
showBadge: true,
showSkills: true,
},
{
id: 'minimal',
name: 'Minimal',
backgroundColor: '#0f172a',
accentColor: '#8b5cf6',
borderStyle: 'minimal',
showBadge: false,
showSkills: false,
},
];
const CertificateGenerator: React.FC<CertificateGeneratorProps> = ({
certificate,
template: initialTemplate = DEFAULT_TEMPLATE,
onDownload,
onShare,
onPrint,
isGenerating = false,
compact = false,
}) => {
const certificateRef = useRef<HTMLDivElement>(null);
const [selectedTemplate, setSelectedTemplate] = useState<CertificateTemplate>(initialTemplate);
const [showShareMenu, setShowShareMenu] = useState(false);
const [copied, setCopied] = useState(false);
// Format date
const formatDate = (date: Date): string => {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
// Format duration
const formatDuration = (hours: number): string => {
if (hours < 1) return `${Math.round(hours * 60)} minutes`;
return `${hours} hour${hours !== 1 ? 's' : ''}`;
};
// Handle copy credential ID
const handleCopyCredential = useCallback(() => {
navigator.clipboard.writeText(certificate.credentialId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [certificate.credentialId]);
// Handle share
const handleShare = useCallback(
(platform: 'linkedin' | 'twitter' | 'email' | 'copy') => {
onShare?.(platform);
setShowShareMenu(false);
},
[onShare]
);
// Get border style classes
const getBorderClasses = (style: CertificateTemplate['borderStyle']): string => {
switch (style) {
case 'classic':
return 'border-4 border-double';
case 'modern':
return 'border-2 rounded-lg';
case 'minimal':
return 'border border-opacity-50';
default:
return 'border-2';
}
};
return (
<div className={`${compact ? 'space-y-4' : 'space-y-6'}`}>
{/* Controls */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-white">Your Certificate</h2>
<p className="text-gray-400 text-sm">
Credential ID: {certificate.credentialId}
<button
onClick={handleCopyCredential}
className="ml-2 text-blue-400 hover:text-blue-300"
title="Copy"
>
{copied ? (
<CheckBadgeIcon className="w-4 h-4 inline" />
) : (
<DocumentDuplicateIcon className="w-4 h-4 inline" />
)}
</button>
</p>
</div>
<div className="flex items-center gap-2">
{/* Template Selector */}
<select
value={selectedTemplate.id}
onChange={(e) => {
const template = TEMPLATES.find((t) => t.id === e.target.value);
if (template) setSelectedTemplate(template);
}}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
>
{TEMPLATES.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
{/* Print Button */}
{onPrint && (
<button
onClick={onPrint}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
title="Print"
>
<PrinterIcon className="w-5 h-5" />
</button>
)}
{/* Share Button */}
<div className="relative">
<button
onClick={() => setShowShareMenu(!showShareMenu)}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
title="Share"
>
<ShareIcon className="w-5 h-5" />
</button>
{showShareMenu && (
<div className="absolute right-0 top-full mt-2 w-48 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-10">
<div className="p-1">
<button
onClick={() => handleShare('linkedin')}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-white hover:bg-gray-700 rounded"
>
Share on LinkedIn
</button>
<button
onClick={() => handleShare('twitter')}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-white hover:bg-gray-700 rounded"
>
Share on Twitter
</button>
<button
onClick={() => handleShare('email')}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-white hover:bg-gray-700 rounded"
>
Send via Email
</button>
<button
onClick={() => handleShare('copy')}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-white hover:bg-gray-700 rounded"
>
Copy Link
</button>
</div>
</div>
)}
</div>
{/* Download Buttons */}
{onDownload && (
<div className="flex items-center gap-2">
<button
onClick={() => onDownload('pdf')}
disabled={isGenerating}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 transition-colors"
>
{isGenerating ? (
<ArrowPathIcon className="w-4 h-4 animate-spin" />
) : (
<ArrowDownTrayIcon className="w-4 h-4" />
)}
PDF
</button>
<button
onClick={() => onDownload('png')}
disabled={isGenerating}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 disabled:opacity-50 transition-colors"
>
PNG
</button>
</div>
)}
</div>
</div>
{/* Certificate Preview */}
<div className="flex justify-center">
<div
ref={certificateRef}
className={`relative w-full max-w-3xl aspect-[1.414/1] p-8 ${getBorderClasses(
selectedTemplate.borderStyle
)}`}
style={{
backgroundColor: selectedTemplate.backgroundColor,
borderColor: selectedTemplate.accentColor,
}}
>
{/* Decorative Elements */}
{selectedTemplate.borderStyle === 'classic' && (
<>
<div
className="absolute top-4 left-4 w-16 h-16 border-t-2 border-l-2"
style={{ borderColor: selectedTemplate.accentColor }}
/>
<div
className="absolute top-4 right-4 w-16 h-16 border-t-2 border-r-2"
style={{ borderColor: selectedTemplate.accentColor }}
/>
<div
className="absolute bottom-4 left-4 w-16 h-16 border-b-2 border-l-2"
style={{ borderColor: selectedTemplate.accentColor }}
/>
<div
className="absolute bottom-4 right-4 w-16 h-16 border-b-2 border-r-2"
style={{ borderColor: selectedTemplate.accentColor }}
/>
</>
)}
{/* Content */}
<div className="h-full flex flex-col justify-between text-center">
{/* Header */}
<div>
{certificate.issuerLogo ? (
<img
src={certificate.issuerLogo}
alt={certificate.issuerName}
className="h-12 mx-auto mb-4 object-contain"
/>
) : (
<div className="flex items-center justify-center gap-2 mb-4">
<AcademicCapIcon className="w-10 h-10" style={{ color: selectedTemplate.accentColor }} />
<span className="text-xl font-bold text-white">{certificate.issuerName}</span>
</div>
)}
<h1 className="text-3xl md:text-4xl font-serif text-white mb-2">
Certificate of Completion
</h1>
<div
className="w-32 h-1 mx-auto rounded"
style={{ backgroundColor: selectedTemplate.accentColor }}
/>
</div>
{/* Body */}
<div className="py-8">
<p className="text-gray-400 text-lg mb-2">This is to certify that</p>
<p
className="text-3xl md:text-4xl font-semibold mb-4"
style={{ color: selectedTemplate.accentColor }}
>
{certificate.recipientName}
</p>
<p className="text-gray-400 text-lg mb-2">has successfully completed the course</p>
<p className="text-xl md:text-2xl font-medium text-white mb-4">
{certificate.courseName}
</p>
{/* Details */}
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-400 mt-6">
<div>
<span className="block text-gray-500">Instructor</span>
<span className="text-white">{certificate.courseInstructor}</span>
</div>
<div>
<span className="block text-gray-500">Duration</span>
<span className="text-white">{formatDuration(certificate.courseDuration)}</span>
</div>
{certificate.grade && (
<div>
<span className="block text-gray-500">Grade</span>
<span className="text-white">{certificate.grade}%</span>
</div>
)}
<div>
<span className="block text-gray-500">Completed</span>
<span className="text-white">{formatDate(certificate.completionDate)}</span>
</div>
</div>
{/* Skills */}
{selectedTemplate.showSkills && certificate.skills && certificate.skills.length > 0 && (
<div className="mt-6">
<p className="text-gray-500 text-sm mb-2">Skills Acquired</p>
<div className="flex flex-wrap justify-center gap-2">
{certificate.skills.map((skill) => (
<span
key={skill}
className="px-3 py-1 text-xs rounded-full"
style={{
backgroundColor: `${selectedTemplate.accentColor}20`,
color: selectedTemplate.accentColor,
}}
>
{skill}
</span>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-end justify-between">
{/* Signature */}
<div className="text-left">
{certificate.signatureUrl && (
<img
src={certificate.signatureUrl}
alt="Signature"
className="h-12 mb-1 object-contain"
/>
)}
<div className="w-40 border-t border-gray-600 pt-1">
<p className="text-white text-sm">{certificate.courseInstructor}</p>
<p className="text-gray-500 text-xs">Instructor</p>
</div>
</div>
{/* Badge */}
{selectedTemplate.showBadge && (
<div
className="flex flex-col items-center"
style={{ color: selectedTemplate.accentColor }}
>
<CheckBadgeIcon className="w-16 h-16" />
<span className="text-xs text-gray-500 mt-1">Verified</span>
</div>
)}
{/* Credential ID */}
<div className="text-right">
<p className="text-gray-500 text-xs mb-1">Credential ID</p>
<p className="text-white text-sm font-mono">{certificate.credentialId}</p>
</div>
</div>
</div>
</div>
</div>
{/* Verification Info */}
<div className="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
<div className="flex items-start gap-3">
<CheckBadgeIcon className="w-6 h-6 text-green-400 flex-shrink-0" />
<div>
<h3 className="text-white font-medium">Certificate Verification</h3>
<p className="text-gray-400 text-sm mt-1">
This certificate can be verified using the credential ID above. Share the verification
link with employers or add it to your LinkedIn profile.
</p>
<div className="flex items-center gap-2 mt-3">
<input
type="text"
value={`https://platform.com/verify/${certificate.credentialId}`}
readOnly
className="flex-1 bg-gray-900/50 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-400"
/>
<button
onClick={handleCopyCredential}
className="px-3 py-1.5 bg-gray-700 text-white text-sm rounded hover:bg-gray-600 transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default CertificateGenerator;

View File

@ -0,0 +1,483 @@
/**
* CreatorDashboard Component
* Dashboard for course creators with analytics and content management
* Epic: OQI-002 Modulo Educativo
*/
import React, { useState, useMemo } from 'react';
import {
AcademicCapIcon,
PlayCircleIcon,
UsersIcon,
CurrencyDollarIcon,
ChartBarIcon,
PlusIcon,
PencilIcon,
EyeIcon,
TrashIcon,
ArrowPathIcon,
StarIcon,
ClockIcon,
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
FunnelIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/solid';
// Types
export interface CreatorCourse {
id: string;
title: string;
thumbnail?: string;
status: 'draft' | 'published' | 'archived';
studentsEnrolled: number;
rating: number;
reviewsCount: number;
lessonsCount: number;
totalDuration: number;
revenue: number;
completionRate: number;
createdAt: Date;
updatedAt: Date;
}
export interface CreatorStats {
totalStudents: number;
totalCourses: number;
totalRevenue: number;
avgRating: number;
totalViews: number;
totalCompletions: number;
revenueChange: number;
studentsChange: number;
}
export interface RecentActivity {
id: string;
type: 'enrollment' | 'review' | 'completion' | 'revenue';
message: string;
timestamp: Date;
courseId?: string;
courseName?: string;
}
interface CreatorDashboardProps {
stats: CreatorStats;
courses: CreatorCourse[];
recentActivity: RecentActivity[];
onCreateCourse?: () => void;
onEditCourse?: (courseId: string) => void;
onViewCourse?: (courseId: string) => void;
onDeleteCourse?: (courseId: string) => void;
onRefresh?: () => void;
isLoading?: boolean;
compact?: boolean;
}
type CourseFilter = 'all' | 'published' | 'draft' | 'archived';
type SortOption = 'newest' | 'popular' | 'revenue' | 'rating';
const CreatorDashboard: React.FC<CreatorDashboardProps> = ({
stats,
courses,
recentActivity,
onCreateCourse,
onEditCourse,
onViewCourse,
onDeleteCourse,
onRefresh,
isLoading = false,
compact = false,
}) => {
const [filter, setFilter] = useState<CourseFilter>('all');
const [sortBy, setSortBy] = useState<SortOption>('newest');
const [searchQuery, setSearchQuery] = useState('');
// Filter and sort courses
const filteredCourses = useMemo(() => {
let result = [...courses];
// Filter by status
if (filter !== 'all') {
result = result.filter((c) => c.status === filter);
}
// Search
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter((c) => c.title.toLowerCase().includes(query));
}
// Sort
switch (sortBy) {
case 'newest':
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
break;
case 'popular':
result.sort((a, b) => b.studentsEnrolled - a.studentsEnrolled);
break;
case 'revenue':
result.sort((a, b) => b.revenue - a.revenue);
break;
case 'rating':
result.sort((a, b) => b.rating - a.rating);
break;
}
return result;
}, [courses, filter, sortBy, searchQuery]);
// Format helpers
const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const formatNumber = (num: number): string => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
};
const formatDuration = (minutes: number): string => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) return `${mins}m`;
return `${hours}h ${mins}m`;
};
const formatTimeAgo = (date: Date): string => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
};
const getStatusColor = (status: CreatorCourse['status']) => {
switch (status) {
case 'published':
return 'bg-green-500/20 text-green-400';
case 'draft':
return 'bg-yellow-500/20 text-yellow-400';
case 'archived':
return 'bg-gray-500/20 text-gray-400';
}
};
const getActivityIcon = (type: RecentActivity['type']) => {
switch (type) {
case 'enrollment':
return <UsersIcon className="w-4 h-4 text-blue-400" />;
case 'review':
return <StarIcon className="w-4 h-4 text-yellow-400" />;
case 'completion':
return <AcademicCapIcon className="w-4 h-4 text-green-400" />;
case 'revenue':
return <CurrencyDollarIcon className="w-4 h-4 text-emerald-400" />;
}
};
return (
<div className={`space-y-6 ${compact ? '' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Creator Dashboard</h1>
<p className="text-gray-400 text-sm mt-1">Manage your courses and track performance</p>
</div>
<div className="flex items-center gap-3">
{onRefresh && (
<button
onClick={onRefresh}
disabled={isLoading}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
>
<ArrowPathIcon className={`w-5 h-5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
)}
{onCreateCourse && (
<button
onClick={onCreateCourse}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
<PlusIcon className="w-5 h-5" />
Create Course
</button>
)}
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
<div className="flex items-center justify-between mb-2">
<UsersIcon className="w-8 h-8 text-blue-400" />
<div
className={`flex items-center gap-1 text-xs ${
stats.studentsChange >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{stats.studentsChange >= 0 ? (
<ArrowTrendingUpIcon className="w-3 h-3" />
) : (
<ArrowTrendingDownIcon className="w-3 h-3" />
)}
{Math.abs(stats.studentsChange)}%
</div>
</div>
<p className="text-2xl font-bold text-white">{formatNumber(stats.totalStudents)}</p>
<p className="text-gray-400 text-sm">Total Students</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
<div className="flex items-center justify-between mb-2">
<CurrencyDollarIcon className="w-8 h-8 text-emerald-400" />
<div
className={`flex items-center gap-1 text-xs ${
stats.revenueChange >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{stats.revenueChange >= 0 ? (
<ArrowTrendingUpIcon className="w-3 h-3" />
) : (
<ArrowTrendingDownIcon className="w-3 h-3" />
)}
{Math.abs(stats.revenueChange)}%
</div>
</div>
<p className="text-2xl font-bold text-white">{formatCurrency(stats.totalRevenue)}</p>
<p className="text-gray-400 text-sm">Total Revenue</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
<div className="flex items-center justify-between mb-2">
<AcademicCapIcon className="w-8 h-8 text-purple-400" />
</div>
<p className="text-2xl font-bold text-white">{stats.totalCourses}</p>
<p className="text-gray-400 text-sm">Total Courses</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
<div className="flex items-center justify-between mb-2">
<StarIcon className="w-8 h-8 text-yellow-400" />
</div>
<p className="text-2xl font-bold text-white">{stats.avgRating.toFixed(1)}</p>
<p className="text-gray-400 text-sm">Average Rating</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Courses Section */}
<div className="lg:col-span-2 bg-gray-800/50 rounded-lg border border-gray-700 overflow-hidden">
{/* Filters Header */}
<div className="p-4 border-b border-gray-700">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="relative flex-1">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search courses..."
className="w-full bg-gray-900/50 border border-gray-700 rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="flex items-center gap-2">
<select
value={filter}
onChange={(e) => setFilter(e.target.value as CourseFilter)}
className="bg-gray-900/50 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
>
<option value="all">All Status</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="bg-gray-900/50 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
>
<option value="newest">Newest</option>
<option value="popular">Most Popular</option>
<option value="revenue">Highest Revenue</option>
<option value="rating">Best Rated</option>
</select>
</div>
</div>
</div>
{/* Courses List */}
<div className="divide-y divide-gray-700 max-h-[500px] overflow-y-auto">
{filteredCourses.length > 0 ? (
filteredCourses.map((course) => (
<div
key={course.id}
className="p-4 hover:bg-gray-700/30 transition-colors"
>
<div className="flex items-start gap-4">
{/* Thumbnail */}
<div className="w-24 aspect-video bg-gray-700 rounded-lg overflow-hidden flex-shrink-0">
{course.thumbnail ? (
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<PlayCircleIcon className="w-8 h-8 text-gray-500" />
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-white font-medium truncate">{course.title}</h3>
<span
className={`inline-block mt-1 px-2 py-0.5 rounded text-xs ${getStatusColor(
course.status
)}`}
>
{course.status}
</span>
</div>
<div className="flex items-center gap-1">
{onViewCourse && (
<button
onClick={() => onViewCourse(course.id)}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="View"
>
<EyeIcon className="w-4 h-4" />
</button>
)}
{onEditCourse && (
<button
onClick={() => onEditCourse(course.id)}
className="p-1.5 text-gray-400 hover:text-blue-400 hover:bg-blue-500/20 rounded transition-colors"
title="Edit"
>
<PencilIcon className="w-4 h-4" />
</button>
)}
{onDeleteCourse && (
<button
onClick={() => onDeleteCourse(course.id)}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-500/20 rounded transition-colors"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Stats */}
<div className="flex flex-wrap items-center gap-4 mt-2 text-sm">
<div className="flex items-center gap-1 text-gray-400">
<UsersIcon className="w-4 h-4" />
<span>{formatNumber(course.studentsEnrolled)}</span>
</div>
<div className="flex items-center gap-1 text-gray-400">
<StarIcon className="w-4 h-4 text-yellow-400" />
<span>
{course.rating.toFixed(1)} ({course.reviewsCount})
</span>
</div>
<div className="flex items-center gap-1 text-gray-400">
<PlayCircleIcon className="w-4 h-4" />
<span>{course.lessonsCount} lessons</span>
</div>
<div className="flex items-center gap-1 text-emerald-400">
<CurrencyDollarIcon className="w-4 h-4" />
<span>{formatCurrency(course.revenue)}</span>
</div>
</div>
{/* Completion Rate */}
<div className="mt-2">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-400">Completion Rate</span>
<span className="text-white">{course.completionRate}%</span>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${course.completionRate}%` }}
/>
</div>
</div>
</div>
</div>
</div>
))
) : (
<div className="p-8 text-center">
<AcademicCapIcon className="w-12 h-12 mx-auto text-gray-500 mb-3" />
<p className="text-gray-400">No courses found</p>
{onCreateCourse && (
<button
onClick={onCreateCourse}
className="mt-3 text-blue-400 text-sm hover:underline"
>
Create your first course
</button>
)}
</div>
)}
</div>
</div>
{/* Recent Activity */}
<div className="bg-gray-800/50 rounded-lg border border-gray-700 overflow-hidden">
<div className="p-4 border-b border-gray-700">
<h2 className="text-white font-semibold">Recent Activity</h2>
</div>
<div className="divide-y divide-gray-700 max-h-[500px] overflow-y-auto">
{recentActivity.length > 0 ? (
recentActivity.map((activity) => (
<div key={activity.id} className="p-4 hover:bg-gray-700/30 transition-colors">
<div className="flex items-start gap-3">
<div className="p-2 bg-gray-700 rounded-lg">
{getActivityIcon(activity.type)}
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm">{activity.message}</p>
{activity.courseName && (
<p className="text-gray-400 text-xs mt-0.5 truncate">{activity.courseName}</p>
)}
<p className="text-gray-500 text-xs mt-1">
<ClockIcon className="w-3 h-3 inline mr-1" />
{formatTimeAgo(activity.timestamp)}
</p>
</div>
</div>
</div>
))
) : (
<div className="p-8 text-center">
<ChartBarIcon className="w-12 h-12 mx-auto text-gray-500 mb-3" />
<p className="text-gray-400 text-sm">No recent activity</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default CreatorDashboard;

View File

@ -0,0 +1,588 @@
/**
* LiveStreamPlayer Component
* Live streaming player with chat and interaction features
* Epic: OQI-002 Modulo Educativo
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import {
PlayIcon,
PauseIcon,
SpeakerWaveIcon,
SpeakerXMarkIcon,
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
SignalIcon,
UsersIcon,
ChatBubbleLeftRightIcon,
HandRaisedIcon,
HeartIcon,
PaperAirplaneIcon,
Cog6ToothIcon,
XMarkIcon,
ExclamationCircleIcon,
ArrowPathIcon,
} from '@heroicons/react/24/solid';
// Types
export interface StreamInfo {
id: string;
title: string;
instructorName: string;
instructorAvatar?: string;
status: 'live' | 'starting' | 'ended' | 'scheduled';
viewerCount: number;
startTime: Date;
scheduledTime?: Date;
quality: 'auto' | '1080p' | '720p' | '480p' | '360p';
latency: number;
}
export interface ChatMessage {
id: string;
userId: string;
userName: string;
userAvatar?: string;
message: string;
timestamp: Date;
type: 'message' | 'system' | 'question' | 'highlight';
isInstructor?: boolean;
isPinned?: boolean;
}
export interface StreamReaction {
type: 'like' | 'love' | 'fire' | 'clap' | 'question';
count: number;
}
interface LiveStreamPlayerProps {
streamUrl: string;
streamInfo: StreamInfo;
chatMessages: ChatMessage[];
reactions?: StreamReaction[];
onSendMessage?: (message: string) => void;
onRaiseHand?: () => void;
onReaction?: (type: StreamReaction['type']) => void;
onQualityChange?: (quality: StreamInfo['quality']) => void;
onReportIssue?: () => void;
isHandRaised?: boolean;
isChatEnabled?: boolean;
compact?: boolean;
}
const QUALITY_OPTIONS: { value: StreamInfo['quality']; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: '1080p', label: '1080p HD' },
{ value: '720p', label: '720p' },
{ value: '480p', label: '480p' },
{ value: '360p', label: '360p' },
];
const REACTION_EMOJIS: Record<StreamReaction['type'], string> = {
like: '👍',
love: '❤️',
fire: '🔥',
clap: '👏',
question: '❓',
};
const LiveStreamPlayer: React.FC<LiveStreamPlayerProps> = ({
streamUrl,
streamInfo,
chatMessages,
reactions = [],
onSendMessage,
onRaiseHand,
onReaction,
onQualityChange,
onReportIssue,
isHandRaised = false,
isChatEnabled = true,
compact = false,
}) => {
// Video state
const videoRef = useRef<HTMLVideoElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(true);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showControls, setShowControls] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [showChat, setShowChat] = useState(true);
const [chatInput, setChatInput] = useState('');
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'reconnecting' | 'disconnected'>('connected');
// Auto-hide controls
useEffect(() => {
let timeout: NodeJS.Timeout;
const hideControls = () => {
if (isPlaying && !showSettings) {
timeout = setTimeout(() => setShowControls(false), 3000);
}
};
hideControls();
return () => clearTimeout(timeout);
}, [isPlaying, showSettings, showControls]);
// Auto-scroll chat
useEffect(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
}, [chatMessages]);
// Handle play/pause
const togglePlay = useCallback(() => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
}, [isPlaying]);
// Handle mute
const toggleMute = useCallback(() => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
}, [isMuted]);
// Handle volume change
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
if (videoRef.current) {
videoRef.current.volume = newVolume;
setIsMuted(newVolume === 0);
}
}, []);
// Handle fullscreen
const toggleFullscreen = useCallback(() => {
const container = videoRef.current?.parentElement?.parentElement;
if (!container) return;
if (!isFullscreen) {
if (container.requestFullscreen) {
container.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
setIsFullscreen(!isFullscreen);
}, [isFullscreen]);
// Handle send message
const handleSendMessage = useCallback(() => {
if (chatInput.trim() && onSendMessage) {
onSendMessage(chatInput.trim());
setChatInput('');
}
}, [chatInput, onSendMessage]);
// Format time
const formatTime = (date: Date): string => {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
// Format duration since start
const formatDuration = (startTime: Date): string => {
const diff = Date.now() - startTime.getTime();
const hours = Math.floor(diff / 3600000);
const minutes = Math.floor((diff % 3600000) / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
if (hours > 0) return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
// Get status badge
const getStatusBadge = () => {
switch (streamInfo.status) {
case 'live':
return (
<span className="flex items-center gap-1 px-2 py-1 bg-red-500 text-white text-xs font-medium rounded">
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
LIVE
</span>
);
case 'starting':
return (
<span className="flex items-center gap-1 px-2 py-1 bg-yellow-500 text-black text-xs font-medium rounded">
<ArrowPathIcon className="w-3 h-3 animate-spin" />
Starting
</span>
);
case 'ended':
return (
<span className="px-2 py-1 bg-gray-500 text-white text-xs font-medium rounded">
Ended
</span>
);
case 'scheduled':
return (
<span className="px-2 py-1 bg-blue-500 text-white text-xs font-medium rounded">
Scheduled
</span>
);
}
};
return (
<div className={`flex flex-col lg:flex-row gap-4 ${compact ? '' : ''}`}>
{/* Video Player Section */}
<div className="flex-1">
<div
className="relative bg-black rounded-lg overflow-hidden aspect-video group"
onMouseMove={() => setShowControls(true)}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
{/* Video Element */}
<video
ref={videoRef}
src={streamUrl}
className="w-full h-full object-contain"
autoPlay
playsInline
onClick={togglePlay}
/>
{/* Status Overlay */}
{streamInfo.status !== 'live' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/70">
{streamInfo.status === 'starting' && (
<div className="text-center">
<ArrowPathIcon className="w-12 h-12 mx-auto text-white animate-spin mb-4" />
<p className="text-white text-lg">Stream starting soon...</p>
</div>
)}
{streamInfo.status === 'ended' && (
<div className="text-center">
<p className="text-white text-lg">Stream has ended</p>
<p className="text-gray-400 mt-2">Thank you for watching!</p>
</div>
)}
{streamInfo.status === 'scheduled' && streamInfo.scheduledTime && (
<div className="text-center">
<p className="text-white text-lg">Stream scheduled for</p>
<p className="text-blue-400 text-xl mt-2">
{streamInfo.scheduledTime.toLocaleString()}
</p>
</div>
)}
</div>
)}
{/* Top Overlay */}
<div
className={`absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${
showControls ? 'opacity-100' : 'opacity-0'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{getStatusBadge()}
<div className="flex items-center gap-2 text-white text-sm">
<UsersIcon className="w-4 h-4" />
<span>{streamInfo.viewerCount.toLocaleString()} watching</span>
</div>
{streamInfo.status === 'live' && (
<div className="text-gray-400 text-sm">
{formatDuration(streamInfo.startTime)}
</div>
)}
</div>
{/* Connection Status */}
{connectionStatus !== 'connected' && (
<div
className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
connectionStatus === 'reconnecting'
? 'bg-yellow-500/20 text-yellow-400'
: 'bg-red-500/20 text-red-400'
}`}
>
<SignalIcon className="w-3 h-3" />
{connectionStatus === 'reconnecting' ? 'Reconnecting...' : 'Disconnected'}
</div>
)}
</div>
</div>
{/* Bottom Controls */}
<div
className={`absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/70 to-transparent transition-opacity ${
showControls ? 'opacity-100' : 'opacity-0'
}`}
>
<div className="flex items-center gap-4">
{/* Play/Pause */}
<button
onClick={togglePlay}
className="text-white hover:text-blue-400 transition-colors"
>
{isPlaying ? (
<PauseIcon className="w-8 h-8" />
) : (
<PlayIcon className="w-8 h-8" />
)}
</button>
{/* Volume */}
<div className="flex items-center gap-2">
<button onClick={toggleMute} className="text-white hover:text-blue-400">
{isMuted || volume === 0 ? (
<SpeakerXMarkIcon className="w-6 h-6" />
) : (
<SpeakerWaveIcon className="w-6 h-6" />
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
/>
</div>
<div className="flex-1" />
{/* Latency Indicator */}
<div className="flex items-center gap-1 text-gray-400 text-xs">
<SignalIcon className="w-3 h-3" />
<span>{streamInfo.latency}ms</span>
</div>
{/* Quality Selector */}
<select
value={streamInfo.quality}
onChange={(e) => onQualityChange?.(e.target.value as StreamInfo['quality'])}
className="bg-transparent text-white text-sm border border-gray-600 rounded px-2 py-1"
>
{QUALITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} className="bg-gray-800">
{opt.label}
</option>
))}
</select>
{/* Settings */}
<button
onClick={() => setShowSettings(!showSettings)}
className="text-white hover:text-blue-400 transition-colors"
>
<Cog6ToothIcon className="w-6 h-6" />
</button>
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
className="text-white hover:text-blue-400 transition-colors"
>
{isFullscreen ? (
<ArrowsPointingInIcon className="w-6 h-6" />
) : (
<ArrowsPointingOutIcon className="w-6 h-6" />
)}
</button>
</div>
</div>
{/* Floating Reactions */}
{reactions.length > 0 && (
<div className="absolute bottom-20 right-4 flex flex-col gap-2">
{reactions.map((reaction) => (
<button
key={reaction.type}
onClick={() => onReaction?.(reaction.type)}
className="flex items-center gap-1 px-2 py-1 bg-black/50 rounded-full text-white text-sm hover:bg-black/70 transition-colors"
>
<span>{REACTION_EMOJIS[reaction.type]}</span>
<span>{reaction.count}</span>
</button>
))}
</div>
)}
</div>
{/* Stream Info */}
<div className="mt-4 bg-gray-800/50 rounded-lg p-4 border border-gray-700">
<h1 className="text-xl font-bold text-white">{streamInfo.title}</h1>
<div className="flex items-center gap-4 mt-3">
{streamInfo.instructorAvatar ? (
<img
src={streamInfo.instructorAvatar}
alt={streamInfo.instructorName}
className="w-10 h-10 rounded-full"
/>
) : (
<div className="w-10 h-10 bg-gray-700 rounded-full flex items-center justify-center">
<span className="text-white font-medium">
{streamInfo.instructorName.charAt(0)}
</span>
</div>
)}
<div>
<p className="text-white font-medium">{streamInfo.instructorName}</p>
<p className="text-gray-400 text-sm">Instructor</p>
</div>
<div className="flex-1" />
{/* Action Buttons */}
<div className="flex items-center gap-2">
{onRaiseHand && (
<button
onClick={onRaiseHand}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
isHandRaised
? 'bg-yellow-500 text-black'
: 'bg-gray-700 text-white hover:bg-gray-600'
}`}
>
<HandRaisedIcon className="w-5 h-5" />
{isHandRaised ? 'Hand Raised' : 'Raise Hand'}
</button>
)}
<button
onClick={() => setShowChat(!showChat)}
className={`p-2 rounded-lg transition-colors lg:hidden ${
showChat ? 'bg-blue-500 text-white' : 'bg-gray-700 text-white hover:bg-gray-600'
}`}
>
<ChatBubbleLeftRightIcon className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
{/* Chat Section */}
{showChat && isChatEnabled && (
<div className="w-full lg:w-80 bg-gray-800/50 rounded-lg border border-gray-700 flex flex-col h-[500px] lg:h-auto">
{/* Chat Header */}
<div className="p-3 border-b border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-2">
<ChatBubbleLeftRightIcon className="w-5 h-5 text-blue-400" />
<span className="text-white font-medium">Live Chat</span>
</div>
<button
onClick={() => setShowChat(false)}
className="p-1 text-gray-400 hover:text-white lg:hidden"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
{/* Chat Messages */}
<div ref={chatContainerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{chatMessages.map((msg) => (
<div
key={msg.id}
className={`${
msg.type === 'system'
? 'text-center'
: msg.isPinned
? 'bg-blue-500/10 border border-blue-500/30 rounded-lg p-2'
: ''
}`}
>
{msg.type === 'system' ? (
<p className="text-gray-500 text-xs">{msg.message}</p>
) : (
<div className="flex items-start gap-2">
{msg.userAvatar ? (
<img
src={msg.userAvatar}
alt={msg.userName}
className="w-6 h-6 rounded-full flex-shrink-0"
/>
) : (
<div className="w-6 h-6 bg-gray-700 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs">{msg.userName.charAt(0)}</span>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={`text-sm font-medium ${
msg.isInstructor ? 'text-blue-400' : 'text-gray-300'
}`}
>
{msg.userName}
</span>
{msg.isInstructor && (
<span className="px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] rounded">
Instructor
</span>
)}
<span className="text-gray-500 text-xs">{formatTime(msg.timestamp)}</span>
</div>
<p
className={`text-sm mt-0.5 break-words ${
msg.type === 'question' ? 'text-yellow-400' : 'text-white'
}`}
>
{msg.type === 'question' && '❓ '}
{msg.message}
</p>
</div>
</div>
)}
</div>
))}
</div>
{/* Chat Input */}
<div className="p-3 border-t border-gray-700">
<div className="flex items-center gap-2">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
placeholder="Send a message..."
className="flex-1 bg-gray-900/50 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
<button
onClick={handleSendMessage}
disabled={!chatInput.trim()}
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<PaperAirplaneIcon className="w-5 h-5" />
</button>
</div>
{/* Quick Reactions */}
<div className="flex items-center gap-1 mt-2">
{Object.entries(REACTION_EMOJIS).map(([type, emoji]) => (
<button
key={type}
onClick={() => onReaction?.(type as StreamReaction['type'])}
className="p-1.5 hover:bg-gray-700 rounded transition-colors text-lg"
title={type}
>
{emoji}
</button>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default LiveStreamPlayer;

View File

@ -0,0 +1,663 @@
/**
* VideoUploadForm Component
* Multi-step form for uploading educational videos with metadata
* Epic: OQI-002 Modulo Educativo
*/
import React, { useState, useRef, useCallback, useMemo } from 'react';
import {
CloudArrowUpIcon,
DocumentTextIcon,
PhotoIcon,
XMarkIcon,
CheckCircleIcon,
ExclamationCircleIcon,
PlayIcon,
PauseIcon,
ArrowPathIcon,
} from '@heroicons/react/24/solid';
// Types
export interface VideoMetadata {
title: string;
description: string;
duration?: number;
thumbnail?: string;
tags: string[];
language: string;
difficulty: 'beginner' | 'intermediate' | 'advanced' | 'expert';
transcript?: string;
captions?: { language: string; url: string }[];
}
export interface UploadProgress {
status: 'idle' | 'uploading' | 'processing' | 'completed' | 'error';
progress: number;
message?: string;
videoId?: string;
}
interface VideoUploadFormProps {
courseId: string;
lessonId?: string;
onUploadComplete?: (videoId: string, metadata: VideoMetadata) => void;
onCancel?: () => void;
maxFileSizeMB?: number;
acceptedFormats?: string[];
compact?: boolean;
}
const DEFAULT_ACCEPTED_FORMATS = ['video/mp4', 'video/webm', 'video/quicktime'];
const DIFFICULTY_OPTIONS = [
{ value: 'beginner', label: 'Beginner', color: 'text-green-400' },
{ value: 'intermediate', label: 'Intermediate', color: 'text-yellow-400' },
{ value: 'advanced', label: 'Advanced', color: 'text-orange-400' },
{ value: 'expert', label: 'Expert', color: 'text-red-400' },
];
const VideoUploadForm: React.FC<VideoUploadFormProps> = ({
courseId,
lessonId,
onUploadComplete,
onCancel,
maxFileSizeMB = 500,
acceptedFormats = DEFAULT_ACCEPTED_FORMATS,
compact = false,
}) => {
// File state
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
// Metadata state
const [metadata, setMetadata] = useState<VideoMetadata>({
title: '',
description: '',
tags: [],
language: 'en',
difficulty: 'beginner',
});
const [tagInput, setTagInput] = useState('');
// Upload state
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
status: 'idle',
progress: 0,
});
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
const [errors, setErrors] = useState<Record<string, string>>({});
// Refs
const fileInputRef = useRef<HTMLInputElement>(null);
const thumbnailInputRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
// File validation
const validateFile = useCallback(
(file: File): string | null => {
if (!acceptedFormats.includes(file.type)) {
return `Invalid format. Accepted: ${acceptedFormats.map((f) => f.split('/')[1]).join(', ')}`;
}
if (file.size > maxFileSizeMB * 1024 * 1024) {
return `File too large. Maximum: ${maxFileSizeMB}MB`;
}
return null;
},
[acceptedFormats, maxFileSizeMB]
);
// Handle file selection
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const error = validateFile(file);
if (error) {
setErrors({ file: error });
return;
}
setSelectedFile(file);
setErrors({});
// Create preview URL
const url = URL.createObjectURL(file);
setPreviewUrl(url);
// Auto-fill title from filename
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
setMetadata((prev) => ({
...prev,
title: prev.title || nameWithoutExt.replace(/[-_]/g, ' '),
}));
// Get video duration
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
setMetadata((prev) => ({ ...prev, duration: Math.floor(video.duration) }));
URL.revokeObjectURL(video.src);
};
video.src = url;
},
[validateFile]
);
// Handle thumbnail selection
const handleThumbnailSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
setErrors((prev) => ({ ...prev, thumbnail: 'Please select an image file' }));
return;
}
setThumbnailFile(file);
setThumbnailPreview(URL.createObjectURL(file));
setErrors((prev) => {
const { thumbnail, ...rest } = prev;
return rest;
});
}, []);
// Handle drag and drop
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('video/')) {
const fakeEvent = {
target: { files: [file] },
} as unknown as React.ChangeEvent<HTMLInputElement>;
handleFileSelect(fakeEvent);
}
},
[handleFileSelect]
);
// Handle tag management
const addTag = useCallback(() => {
const tag = tagInput.trim().toLowerCase();
if (tag && !metadata.tags.includes(tag) && metadata.tags.length < 10) {
setMetadata((prev) => ({ ...prev, tags: [...prev.tags, tag] }));
setTagInput('');
}
}, [tagInput, metadata.tags]);
const removeTag = useCallback((tag: string) => {
setMetadata((prev) => ({ ...prev, tags: prev.tags.filter((t) => t !== tag) }));
}, []);
// Validate metadata
const validateMetadata = useCallback((): boolean => {
const newErrors: Record<string, string> = {};
if (!metadata.title.trim()) {
newErrors.title = 'Title is required';
} else if (metadata.title.length > 100) {
newErrors.title = 'Title must be less than 100 characters';
}
if (!metadata.description.trim()) {
newErrors.description = 'Description is required';
} else if (metadata.description.length > 5000) {
newErrors.description = 'Description must be less than 5000 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [metadata]);
// Simulate upload (replace with actual API call)
const handleUpload = useCallback(async () => {
if (!selectedFile || !validateMetadata()) return;
setUploadProgress({ status: 'uploading', progress: 0 });
try {
// Simulate upload progress
for (let i = 0; i <= 100; i += 10) {
await new Promise((r) => setTimeout(r, 200));
setUploadProgress({ status: 'uploading', progress: i, message: `Uploading... ${i}%` });
}
// Simulate processing
setUploadProgress({ status: 'processing', progress: 100, message: 'Processing video...' });
await new Promise((r) => setTimeout(r, 1500));
// Complete
const videoId = `vid_${Date.now()}`;
setUploadProgress({
status: 'completed',
progress: 100,
message: 'Upload complete!',
videoId,
});
onUploadComplete?.(videoId, metadata);
} catch (error) {
setUploadProgress({
status: 'error',
progress: 0,
message: error instanceof Error ? error.message : 'Upload failed',
});
}
}, [selectedFile, metadata, validateMetadata, onUploadComplete]);
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// Format duration
const formatDuration = (seconds: number): string => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
};
// Step validation
const canProceedToStep2 = selectedFile !== null;
const canProceedToStep3 = metadata.title.trim() !== '' && metadata.description.trim() !== '';
return (
<div className={`bg-gray-800/50 rounded-lg ${compact ? 'p-3' : 'p-6'}`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<CloudArrowUpIcon className="w-6 h-6 text-blue-400" />
<h2 className="text-lg font-semibold text-white">Upload Video</h2>
</div>
{onCancel && (
<button
onClick={onCancel}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
>
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
{/* Progress Steps */}
<div className="flex items-center gap-4 mb-8">
{[1, 2, 3].map((step) => (
<React.Fragment key={step}>
<div
className={`flex items-center gap-2 ${
currentStep >= step ? 'text-blue-400' : 'text-gray-500'
}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
currentStep > step
? 'bg-green-500 text-white'
: currentStep === step
? 'bg-blue-500 text-white'
: 'bg-gray-700 text-gray-400'
}`}
>
{currentStep > step ? <CheckCircleIcon className="w-5 h-5" /> : step}
</div>
<span className="text-sm hidden sm:inline">
{step === 1 ? 'Select File' : step === 2 ? 'Details' : 'Upload'}
</span>
</div>
{step < 3 && <div className="flex-1 h-px bg-gray-700" />}
</React.Fragment>
))}
</div>
{/* Step 1: File Selection */}
{currentStep === 1 && (
<div className="space-y-4">
{!selectedFile ? (
<div
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center hover:border-blue-500 hover:bg-gray-700/30 transition-colors cursor-pointer"
>
<CloudArrowUpIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
<p className="text-white font-medium mb-2">Drop your video here or click to browse</p>
<p className="text-gray-400 text-sm">
Accepted formats: {acceptedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}
</p>
<p className="text-gray-500 text-sm mt-1">Maximum size: {maxFileSizeMB}MB</p>
</div>
) : (
<div className="bg-gray-900/50 rounded-lg p-4">
<div className="flex items-start gap-4">
{/* Video Preview */}
<div className="relative w-48 aspect-video bg-black rounded-lg overflow-hidden flex-shrink-0">
{previewUrl && (
<video
ref={videoRef}
src={previewUrl}
className="w-full h-full object-cover"
muted
/>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<PlayIcon className="w-10 h-10 text-white/80" />
</div>
</div>
{/* File Info */}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{selectedFile.name}</p>
<p className="text-gray-400 text-sm mt-1">
{formatFileSize(selectedFile.size)}
{metadata.duration && `${formatDuration(metadata.duration)}`}
</p>
<button
onClick={() => {
setSelectedFile(null);
setPreviewUrl(null);
}}
className="text-red-400 text-sm mt-2 hover:underline"
>
Remove
</button>
</div>
</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept={acceptedFormats.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
{errors.file && (
<div className="flex items-center gap-2 text-red-400 text-sm">
<ExclamationCircleIcon className="w-4 h-4" />
{errors.file}
</div>
)}
<div className="flex justify-end">
<button
onClick={() => setCurrentStep(2)}
disabled={!canProceedToStep2}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Continue
</button>
</div>
</div>
)}
{/* Step 2: Metadata */}
{currentStep === 2 && (
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Title *</label>
<input
type="text"
value={metadata.title}
onChange={(e) => setMetadata((prev) => ({ ...prev, title: e.target.value }))}
placeholder="Enter video title"
className={`w-full bg-gray-900/50 border ${
errors.title ? 'border-red-500' : 'border-gray-700'
} rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none`}
/>
{errors.title && <p className="text-red-400 text-sm mt-1">{errors.title}</p>}
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Description *</label>
<textarea
value={metadata.description}
onChange={(e) => setMetadata((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Enter video description"
rows={4}
className={`w-full bg-gray-900/50 border ${
errors.description ? 'border-red-500' : 'border-gray-700'
} rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none resize-none`}
/>
{errors.description && <p className="text-red-400 text-sm mt-1">{errors.description}</p>}
</div>
{/* Thumbnail */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Thumbnail</label>
<div className="flex items-center gap-4">
{thumbnailPreview ? (
<div className="relative w-32 aspect-video rounded-lg overflow-hidden">
<img src={thumbnailPreview} alt="Thumbnail" className="w-full h-full object-cover" />
<button
onClick={() => {
setThumbnailFile(null);
setThumbnailPreview(null);
}}
className="absolute top-1 right-1 p-1 bg-black/50 rounded-full hover:bg-black/70"
>
<XMarkIcon className="w-3 h-3 text-white" />
</button>
</div>
) : (
<button
onClick={() => thumbnailInputRef.current?.click()}
className="w-32 aspect-video border-2 border-dashed border-gray-600 rounded-lg flex items-center justify-center hover:border-blue-500 transition-colors"
>
<PhotoIcon className="w-8 h-8 text-gray-500" />
</button>
)}
<input
ref={thumbnailInputRef}
type="file"
accept="image/*"
onChange={handleThumbnailSelect}
className="hidden"
/>
</div>
</div>
{/* Difficulty & Language */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Difficulty</label>
<select
value={metadata.difficulty}
onChange={(e) =>
setMetadata((prev) => ({
...prev,
difficulty: e.target.value as VideoMetadata['difficulty'],
}))
}
className="w-full bg-gray-900/50 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
>
{DIFFICULTY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Language</label>
<select
value={metadata.language}
onChange={(e) => setMetadata((prev) => ({ ...prev, language: e.target.value }))}
className="w-full bg-gray-900/50 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="pt">Portuguese</option>
</select>
</div>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">Tags</label>
<div className="flex flex-wrap gap-2 mb-2">
{metadata.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-500/20 text-blue-400 rounded text-sm"
>
{tag}
<button onClick={() => removeTag(tag)} className="hover:text-blue-300">
<XMarkIcon className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="Add a tag..."
className="flex-1 bg-gray-900/50 border border-gray-700 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none text-sm"
/>
<button
onClick={addTag}
disabled={!tagInput.trim() || metadata.tags.length >= 10}
className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 disabled:opacity-50 text-sm"
>
Add
</button>
</div>
</div>
<div className="flex justify-between pt-4">
<button
onClick={() => setCurrentStep(1)}
className="px-6 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
Back
</button>
<button
onClick={() => validateMetadata() && setCurrentStep(3)}
disabled={!canProceedToStep3}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Continue
</button>
</div>
</div>
)}
{/* Step 3: Upload */}
{currentStep === 3 && (
<div className="space-y-6">
{/* Summary */}
<div className="bg-gray-900/50 rounded-lg p-4 space-y-3">
<h3 className="text-white font-medium">Upload Summary</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-400">Title:</span>
<p className="text-white truncate">{metadata.title}</p>
</div>
<div>
<span className="text-gray-400">File:</span>
<p className="text-white truncate">{selectedFile?.name}</p>
</div>
<div>
<span className="text-gray-400">Duration:</span>
<p className="text-white">
{metadata.duration ? formatDuration(metadata.duration) : 'Unknown'}
</p>
</div>
<div>
<span className="text-gray-400">Difficulty:</span>
<p className="text-white capitalize">{metadata.difficulty}</p>
</div>
</div>
</div>
{/* Upload Progress */}
{uploadProgress.status !== 'idle' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">{uploadProgress.message}</span>
<span className="text-sm text-white">{uploadProgress.progress}%</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
uploadProgress.status === 'error'
? 'bg-red-500'
: uploadProgress.status === 'completed'
? 'bg-green-500'
: 'bg-blue-500'
}`}
style={{ width: `${uploadProgress.progress}%` }}
/>
</div>
</div>
)}
{/* Status Icons */}
{uploadProgress.status === 'completed' && (
<div className="flex items-center gap-3 text-green-400">
<CheckCircleIcon className="w-6 h-6" />
<span>Video uploaded successfully!</span>
</div>
)}
{uploadProgress.status === 'error' && (
<div className="flex items-center gap-3 text-red-400">
<ExclamationCircleIcon className="w-6 h-6" />
<span>{uploadProgress.message}</span>
</div>
)}
<div className="flex justify-between pt-4">
<button
onClick={() => setCurrentStep(2)}
disabled={uploadProgress.status === 'uploading' || uploadProgress.status === 'processing'}
className="px-6 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 disabled:opacity-50 transition-colors"
>
Back
</button>
<button
onClick={handleUpload}
disabled={
uploadProgress.status === 'uploading' ||
uploadProgress.status === 'processing' ||
uploadProgress.status === 'completed'
}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{uploadProgress.status === 'uploading' || uploadProgress.status === 'processing' ? (
<>
<ArrowPathIcon className="w-4 h-4 animate-spin" />
{uploadProgress.status === 'uploading' ? 'Uploading...' : 'Processing...'}
</>
) : uploadProgress.status === 'completed' ? (
<>
<CheckCircleIcon className="w-4 h-4" />
Done
</>
) : (
<>
<CloudArrowUpIcon className="w-4 h-4" />
Upload Video
</>
)}
</button>
</div>
</div>
)}
</div>
);
};
export default VideoUploadForm;

View File

@ -21,3 +21,15 @@ export { default as VideoProgressPlayer } from './VideoProgressPlayer';
export type { VideoBookmark, VideoNote } from './VideoProgressPlayer';
export { default as AssessmentSummaryCard } from './AssessmentSummaryCard';
export type { QuestionResult, AssessmentResult } from './AssessmentSummaryCard';
// Creator & Upload Components (OQI-002)
export { default as VideoUploadForm } from './VideoUploadForm';
export type { VideoMetadata, UploadProgress } from './VideoUploadForm';
export { default as CreatorDashboard } from './CreatorDashboard';
export type { CreatorCourse, CreatorStats, RecentActivity } from './CreatorDashboard';
// Certificate & Live Streaming Components (OQI-002)
export { default as CertificateGenerator } from './CertificateGenerator';
export type { CertificateData, CertificateTemplate } from './CertificateGenerator';
export { default as LiveStreamPlayer } from './LiveStreamPlayer';
export type { StreamInfo, ChatMessage, StreamReaction } from './LiveStreamPlayer';