[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:
parent
c145878c24
commit
fc99c34749
452
src/modules/education/components/CertificateGenerator.tsx
Normal file
452
src/modules/education/components/CertificateGenerator.tsx
Normal 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;
|
||||
483
src/modules/education/components/CreatorDashboard.tsx
Normal file
483
src/modules/education/components/CreatorDashboard.tsx
Normal 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;
|
||||
588
src/modules/education/components/LiveStreamPlayer.tsx
Normal file
588
src/modules/education/components/LiveStreamPlayer.tsx
Normal 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;
|
||||
663
src/modules/education/components/VideoUploadForm.tsx
Normal file
663
src/modules/education/components/VideoUploadForm.tsx
Normal 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;
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user