Spaces:
Running
Running
Download src/components/certification-flow/LearningModule.tsx from Sarvesh213/getcert-backend: direct link, hf CLI and curl.
- Browser
- Download file 40.4 kB
-
https://huggingface.co/spaces/Sarvesh213/getcert-backend/resolve/main/src/components/certification-flow/LearningModule.tsx
- Command line
-
hf download hf://spaces/Sarvesh213/getcert-backend/src/components/certification-flow/LearningModule.tsx
-
curl -L -o LearningModule.tsx https://huggingface.co/spaces/Sarvesh213/getcert-backend/resolve/main/src/components/certification-flow/LearningModule.tsx
40.4 kB
| import { useState, useEffect } from 'react'; | |
| import type { CourseModuleV2 } from '../../types/getcertV2'; | |
| import { NapkinSpinner } from '../NapkinSpinner'; | |
| import { | |
| Clock, | |
| CheckCircle2, | |
| ExternalLink, | |
| FileText, | |
| BookOpen, | |
| Paperclip, | |
| ListChecks, | |
| Target, | |
| FlaskConical, | |
| Lock, | |
| ArrowRight, | |
| ArrowLeft, | |
| Tv, | |
| GraduationCap, | |
| Layers, | |
| Link2 | |
| } from 'lucide-react'; | |
| import { resolveCanonicalCourseTitle } from '../../utils/courseTitleResolver'; | |
| interface LearningModuleProps { | |
| courseName: string; | |
| courseSummary: string; | |
| modules: CourseModuleV2[]; | |
| onTakeAssessment: () => void; | |
| onBackToDashboard: () => void; | |
| generatingAssessment?: boolean; | |
| completedModuleIds?: string[] | null; | |
| onCompletedModulesChange?: (ids: string[]) => void; | |
| } | |
| export function formatDurationSeconds(totalSeconds: number): string { | |
| if (!totalSeconds || isNaN(totalSeconds) || totalSeconds <= 0) return ''; | |
| const hours = Math.floor(totalSeconds / 3600); | |
| const minutes = Math.floor((totalSeconds % 3600) / 60); | |
| const seconds = totalSeconds % 60; | |
| if (hours > 0) { | |
| if (minutes > 0 && seconds > 0) return `${hours}h ${minutes}m ${seconds}s`; | |
| if (minutes > 0) return `${hours}h ${minutes}m`; | |
| if (seconds > 0) return `${hours}h ${seconds}s`; | |
| return `${hours}h`; | |
| } | |
| if (minutes > 0) { | |
| return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; | |
| } | |
| return `${seconds}s`; | |
| } | |
| function extractYouTubeVideoId(url: string): string | null { | |
| const value = url.trim(); | |
| const patterns = [ | |
| /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/, | |
| /youtu\.be\/([a-zA-Z0-9_-]{11})/, | |
| /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/, | |
| /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/, | |
| ]; | |
| for (const pattern of patterns) { | |
| const match = value.match(pattern); | |
| if (match?.[1]) { | |
| return match[1]; | |
| } | |
| } | |
| return null; | |
| } | |
| export function LearningModule({ | |
| courseName, | |
| courseSummary, | |
| modules, | |
| onTakeAssessment, | |
| onBackToDashboard, | |
| generatingAssessment = false, | |
| completedModuleIds = null, | |
| onCompletedModulesChange, | |
| }: LearningModuleProps) { | |
| const [localCompleted, setLocalCompleted] = useState<Set<string>>(new Set()); | |
| const completedModules = new Set<string>( | |
| completedModuleIds !== null && completedModuleIds !== undefined ? completedModuleIds : [...localCompleted], | |
| ); | |
| // Active module currently being watched/studied | |
| const [activeModuleId, setActiveModuleId] = useState<string>(() => { | |
| if (!modules || modules.length === 0) return ''; | |
| const firstUncompleted = modules.find((m) => !completedModules.has(m.id)); | |
| return firstUncompleted ? firstUncompleted.id : modules[0].id; | |
| }); | |
| // Active tab in the lesson briefing section | |
| const [activeTab, setActiveTab] = useState<'notes' | 'takeaways' | 'resources' | 'syllabus'>('notes'); | |
| const activeModule = modules.find((m) => m.id === activeModuleId) || modules[0] || null; | |
| const activeModuleIndex = modules.findIndex((m) => m.id === (activeModule?.id || '')); | |
| const markModuleComplete = (moduleId: string) => { | |
| if (completedModules.has(moduleId)) return; | |
| const next = new Set([...completedModules, moduleId]); | |
| if (onCompletedModulesChange) { | |
| onCompletedModulesChange(modules.filter((m) => next.has(m.id)).map((m) => m.id)); | |
| } else { | |
| setLocalCompleted(next); | |
| } | |
| }; | |
| const allModulesCompleted = modules.length > 0 && completedModules.size === modules.length; | |
| const isActiveCompleted = activeModule ? completedModules.has(activeModule.id) : false; | |
| // Extract video and other resources for active module | |
| const activeResources = activeModule?.resources || []; | |
| const parsedVideoResources = activeResources.map((resource) => ({ | |
| resource, | |
| videoId: resource.type === 'youtube' ? extractYouTubeVideoId(resource.url) : null, | |
| })); | |
| const primaryVideo = parsedVideoResources.find((item) => !!item.videoId) || null; | |
| const secondaryVideos = parsedVideoResources.filter((item) => !!item.videoId && item !== primaryVideo); | |
| const linkedResources = parsedVideoResources.filter((item) => !item.videoId); | |
| const completionPercent = modules.length === 0 ? 0 : Math.round((completedModules.size / modules.length) * 100); | |
| // Map of videoId -> formatted duration string (e.g. '26m 52s', '20h 47m') | |
| const [videoDurations, setVideoDurations] = useState<Record<string, string>>({}); | |
| // 1. Initial resolution from pre-cached or resource duration metadata | |
| useEffect(() => { | |
| const initialMap: Record<string, string> = {}; | |
| modules.forEach((mod) => { | |
| mod.resources?.forEach((res) => { | |
| if (res.type === 'youtube') { | |
| const id = extractYouTubeVideoId(res.url); | |
| if ( | |
| id && | |
| res.duration && | |
| !res.duration.includes('>20m') && | |
| !res.duration.includes('Full Course') | |
| ) { | |
| initialMap[id] = res.duration; | |
| } | |
| } | |
| }); | |
| }); | |
| if (Object.keys(initialMap).length > 0) { | |
| setVideoDurations((prev) => ({ ...initialMap, ...prev })); | |
| } | |
| }, [modules]); | |
| // 2. Query /api/v2/video-info for exact server-verified duration | |
| useEffect(() => { | |
| const videoIdsToFetch = new Set<string>(); | |
| modules.forEach((mod) => { | |
| mod.resources?.forEach((res) => { | |
| if (res.type === 'youtube') { | |
| const id = extractYouTubeVideoId(res.url); | |
| const current = videoDurations[id] || (res as any)?.duration || ''; | |
| if ( | |
| id && | |
| (!current || | |
| current.includes('>20m') || | |
| current.includes('Full Course') || | |
| current.includes('Loading')) | |
| ) { | |
| videoIdsToFetch.add(id); | |
| } | |
| } | |
| }); | |
| }); | |
| videoIdsToFetch.forEach((vid) => { | |
| fetch(`/api/v2/video-info?videoId=${encodeURIComponent(vid)}`) | |
| .then((res) => (res.ok ? res.json() : null)) | |
| .then((payload) => { | |
| if (payload?.success && payload?.data?.durationFormatted) { | |
| setVideoDurations((prev) => ({ | |
| ...prev, | |
| [vid]: payload.data.durationFormatted, | |
| })); | |
| } | |
| }) | |
| .catch(() => {}); | |
| }); | |
| }, [modules, videoDurations]); | |
| // 3. Client-side YouTube Iframe postMessage listener (real-time zero latency) | |
| useEffect(() => { | |
| const handleMessage = (event: MessageEvent) => { | |
| try { | |
| if ( | |
| typeof event.origin === 'string' && | |
| !event.origin.includes('youtube.com') && | |
| !event.origin.includes('youtube-nocookie.com') | |
| ) { | |
| return; | |
| } | |
| const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; | |
| const currentVid = primaryVideo?.videoId; | |
| if (data?.event === 'infoDelivery' && data?.info?.duration && currentVid) { | |
| const seconds = Math.round(data.info.duration); | |
| if (seconds > 0) { | |
| const formatted = formatDurationSeconds(seconds); | |
| setVideoDurations((prev) => { | |
| if (prev[currentVid] === formatted) return prev; | |
| return { ...prev, [currentVid]: formatted }; | |
| }); | |
| } | |
| } | |
| } catch {} | |
| }; | |
| window.addEventListener('message', handleMessage); | |
| return () => window.removeEventListener('message', handleMessage); | |
| }, [primaryVideo?.videoId]); | |
| const activeVideoId = primaryVideo?.videoId || null; | |
| const rawActiveDur = activeVideoId | |
| ? (videoDurations[activeVideoId] || (primaryVideo?.resource as any)?.duration || '') | |
| : ''; | |
| const activeDurationFormatted = | |
| rawActiveDur && !rawActiveDur.includes('>20m') && !rawActiveDur.includes('Full Course') | |
| ? rawActiveDur | |
| : ''; | |
| const displayCourseName = resolveCanonicalCourseTitle(courseName); | |
| return ( | |
| <div className="bg-slate-50 min-h-screen text-slate-900 font-sans pb-28 lg:pb-16"> | |
| {/* 1. Institutional Header Strip */} | |
| <header className="border-b border-slate-200 bg-white/95 backdrop-blur-md sticky top-0 z-40 px-3 sm:px-6 py-2.5 sm:py-3 shadow-xs"> | |
| <div className="max-w-7xl mx-auto flex items-center justify-between gap-2"> | |
| {/* Back button + Course Title */} | |
| <div className="flex items-center gap-2.5 sm:gap-3 min-w-0"> | |
| <button | |
| onClick={onBackToDashboard} | |
| className="inline-flex items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-slate-900 px-2.5 sm:px-3.5 py-1.5 rounded-lg bg-slate-50 hover:bg-slate-100 border border-slate-200 transition-colors shrink-0" | |
| title="Return to Dashboard" | |
| > | |
| <ArrowLeft className="w-3.5 h-3.5" /> | |
| <span className="hidden sm:inline">Back to Dashboard</span> | |
| <span className="sm:hidden">Exit</span> | |
| </button> | |
| <div className="h-4 w-px bg-slate-200 shrink-0"></div> | |
| <div className="min-w-0 flex-1"> | |
| <div className="hidden sm:flex items-center gap-1.5"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-blue-600" /> | |
| <span className="text-[10px] sm:text-[11px] font-semibold uppercase tracking-wider text-slate-500 truncate"> | |
| ACTIVE ACCREDITATION TRACK | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-1.5 min-w-0"> | |
| <h2 className="text-sm sm:text-base md:text-lg font-bold text-slate-900 leading-tight truncate tracking-tight" title={displayCourseName}> | |
| {displayCourseName} | |
| </h2> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Progress Indicator */} | |
| <div className="flex items-center gap-2.5 shrink-0"> | |
| <div className="flex items-center gap-1.5 bg-blue-50 px-2.5 py-1 rounded-full border border-blue-200/80 text-xs"> | |
| <span className="text-[11px] sm:text-xs text-[#132F50] font-semibold"> | |
| {completionPercent}% Ready | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| {/* 2. Main Studio Workspace */} | |
| <main className="max-w-7xl mx-auto px-0 sm:px-4 lg:px-6 pt-0 sm:pt-6"> | |
| <div className="grid grid-cols-1 lg:grid-cols-12 gap-0 lg:gap-6 items-start"> | |
| {/* LEFT: Cinema Video Stage + Interactive Lesson Workbook (12 cols mobile, 8 cols desktop) */} | |
| <div className="lg:col-span-8 flex flex-col"> | |
| {/* VIDEO PLAYER CHASSIS */} | |
| <div className="relative"> | |
| <div className="overflow-hidden bg-slate-950 sm:p-2 sm:rounded-2xl border border-slate-800 shadow-sm relative"> | |
| {/* 16:9 Video Canvas */} | |
| <div className="relative w-full aspect-video bg-black overflow-hidden sm:rounded-xl"> | |
| {primaryVideo?.videoId ? ( | |
| <iframe | |
| id="course-video-iframe" | |
| className="absolute inset-0 w-full h-full" | |
| src={`https://www.youtube-nocookie.com/embed/${primaryVideo.videoId}?rel=0&modestbranding=1&enablejsapi=1`} | |
| title={primaryVideo.resource.title} | |
| loading="eager" | |
| allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" | |
| referrerPolicy="strict-origin-when-cross-origin" | |
| allowFullScreen | |
| /> | |
| ) : ( | |
| <div className="absolute inset-0 flex flex-col items-center justify-center p-6 text-center text-slate-300"> | |
| <div className="w-12 h-12 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-xl mb-3 text-slate-400"> | |
| <Tv className="w-6 h-6" /> | |
| </div> | |
| <h4 className="font-bold text-sm text-white mb-1"> | |
| Lecture Reference Available | |
| </h4> | |
| <p className="text-xs text-slate-400 max-w-sm mb-4"> | |
| {activeModule?.title || 'This module contains direct interactive reading references.'} | |
| </p> | |
| {activeResources[0]?.url && ( | |
| <a | |
| href={activeResources[0].url} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="bg-[#132F50] hover:bg-[#0e233d] text-white text-xs font-semibold px-4 py-2 rounded-lg flex items-center gap-1.5 transition-colors" | |
| > | |
| <span>Open Resource</span> | |
| <ExternalLink className="w-3.5 h-3.5" /> | |
| </a> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Video Sub-Bar */} | |
| <div className="bg-slate-950 text-white px-3 sm:px-4 py-2 sm:py-2.5 flex items-center justify-between text-xs border-t sm:border-t-0 sm:mt-1 sm:rounded-lg border-slate-800"> | |
| <div className="flex items-center gap-2 truncate"> | |
| {activeDurationFormatted && ( | |
| <span className="bg-slate-800 text-blue-300 text-[10px] px-2 py-0.5 rounded font-mono font-medium shrink-0"> | |
| {activeDurationFormatted} | |
| </span> | |
| )} | |
| <span className="text-xs font-medium text-slate-300 truncate"> | |
| {primaryVideo?.resource.title || activeModule?.title} | |
| </span> | |
| </div> | |
| {primaryVideo?.resource.url && ( | |
| <div className="flex items-center gap-3 shrink-0 ml-2"> | |
| <a | |
| href={primaryVideo.resource.url} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="text-[11px] text-blue-400 hover:text-blue-300 flex items-center gap-1 font-medium transition-colors" | |
| > | |
| <span>YouTube Source</span> | |
| <ExternalLink className="w-3 h-3" /> | |
| </a> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* LESSON DETAILS & ACTION RIBBON */} | |
| <div className="px-4 py-4 sm:px-0"> | |
| <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-200"> | |
| <div> | |
| <div className="flex items-center gap-2 mb-1.5 flex-wrap"> | |
| <span className="bg-blue-50 text-[#132F50] border border-blue-200/80 font-semibold text-[10px] px-2.5 py-0.5 rounded-full uppercase tracking-wider"> | |
| Module {activeModuleIndex + 1} of {modules.length} | |
| </span> | |
| <span className="text-xs text-slate-600 font-medium inline-flex items-center gap-1 bg-slate-100 px-2 py-0.5 rounded-md"> | |
| <Clock className="w-3.5 h-3.5 text-slate-500 shrink-0" /> | |
| {activeDurationFormatted ? ( | |
| <span>{activeDurationFormatted}</span> | |
| ) : ( | |
| <span className="text-slate-400 font-mono text-xs">Runtime indexing...</span> | |
| )} | |
| </span> | |
| {isActiveCompleted && ( | |
| <span className="bg-emerald-50 text-emerald-800 text-[10px] px-2.5 py-0.5 rounded-full border border-emerald-200 font-semibold flex items-center gap-1"> | |
| <CheckCircle2 className="w-3 h-3 text-emerald-600 shrink-0" /> | |
| <span>Completed</span> | |
| </span> | |
| )} | |
| </div> | |
| <h1 className="text-lg sm:text-xl font-extrabold text-slate-900 leading-tight"> | |
| {activeModule?.title} | |
| </h1> | |
| </div> | |
| {/* Desktop Quick Complete Button */} | |
| <div className="hidden sm:flex items-center gap-2 shrink-0"> | |
| {activeModule && ( | |
| isActiveCompleted ? ( | |
| <div className="bg-emerald-50 text-emerald-800 border border-emerald-200 text-xs font-semibold px-3 py-1.5 rounded-lg flex items-center gap-1.5"> | |
| <CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" /> | |
| <span>Module Completed</span> | |
| </div> | |
| ) : ( | |
| <button | |
| onClick={() => markModuleComplete(activeModule.id)} | |
| className="bg-[#132F50] hover:bg-[#0e233d] text-white font-semibold text-xs px-4 py-2 rounded-lg flex items-center gap-1.5 transition-all shadow-xs cursor-pointer active:scale-[0.98]" | |
| > | |
| <CheckCircle2 className="w-3.5 h-3.5" /> | |
| <span>Mark Module Done</span> | |
| </button> | |
| ) | |
| )} | |
| </div> | |
| </div> | |
| {/* SEGMENTED TABS */} | |
| <div className="mt-4 flex items-center gap-2 overflow-x-auto pb-1 text-xs font-semibold scrollbar-none"> | |
| <button | |
| onClick={() => setActiveTab('notes')} | |
| className={`px-3.5 py-2 rounded-lg shrink-0 flex items-center gap-1.5 transition-all cursor-pointer ${ | |
| activeTab === 'notes' | |
| ? 'bg-[#132F50] text-white shadow-2xs' | |
| : 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200' | |
| }`} | |
| > | |
| <BookOpen className="w-3.5 h-3.5" /> | |
| <span>Lesson Notes</span> | |
| </button> | |
| <button | |
| onClick={() => setActiveTab('takeaways')} | |
| className={`px-3.5 py-2 rounded-lg shrink-0 flex items-center gap-1.5 transition-all cursor-pointer ${ | |
| activeTab === 'takeaways' | |
| ? 'bg-[#132F50] text-white shadow-2xs' | |
| : 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200' | |
| }`} | |
| > | |
| <Layers className="w-3.5 h-3.5" /> | |
| <span>Key Principles</span> | |
| </button> | |
| <button | |
| onClick={() => setActiveTab('resources')} | |
| className={`px-3.5 py-2 rounded-lg shrink-0 flex items-center gap-1.5 transition-all cursor-pointer ${ | |
| activeTab === 'resources' | |
| ? 'bg-[#132F50] text-white shadow-2xs' | |
| : 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200' | |
| }`} | |
| > | |
| <Paperclip className="w-3.5 h-3.5" /> | |
| <span>Documentation & References ({linkedResources.length})</span> | |
| </button> | |
| {/* Full Syllabus Tab for mobile/tablets */} | |
| <button | |
| onClick={() => setActiveTab('syllabus')} | |
| className={`lg:hidden px-3.5 py-2 rounded-lg shrink-0 flex items-center gap-1.5 transition-all cursor-pointer ${ | |
| activeTab === 'syllabus' | |
| ? 'bg-blue-600 text-white shadow-2xs' | |
| : 'bg-blue-50 text-blue-900 border border-blue-200' | |
| }`} | |
| > | |
| <ListChecks className="w-3.5 h-3.5" /> | |
| <span>Syllabus Playlist ({modules.length})</span> | |
| </button> | |
| </div> | |
| {/* TAB CONTENT PANELS */} | |
| <div className="mt-4 space-y-4"> | |
| {/* 1. LESSON NOTES TAB */} | |
| {activeTab === 'notes' && ( | |
| <div className="bg-white rounded-2xl border border-slate-200/90 p-5 sm:p-6 shadow-xs space-y-4"> | |
| <div> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <span className="text-sm font-bold text-slate-900 uppercase tracking-wider"> | |
| Session Learning Objectives: | |
| </span> | |
| </div> | |
| <p className="text-xs sm:text-sm text-slate-600 leading-relaxed font-normal"> | |
| {activeModule?.description || courseSummary} | |
| </p> | |
| </div> | |
| {/* Certification Exam Intel Callout */} | |
| <div className="p-4 bg-blue-50/70 border border-blue-200/80 rounded-xl"> | |
| <div className="flex items-start gap-3"> | |
| <Target className="w-4 h-4 text-blue-700 shrink-0 mt-0.5" /> | |
| <div> | |
| <span className="text-xs font-bold text-blue-950 uppercase tracking-wider block"> | |
| Certification Exam Telemetry: | |
| </span> | |
| <p className="text-xs text-blue-900/90 mt-1 leading-relaxed font-normal"> | |
| Pay close attention to practical syntax and underlying logic in this video. The final proctored evaluation creates dynamic scenario questions derived directly from this module's technical audio transcripts. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Secondary video embeds if module has more than one */} | |
| {secondaryVideos.length > 0 && ( | |
| <div className="mt-4 pt-4 border-t border-slate-100 space-y-3"> | |
| <span className="text-xs font-semibold uppercase text-slate-500 tracking-wider"> | |
| Supplemental Video Exercises: | |
| </span> | |
| {secondaryVideos.map(({ resource, videoId }, idx) => ( | |
| <div key={idx} className="rounded-xl border border-slate-200 p-3 bg-slate-50"> | |
| <div className="aspect-video rounded-lg overflow-hidden bg-black mb-2"> | |
| <iframe | |
| src={`https://www.youtube-nocookie.com/embed/${videoId}?rel=0`} | |
| title={resource.title} | |
| className="w-full h-full" | |
| allowFullScreen | |
| /> | |
| </div> | |
| <div className="text-xs font-semibold text-slate-800">{resource.title}</div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* 2. ARCHITECTURE TAKEAWAYS TAB */} | |
| {activeTab === 'takeaways' && ( | |
| <div className="bg-white rounded-2xl border border-slate-200/90 p-5 sm:p-6 shadow-xs space-y-4"> | |
| <h3 className="text-sm font-bold text-slate-900 uppercase tracking-wider mb-2"> | |
| Core Engineering Principles & Best Practices: | |
| </h3> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs"> | |
| <div className="bg-slate-50 border border-slate-200 rounded-xl p-3.5"> | |
| <span className="font-semibold text-slate-900 mb-1 flex items-center gap-1.5"> | |
| <Target className="w-3.5 h-3.5 text-blue-600" /> Core Objective | |
| </span> | |
| <p className="text-slate-600 leading-relaxed font-normal mt-1"> | |
| Build deep mental models rather than memorizing syntax. Understand failure modes and debugging strategies. | |
| </p> | |
| </div> | |
| <div className="bg-slate-50 border border-slate-200 rounded-xl p-3.5"> | |
| <span className="font-semibold text-slate-900 mb-1 flex items-center gap-1.5"> | |
| <FlaskConical className="w-3.5 h-3.5 text-blue-600" /> Verification Focus | |
| </span> | |
| <p className="text-slate-600 leading-relaxed font-normal mt-1"> | |
| Recruiter-verifiable diplomas grade candidates on situational problem solving, architecture design, and code safety. | |
| </p> | |
| </div> | |
| </div> | |
| <div className="pt-3 border-t border-slate-100"> | |
| <h4 className="text-xs font-semibold uppercase text-slate-500 mb-1.5 tracking-wider">Course Synopsis:</h4> | |
| <p className="text-xs text-slate-600 leading-relaxed font-normal">{courseSummary}</p> | |
| </div> | |
| </div> | |
| )} | |
| {/* 3. CHEATSHEETS & ATTACHMENTS TAB */} | |
| {activeTab === 'resources' && ( | |
| <div className="bg-white rounded-2xl border border-slate-200/90 p-5 sm:p-6 shadow-xs space-y-3"> | |
| <h3 className="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-3 flex items-center gap-1.5"> | |
| <Paperclip className="w-3.5 h-3.5" /> | |
| <span>Documentation, Code & Official References</span> | |
| </h3> | |
| {linkedResources.length === 0 ? ( | |
| <p className="text-xs text-slate-500 italic py-2 font-normal"> | |
| All study materials for this module are incorporated directly in the primary video lecture above. | |
| </p> | |
| ) : ( | |
| <div className="space-y-2.5"> | |
| {linkedResources.map(({ resource }, idx) => ( | |
| <a | |
| key={idx} | |
| href={resource.url} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200 hover:border-[#132F50] bg-slate-50/50 hover:bg-blue-50/30 transition-all text-xs group" | |
| > | |
| <div className="flex items-center gap-2.5"> | |
| <span className="text-[#132F50] text-sm"> | |
| {resource.type === 'documentation' ? ( | |
| <FileText className="w-4 h-4" /> | |
| ) : ( | |
| <Link2 className="w-4 h-4" /> | |
| )} | |
| </span> | |
| <div> | |
| <div className="font-semibold text-slate-800 group-hover:text-blue-700 transition-colors"> | |
| {resource.title} | |
| </div> | |
| <div className="text-[10px] text-slate-400 font-mono"> | |
| Type: {resource.type} | |
| </div> | |
| </div> | |
| </div> | |
| <span className="text-xs font-semibold text-[#132F50] group-hover:text-blue-700 flex items-center gap-1"> | |
| <span>Open</span> | |
| <ExternalLink className="w-3 h-3" /> | |
| </span> | |
| </a> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* 4. FULL SYLLABUS TAB (MOBILE / TABLET) */} | |
| {activeTab === 'syllabus' && ( | |
| <div className="bg-white rounded-2xl border border-slate-200/90 p-5 sm:p-6 shadow-xs space-y-3 lg:hidden"> | |
| <h3 className="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-2"> | |
| Tap any module to switch video: | |
| </h3> | |
| <div className="space-y-2.5"> | |
| {modules.map((module, idx) => { | |
| const isDone = completedModules.has(module.id); | |
| const isCurrent = module.id === activeModuleId; | |
| const modYt = module.resources?.find((r) => r.type === 'youtube'); | |
| const modVid = modYt ? extractYouTubeVideoId(modYt.url) : null; | |
| const modDur = modVid ? (videoDurations[modVid] || (modYt as any)?.duration || '') : ''; | |
| return ( | |
| <div | |
| key={module.id} | |
| onClick={() => { | |
| setActiveModuleId(module.id); | |
| setActiveTab('notes'); | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| }} | |
| className={`p-3.5 rounded-xl border transition-all cursor-pointer ${ | |
| isCurrent | |
| ? 'border-[#132F50] bg-blue-50/70 shadow-xs' | |
| : 'border-slate-200 hover:border-slate-300 bg-white' | |
| }`} | |
| > | |
| <div className="flex items-center justify-between text-[10px] font-mono mb-1"> | |
| <span className={isCurrent ? 'text-blue-700 font-bold' : 'text-slate-400'}> | |
| {isCurrent ? 'NOW PLAYING' : `MODULE ${idx + 1}`} | |
| </span> | |
| <div className="flex items-center gap-1.5"> | |
| <span className="text-slate-500 font-mono text-[10px] font-medium flex items-center gap-1"> | |
| <Clock className="w-2.5 h-2.5 text-slate-400 shrink-0" /> | |
| <span>{modDur || (isCurrent && activeDurationFormatted) || 'Loading...'}</span> | |
| </span> | |
| {isDone && ( | |
| <span className="text-emerald-700 font-semibold bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200 flex items-center gap-0.5"> | |
| <CheckCircle2 className="w-2.5 h-2.5 text-emerald-600" /> | |
| <span>Done</span> | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| <h4 className="text-xs font-bold text-slate-900">{module.title}</h4> | |
| <p className="text-[11px] text-slate-500 mt-1 line-clamp-2">{module.description}</p> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* RIGHT: Syllabus Workbook & Exam Gate (Desktop 4 cols, Sticky) */} | |
| <div className="hidden lg:flex lg:col-span-4 flex-col gap-5 sticky top-20"> | |
| {/* Syllabus Playlist Box */} | |
| <div className="bg-white rounded-2xl border border-slate-200/90 p-5 shadow-xs"> | |
| <div className="flex items-center justify-between pb-3 border-b border-slate-100 mb-3"> | |
| <div> | |
| <span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block"> | |
| Curriculum Playlist | |
| </span> | |
| <h3 className="text-sm font-bold text-slate-900">Module Lessons</h3> | |
| </div> | |
| <span className="bg-blue-50 text-[#132F50] border border-blue-200/80 text-[10px] px-2.5 py-0.5 rounded-full font-semibold"> | |
| {completedModules.size} / {modules.length} Completed | |
| </span> | |
| </div> | |
| {/* Modules List */} | |
| <div className="space-y-2.5 max-h-[420px] overflow-y-auto pr-1"> | |
| {modules.map((module, idx) => { | |
| const isDone = completedModules.has(module.id); | |
| const isCurrent = module.id === activeModuleId; | |
| const modYt = module.resources?.find((r) => r.type === 'youtube'); | |
| const modVid = modYt ? extractYouTubeVideoId(modYt.url) : null; | |
| const modDur = modVid ? (videoDurations[modVid] || (modYt as any)?.duration || '') : ''; | |
| return ( | |
| <div | |
| key={module.id} | |
| onClick={() => setActiveModuleId(module.id)} | |
| className={`p-3 rounded-xl border transition-all cursor-pointer ${ | |
| isCurrent | |
| ? 'border-[#132F50] bg-blue-50/50 shadow-xs ring-1 ring-[#132F50]/20' | |
| : 'border-slate-200/80 hover:border-slate-300 bg-white' | |
| }`} | |
| > | |
| <div className="flex items-center justify-between text-[10px] font-mono mb-1"> | |
| <span className={`flex items-center gap-1.5 ${isCurrent ? 'text-blue-700 font-semibold' : 'text-slate-400'}`}> | |
| {isCurrent ? ( | |
| <> | |
| <span className="w-1.5 h-1.5 rounded-full bg-blue-600 animate-pulse"></span> | |
| <span>NOW PLAYING</span> | |
| </> | |
| ) : ( | |
| <span>MODULE {idx + 1}</span> | |
| )} | |
| </span> | |
| {isDone ? ( | |
| <span className="text-emerald-700 font-semibold bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200 text-[9px] flex items-center gap-0.5"> | |
| <CheckCircle2 className="w-2.5 h-2.5 text-emerald-600" /> | |
| <span>Done</span> | |
| </span> | |
| ) : ( | |
| <span className="text-slate-500 font-mono text-[10px] font-medium flex items-center gap-1"> | |
| <Clock className="w-2.5 h-2.5 text-slate-400 shrink-0" /> | |
| <span>{modDur || (isCurrent && activeDurationFormatted) || 'Loading...'}</span> | |
| </span> | |
| )} | |
| </div> | |
| <h4 className={`text-xs font-bold leading-snug ${isCurrent ? 'text-slate-900' : 'text-slate-700'}`}> | |
| {idx + 1}. {module.title} | |
| </h4> | |
| <p className="text-[11px] text-slate-500 mt-1 leading-tight line-clamp-2"> | |
| {module.description} | |
| </p> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| {/* Certification Exam Gate Card */} | |
| <div id="milestone-gate" className="bg-white rounded-2xl border border-slate-200/90 p-5 shadow-xs"> | |
| <h3 className="text-sm font-bold text-slate-900"> | |
| Certification Exam Gate | |
| </h3> | |
| <p className="text-xs text-slate-500 mt-1 leading-relaxed font-normal"> | |
| Pass mark: 60% or higher. Complete all curriculum modules to unlock your live dynamic proctored assessment. | |
| </p> | |
| <div className="mt-4 pt-3 border-t border-slate-100"> | |
| <div className="flex justify-between text-xs mb-1.5"> | |
| <span className="text-slate-500 font-medium">Readiness Threshold</span> | |
| <span className="text-[#132F50] font-bold">{completionPercent}%</span> | |
| </div> | |
| <div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden"> | |
| <div | |
| className="bg-[#132F50] h-full rounded-full transition-all duration-500" | |
| style={{ width: `${completionPercent}%` }} | |
| /> | |
| </div> | |
| </div> | |
| {allModulesCompleted ? ( | |
| <button | |
| onClick={onTakeAssessment} | |
| disabled={generatingAssessment} | |
| className="mt-4 w-full bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white rounded-xl font-semibold text-xs py-3 transition-all flex items-center justify-center gap-2 shadow-xs cursor-pointer active:scale-[0.98]" | |
| > | |
| {generatingAssessment ? ( | |
| <> | |
| <NapkinSpinner size="sm" /> | |
| <span>Synthesizing examination sheet...</span> | |
| </> | |
| ) : ( | |
| <span className="flex items-center gap-1.5"> | |
| <GraduationCap className="w-4 h-4" /> | |
| <span>Start Certification Exam Now</span> | |
| </span> | |
| )} | |
| </button> | |
| ) : ( | |
| <button | |
| disabled | |
| className="mt-4 w-full bg-slate-100 text-slate-400 border border-slate-200 rounded-xl py-2.5 text-xs font-medium cursor-not-allowed flex items-center justify-center gap-1.5" | |
| > | |
| <Lock className="w-3.5 h-3.5" /> | |
| <span>Complete {modules.length - completedModules.size} More {modules.length - completedModules.size === 1 ? 'Module' : 'Modules'} to Unlock</span> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| {/* 3. Mobile Docked Bottom Bar */} | |
| <div className="lg:hidden fixed bottom-0 inset-x-0 bg-white/95 backdrop-blur-md border-t border-slate-200 px-4 py-3 z-50 flex items-center justify-between shadow-lg"> | |
| <div> | |
| <div className="text-[10px] font-semibold text-slate-500 uppercase"> | |
| Module {activeModuleIndex + 1} of {modules.length} | |
| </div> | |
| <div className="text-xs font-bold text-slate-900 truncate max-w-[190px]"> | |
| {activeModule?.title} | |
| </div> | |
| </div> | |
| {allModulesCompleted ? ( | |
| <button | |
| onClick={onTakeAssessment} | |
| disabled={generatingAssessment} | |
| className="bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-4 py-2 rounded-lg flex items-center gap-1.5 shadow-xs cursor-pointer active:scale-[0.98]" | |
| > | |
| {generatingAssessment ? ( | |
| <span>Preparing...</span> | |
| ) : ( | |
| <> | |
| <GraduationCap className="w-3.5 h-3.5" /> | |
| <span>Take Exam</span> | |
| <ArrowRight className="w-3.5 h-3.5" /> | |
| </> | |
| )} | |
| </button> | |
| ) : activeModule && !isActiveCompleted ? ( | |
| <button | |
| onClick={() => markModuleComplete(activeModule.id)} | |
| className="bg-[#132F50] hover:bg-[#0e233d] text-white text-xs font-semibold px-4 py-2 rounded-lg flex items-center gap-1.5 shadow-xs cursor-pointer active:scale-[0.98]" | |
| > | |
| <CheckCircle2 className="w-3.5 h-3.5" /> | |
| <span>Mark Complete</span> | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={() => { | |
| // Jump to next uncompleted module | |
| const nextMod = modules.find((m) => !completedModules.has(m.id)); | |
| if (nextMod) { | |
| setActiveModuleId(nextMod.id); | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } | |
| }} | |
| className="bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold px-4 py-2 rounded-lg flex items-center gap-1.5 shadow-xs cursor-pointer active:scale-[0.98]" | |
| > | |
| <span>Next Module</span> | |
| <ArrowRight className="w-3.5 h-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |