refactor(education): CourseLearningView module with hook, subcomponents, skeleton
- Add course-learning-view/ with useCourseLearningView, Header, Player, Tabs, Sidebar, Skeleton - Layout min-h-layout-main, no arbitrary values - Re-export from CourseLearningView.tsx - Stories: Default, Loading (Skeleton), Empty, Complete Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a88d5a3e7c
commit
bfd95f2ce8
11 changed files with 608 additions and 354 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { CourseLearningView } from './CourseLearningView';
|
||||
import { CourseLearningView, CourseLearningViewSkeleton } from './CourseLearningView';
|
||||
|
||||
const meta: Meta<typeof CourseLearningView> = {
|
||||
title: 'Components/Features/Education/CourseLearningView',
|
||||
|
|
@ -52,6 +52,24 @@ export const Default: Story = {
|
|||
}
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
name: 'Chargement',
|
||||
render: () => <CourseLearningViewSkeleton />,
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
name: 'Vide (sans leçons)',
|
||||
args: {
|
||||
course: {
|
||||
...mockCourse,
|
||||
modules: [
|
||||
{ id: 'm1', title: 'Introduction', lessons: [] }
|
||||
]
|
||||
},
|
||||
onBack: () => console.log('Back clicked')
|
||||
}
|
||||
};
|
||||
|
||||
export const Complete: Story = {
|
||||
name: 'Terminé',
|
||||
args: {
|
||||
|
|
|
|||
|
|
@ -1,353 +1,8 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { ProgressBar } from '../ui/progress';
|
||||
import { Course } from '../../types';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
PlayCircle,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../../components/feedback/ToastProvider';
|
||||
import { QuizModal } from './modals/QuizModal';
|
||||
import { CertificateModal } from './modals/CertificateModal';
|
||||
|
||||
interface CourseLearningViewProps {
|
||||
course: Course;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const CourseLearningView: React.FC<CourseLearningViewProps> = ({
|
||||
course,
|
||||
onBack,
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
const [activeLessonId, setActiveLessonId] = useState<string>(
|
||||
course.modules?.[0]?.lessons[0]?.id || '',
|
||||
);
|
||||
const [completedLessons, setCompletedLessons] = useState<string[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'overview' | 'notes' | 'resources'
|
||||
>('overview');
|
||||
|
||||
// Quiz State
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
const [activeQuiz, setActiveQuiz] = useState<any>(null);
|
||||
|
||||
// Certificate State
|
||||
const [showCertificate, setShowCertificate] = useState(false);
|
||||
|
||||
// Flattened lessons for navigation
|
||||
const allLessons = course.modules?.flatMap((m) => m.lessons) || [];
|
||||
const currentLessonIndex = allLessons.findIndex(
|
||||
(l) => l.id === activeLessonId,
|
||||
);
|
||||
const currentLesson = allLessons[currentLessonIndex];
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentLessonIndex < allLessons.length - 1) {
|
||||
const nextLesson = allLessons[currentLessonIndex + 1];
|
||||
setActiveLessonId(nextLesson.id);
|
||||
markComplete(currentLesson.id);
|
||||
} else {
|
||||
// Course finished
|
||||
markComplete(currentLesson.id);
|
||||
addToast('Course Completed! 🎉', 'success');
|
||||
if (course.certificateAvailable) {
|
||||
setShowCertificate(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentLessonIndex > 0) {
|
||||
setActiveLessonId(allLessons[currentLessonIndex - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const markComplete = (id: string) => {
|
||||
if (!completedLessons.includes(id)) {
|
||||
setCompletedLessons([...completedLessons, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const startQuiz = (quizId: string) => {
|
||||
// Mock Quiz Data
|
||||
setActiveQuiz({
|
||||
id: quizId,
|
||||
title: 'Module Assessment',
|
||||
passingScore: 70,
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'What is the frequency range of a sub-bass?',
|
||||
options: ['20-60Hz', '200-500Hz', '1-2kHz'],
|
||||
correctIndex: 0,
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
question: 'Which plugin is best for sidechaining?',
|
||||
options: ['Reverb', 'Compressor', 'Delay'],
|
||||
correctIndex: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
setShowQuiz(true);
|
||||
};
|
||||
|
||||
const progress = Math.round(
|
||||
(completedLessons.length / allLessons.length) * 100,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-6rem)] -m-6 md:-m-12 bg-kodo-void">
|
||||
{/* Header Bar */}
|
||||
<div className="h-16 border-b border-kodo-steel bg-kodo-ink px-4 flex items-center justify-between shrink-0 z-20">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Back
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-kodo-steel"></div>
|
||||
<h2 className="font-bold text-white text-sm md:text-base truncate max-w-md">
|
||||
{course.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden md:block w-32">
|
||||
<ProgressBar value={progress} color="lime" />
|
||||
</div>
|
||||
<div className="text-xs text-kodo-content-dim font-mono hidden md:block">
|
||||
{progress}% Complete
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<X className="w-5 h-5" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-y-auto custom-scrollbar">
|
||||
{/* Player Stage */}
|
||||
<div className="bg-black aspect-video w-full flex items-center justify-center relative">
|
||||
{currentLesson?.type === 'video' ? (
|
||||
<div className="text-center">
|
||||
<PlayCircle className="w-16 h-16 text-white opacity-50 mx-auto mb-4" />
|
||||
<p className="text-kodo-content-dim">Video Player Placeholder</p>
|
||||
<p className="text-xs text-kodo-content-dim mt-2">
|
||||
{currentLesson.title}
|
||||
</p>
|
||||
</div>
|
||||
) : currentLesson?.type === 'quiz' ? (
|
||||
<div className="text-center">
|
||||
<HelpCircle className="w-16 h-16 text-kodo-gold mx-auto mb-4" />
|
||||
<h3 className="text-white text-xl font-bold mb-4">
|
||||
Quiz: {currentLesson.title}
|
||||
</h3>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
currentLesson.quizId && startQuiz(currentLesson.quizId)
|
||||
}
|
||||
>
|
||||
Start Quiz
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 max-w-2xl mx-auto text-left w-full h-full overflow-y-auto bg-kodo-graphite">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
{currentLesson?.title}
|
||||
</h2>
|
||||
<p className="text-kodo-text-main leading-relaxed">
|
||||
{currentLesson?.content ||
|
||||
'This is a text-based lesson. Content would be rendered here in Markdown.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs & Meta */}
|
||||
<div className="p-6 md:p-8 max-w-5xl mx-auto w-full">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{currentLesson?.title}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePrev}
|
||||
disabled={currentLessonIndex === 0}
|
||||
icon={<ChevronLeft className="w-4 h-4" />}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleNext}>
|
||||
{currentLessonIndex === allLessons.length - 1
|
||||
? 'Finish Course'
|
||||
: 'Next'}{' '}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-kodo-steel flex gap-6 mb-6">
|
||||
{['overview', 'notes', 'resources'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab as any)}
|
||||
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === tab ? 'border-kodo-cyan text-white' : 'border-transparent text-kodo-content-dim hover:text-kodo-text-main'}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="text-kodo-text-main space-y-4">
|
||||
<p>
|
||||
In this lesson, we cover the fundamentals of the topic. Make
|
||||
sure to take notes.
|
||||
</p>
|
||||
<div className="p-4 bg-kodo-ink rounded border border-kodo-steel/50">
|
||||
<h4 className="font-bold text-white mb-2">Key Takeaways</h4>
|
||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||
<li>Understanding the core concept</li>
|
||||
<li>Applying technique A to situation B</li>
|
||||
<li>Common pitfalls to avoid</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'notes' && (
|
||||
<div>
|
||||
<textarea
|
||||
className="w-full h-40 bg-kodo-ink border border-kodo-steel rounded p-4 text-white resize-none focus:border-kodo-steel outline-none"
|
||||
placeholder="Type your personal notes here..."
|
||||
/>
|
||||
<Button variant="secondary" size="sm" className="mt-2">
|
||||
Save Note
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'resources' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-4 bg-kodo-ink rounded border border-kodo-steel">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileText className="w-5 h-5 text-kodo-steel" />
|
||||
<span className="text-sm text-white">
|
||||
Lesson Slides.pdf
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-kodo-ink rounded border border-kodo-steel">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileText className="w-5 h-5 text-kodo-magenta" />
|
||||
<span className="text-sm text-white">
|
||||
Project Files.zip
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar (Curriculum) */}
|
||||
{sidebarOpen && (
|
||||
<div className="w-80 bg-kodo-graphite border-l border-kodo-steel flex flex-col flex-shrink-0 animate-slideInRight">
|
||||
<div className="p-4 border-b border-kodo-steel font-bold text-white text-sm bg-kodo-ink">
|
||||
Course Content
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{course.modules?.map((module, i) => (
|
||||
<div key={module.id} className="border-b border-kodo-steel/30">
|
||||
<div className="p-4 bg-kodo-ink/50 text-xs font-bold text-kodo-content-dim uppercase tracking-wider sticky top-0 backdrop-blur-sm z-10">
|
||||
Section {i + 1}: {module.title}
|
||||
</div>
|
||||
<div>
|
||||
{module.lessons.map((lesson) => {
|
||||
const isActive = lesson.id === activeLessonId;
|
||||
const isCompleted = completedLessons.includes(lesson.id);
|
||||
return (
|
||||
<div
|
||||
key={lesson.id}
|
||||
onClick={() => setActiveLessonId(lesson.id)}
|
||||
className={`flex items-start gap-4 p-4 cursor-pointer border-l-2 transition-all hover:bg-white/5 ${isActive ? 'bg-kodo-cyan/10 border-kodo-cyan' : 'border-transparent'}`}
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="w-4 h-4 text-kodo-lime" />
|
||||
) : lesson.type === 'video' ? (
|
||||
<PlayCircle
|
||||
className={`w-4 h-4 ${isActive ? 'text-kodo-cyan' : 'text-kodo-content-dim'}`}
|
||||
/>
|
||||
) : (
|
||||
<HelpCircle className="w-4 h-4 text-kodo-content-dim" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium leading-snug ${isActive ? 'text-white' : 'text-kodo-text-main'}`}
|
||||
>
|
||||
{lesson.title}
|
||||
</div>
|
||||
<div className="text-[10px] text-kodo-content-dim mt-1 flex items-center gap-2">
|
||||
<span>{lesson.duration}</span>
|
||||
{lesson.type === 'quiz' && (
|
||||
<span className="text-kodo-gold">Quiz</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showQuiz && activeQuiz && (
|
||||
<QuizModal
|
||||
quiz={activeQuiz}
|
||||
onClose={() => setShowQuiz(false)}
|
||||
onComplete={(score) => {
|
||||
addToast(`Quiz Completed. Score: ${score}%`, 'info');
|
||||
markComplete(currentLesson.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCertificate && (
|
||||
<CertificateModal
|
||||
studentName="Cyber Producer"
|
||||
courseName={course.title}
|
||||
completionDate={new Date().toLocaleDateString()}
|
||||
onClose={() => setShowCertificate(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* CourseLearningView — re-export from feature module.
|
||||
*/
|
||||
export {
|
||||
CourseLearningView,
|
||||
CourseLearningViewSkeleton,
|
||||
} from './course-learning-view';
|
||||
export type { CourseLearningViewProps } from './course-learning-view';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* CourseLearningView — orchestration: hook + Header, Player, Tabs, Sidebar, modals.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useToast } from '@/components/feedback/ToastProvider';
|
||||
import { QuizModal } from '../modals/QuizModal';
|
||||
import { CertificateModal } from '../modals/CertificateModal';
|
||||
import { useCourseLearningView } from './useCourseLearningView';
|
||||
import { CourseLearningViewHeader } from './CourseLearningViewHeader';
|
||||
import { CourseLearningViewPlayer } from './CourseLearningViewPlayer';
|
||||
import { CourseLearningViewTabs } from './CourseLearningViewTabs';
|
||||
import { CourseLearningViewSidebar } from './CourseLearningViewSidebar';
|
||||
import { CourseLearningViewSkeleton } from './CourseLearningViewSkeleton';
|
||||
import type { CourseLearningViewProps } from './types';
|
||||
|
||||
export const CourseLearningView: React.FC<CourseLearningViewProps> = ({
|
||||
course,
|
||||
onBack,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
const {
|
||||
allLessons,
|
||||
currentLesson,
|
||||
currentLessonIndex,
|
||||
activeLessonId,
|
||||
setActiveLessonId,
|
||||
completedLessons,
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
progress,
|
||||
showQuiz,
|
||||
setShowQuiz,
|
||||
activeQuiz,
|
||||
showCertificate,
|
||||
setShowCertificate,
|
||||
markComplete,
|
||||
handleNext,
|
||||
handlePrev,
|
||||
startQuiz,
|
||||
} = useCourseLearningView(course);
|
||||
|
||||
if (isLoading) {
|
||||
return <CourseLearningViewSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-layout-main -m-6 md:-m-12 bg-kodo-void">
|
||||
<CourseLearningViewHeader
|
||||
course={course}
|
||||
progress={progress}
|
||||
sidebarOpen={sidebarOpen}
|
||||
onBack={onBack}
|
||||
onToggleSidebar={() => setSidebarOpen((o) => !o)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-y-auto custom-scrollbar">
|
||||
<CourseLearningViewPlayer
|
||||
lesson={currentLesson}
|
||||
onStartQuiz={startQuiz}
|
||||
/>
|
||||
<CourseLearningViewTabs
|
||||
lesson={currentLesson}
|
||||
currentIndex={currentLessonIndex}
|
||||
totalLessons={allLessons.length}
|
||||
onPrev={handlePrev}
|
||||
onNext={handleNext}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<CourseLearningViewSidebar
|
||||
course={course}
|
||||
activeLessonId={activeLessonId}
|
||||
completedLessons={completedLessons}
|
||||
onSelectLesson={setActiveLessonId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showQuiz && activeQuiz && (
|
||||
<QuizModal
|
||||
quiz={activeQuiz}
|
||||
onClose={() => setShowQuiz(false)}
|
||||
onComplete={(score) => {
|
||||
addToast(`Quiz Completed. Score: ${score}%`, 'info');
|
||||
if (currentLesson) markComplete(currentLesson.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCertificate && (
|
||||
<CertificateModal
|
||||
studentName="Cyber Producer"
|
||||
courseName={course.title}
|
||||
completionDate={new Date().toLocaleDateString()}
|
||||
onClose={() => setShowCertificate(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* CourseLearningView — top bar (back, title, progress, sidebar toggle).
|
||||
*/
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProgressBar } from '@/components/ui/progress';
|
||||
import { ChevronLeft, Menu, X } from 'lucide-react';
|
||||
import type { Course } from '@/types';
|
||||
|
||||
interface CourseLearningViewHeaderProps {
|
||||
course: Course;
|
||||
progress: number;
|
||||
sidebarOpen: boolean;
|
||||
onBack: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export function CourseLearningViewHeader({
|
||||
course,
|
||||
progress,
|
||||
sidebarOpen,
|
||||
onBack,
|
||||
onToggleSidebar,
|
||||
}: CourseLearningViewHeaderProps) {
|
||||
return (
|
||||
<div className="h-16 border-b border-kodo-steel bg-kodo-ink px-4 flex items-center justify-between shrink-0 z-20">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Back
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-kodo-steel" />
|
||||
<h2 className="font-bold text-white text-sm md:text-base truncate max-w-md">
|
||||
{course.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden md:block w-32">
|
||||
<ProgressBar value={progress} color="lime" />
|
||||
</div>
|
||||
<div className="text-xs text-kodo-content-dim font-mono hidden md:block">
|
||||
{progress}% Complete
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onToggleSidebar}>
|
||||
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* CourseLearningView — player stage (video / quiz / article).
|
||||
*/
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlayCircle, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: string;
|
||||
type: 'video' | 'article' | 'quiz';
|
||||
content?: string;
|
||||
quizId?: string;
|
||||
}
|
||||
|
||||
interface CourseLearningViewPlayerProps {
|
||||
lesson: Lesson | null;
|
||||
onStartQuiz: (quizId: string) => void;
|
||||
}
|
||||
|
||||
export function CourseLearningViewPlayer({
|
||||
lesson,
|
||||
onStartQuiz,
|
||||
}: CourseLearningViewPlayerProps) {
|
||||
if (!lesson) {
|
||||
return (
|
||||
<div className="bg-black aspect-video w-full flex items-center justify-center">
|
||||
<p className="text-kodo-content-dim">Select a lesson</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (lesson.type === 'video') {
|
||||
return (
|
||||
<div className="bg-black aspect-video w-full flex items-center justify-center relative">
|
||||
<div className="text-center">
|
||||
<PlayCircle className="w-16 h-16 text-white opacity-50 mx-auto mb-4" />
|
||||
<p className="text-kodo-content-dim">Video Player Placeholder</p>
|
||||
<p className="text-xs text-kodo-content-dim mt-2">{lesson.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (lesson.type === 'quiz') {
|
||||
return (
|
||||
<div className="bg-black aspect-video w-full flex items-center justify-center relative">
|
||||
<div className="text-center">
|
||||
<HelpCircle className="w-16 h-16 text-kodo-gold mx-auto mb-4" />
|
||||
<h3 className="text-white text-xl font-bold mb-4">Quiz: {lesson.title}</h3>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => lesson.quizId && onStartQuiz(lesson.quizId)}
|
||||
>
|
||||
Start Quiz
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl mx-auto text-left w-full h-full overflow-y-auto bg-kodo-graphite">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">{lesson.title}</h2>
|
||||
<p className="text-kodo-text-main leading-relaxed">
|
||||
{lesson.content ??
|
||||
'This is a text-based lesson. Content would be rendered here in Markdown.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* CourseLearningView — curriculum sidebar (modules + lessons).
|
||||
*/
|
||||
import { CheckCircle, PlayCircle, HelpCircle } from 'lucide-react';
|
||||
import type { Course } from '@/types';
|
||||
|
||||
interface CourseLearningViewSidebarProps {
|
||||
course: Course;
|
||||
activeLessonId: string;
|
||||
completedLessons: string[];
|
||||
onSelectLesson: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CourseLearningViewSidebar({
|
||||
course,
|
||||
activeLessonId,
|
||||
completedLessons,
|
||||
onSelectLesson,
|
||||
}: CourseLearningViewSidebarProps) {
|
||||
return (
|
||||
<div className="w-80 bg-kodo-graphite border-l border-kodo-steel flex flex-col flex-shrink-0 animate-slideInRight">
|
||||
<div className="p-4 border-b border-kodo-steel font-bold text-white text-sm bg-kodo-ink">
|
||||
Course Content
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{course.modules?.map((module, i) => (
|
||||
<div key={module.id} className="border-b border-kodo-steel/30">
|
||||
<div className="p-4 bg-kodo-ink/50 text-xs font-bold text-kodo-content-dim uppercase tracking-wider sticky top-0 backdrop-blur-sm z-10">
|
||||
Section {i + 1}: {module.title}
|
||||
</div>
|
||||
<div>
|
||||
{module.lessons.map((lesson) => {
|
||||
const isActive = lesson.id === activeLessonId;
|
||||
const isCompleted = completedLessons.includes(lesson.id);
|
||||
return (
|
||||
<button
|
||||
key={lesson.id}
|
||||
type="button"
|
||||
onClick={() => onSelectLesson(lesson.id)}
|
||||
className={`flex items-start gap-4 p-4 cursor-pointer border-l-2 transition-all hover:bg-white/5 w-full text-left ${isActive ? 'bg-kodo-cyan/10 border-kodo-cyan' : 'border-transparent'}`}
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="w-4 h-4 text-kodo-lime" />
|
||||
) : lesson.type === 'video' ? (
|
||||
<PlayCircle
|
||||
className={`w-4 h-4 ${isActive ? 'text-kodo-cyan' : 'text-kodo-content-dim'}`}
|
||||
/>
|
||||
) : (
|
||||
<HelpCircle className="w-4 h-4 text-kodo-content-dim" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium leading-snug ${isActive ? 'text-white' : 'text-kodo-text-main'}`}
|
||||
>
|
||||
{lesson.title}
|
||||
</div>
|
||||
<div className="text-[10px] text-kodo-content-dim mt-1 flex items-center gap-2">
|
||||
<span>{lesson.duration}</span>
|
||||
{lesson.type === 'quiz' && (
|
||||
<span className="text-kodo-gold">Quiz</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* CourseLearningView — loading skeleton (layout primitive).
|
||||
*/
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CourseLearningViewSkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CourseLearningViewSkeleton({ className }: CourseLearningViewSkeletonProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col min-h-layout-main bg-kodo-void', className)}>
|
||||
<div className="h-16 border-b border-kodo-steel bg-kodo-ink px-4 flex items-center justify-between shrink-0">
|
||||
<div className="flex gap-4">
|
||||
<div className="h-9 w-24 rounded-lg bg-muted animate-pulse" />
|
||||
<div className="h-4 w-48 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="h-4 w-20 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="aspect-video w-full bg-muted animate-pulse" />
|
||||
<div className="p-6 max-w-5xl mx-auto w-full space-y-4">
|
||||
<div className="h-8 w-64 rounded bg-muted animate-pulse" />
|
||||
<div className="flex gap-6 border-b border-border pb-3">
|
||||
<div className="h-4 w-20 rounded bg-muted animate-pulse" />
|
||||
<div className="h-4 w-16 rounded bg-muted animate-pulse" />
|
||||
<div className="h-4 w-24 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="h-24 w-full rounded bg-muted/50 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-80 border-l border-border flex flex-col flex-shrink-0">
|
||||
<div className="p-4 border-b border-border h-12 bg-muted/30 animate-pulse" />
|
||||
<div className="flex-1 p-4 space-y-2">
|
||||
<div className="h-10 rounded bg-muted animate-pulse" />
|
||||
<div className="h-10 rounded bg-muted animate-pulse" />
|
||||
<div className="h-10 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* CourseLearningView — lesson title, prev/next, tabs (overview / notes / resources).
|
||||
*/
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight, FileText } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CourseLearningTab } from './types';
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CourseLearningViewTabsProps {
|
||||
lesson: Lesson | null;
|
||||
currentIndex: number;
|
||||
totalLessons: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
activeTab: CourseLearningTab;
|
||||
onTabChange: (tab: CourseLearningTab) => void;
|
||||
}
|
||||
|
||||
const TABS: CourseLearningTab[] = ['overview', 'notes', 'resources'];
|
||||
|
||||
export function CourseLearningViewTabs({
|
||||
lesson,
|
||||
currentIndex,
|
||||
totalLessons,
|
||||
onPrev,
|
||||
onNext,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: CourseLearningViewTabsProps) {
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-5xl mx-auto w-full">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-white">{lesson?.title ?? '—'}</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onPrev}
|
||||
disabled={currentIndex === 0}
|
||||
icon={<ChevronLeft className="w-4 h-4" />}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onNext}>
|
||||
{currentIndex === totalLessons - 1 ? 'Finish Course' : 'Next'}{' '}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-kodo-steel flex gap-6 mb-6">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab)}
|
||||
className={cn(
|
||||
'pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors',
|
||||
activeTab === tab
|
||||
? 'border-kodo-cyan text-white'
|
||||
: 'border-transparent text-kodo-content-dim hover:text-kodo-text-main',
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="text-kodo-text-main space-y-4">
|
||||
<p>In this lesson, we cover the fundamentals of the topic. Make sure to take notes.</p>
|
||||
<div className="p-4 bg-kodo-ink rounded border border-kodo-steel/50">
|
||||
<h4 className="font-bold text-white mb-2">Key Takeaways</h4>
|
||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||
<li>Understanding the core concept</li>
|
||||
<li>Applying technique A to situation B</li>
|
||||
<li>Common pitfalls to avoid</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'notes' && (
|
||||
<div>
|
||||
<textarea
|
||||
className="w-full h-40 bg-kodo-ink border border-kodo-steel rounded p-4 text-white resize-none focus:border-kodo-steel outline-none"
|
||||
placeholder="Type your personal notes here..."
|
||||
/>
|
||||
<Button variant="secondary" size="sm" className="mt-2">
|
||||
Save Note
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'resources' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-4 bg-kodo-ink rounded border border-kodo-steel">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileText className="w-5 h-5 text-kodo-steel" />
|
||||
<span className="text-sm text-white">Lesson Slides.pdf</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">Download</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-kodo-ink rounded border border-kodo-steel">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileText className="w-5 h-5 text-kodo-magenta" />
|
||||
<span className="text-sm text-white">Project Files.zip</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* CourseLearningView — public API.
|
||||
*/
|
||||
export { CourseLearningView } from './CourseLearningView';
|
||||
export { CourseLearningViewSkeleton } from './CourseLearningViewSkeleton';
|
||||
export type { CourseLearningViewProps, Course } from './types';
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* CourseLearningView — shared types.
|
||||
*/
|
||||
import type { Course } from '@/types';
|
||||
|
||||
export type { Course };
|
||||
|
||||
export type CourseLearningTab = 'overview' | 'notes' | 'resources';
|
||||
|
||||
export interface CourseLearningViewProps {
|
||||
course: Course;
|
||||
onBack: () => void;
|
||||
/** For stories: show skeleton instead of content */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* CourseLearningView — state and handlers.
|
||||
*/
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useToast } from '@/components/feedback/ToastProvider';
|
||||
import type { Course } from '@/types';
|
||||
import type { CourseLearningTab } from './types';
|
||||
|
||||
export function useCourseLearningView(course: Course) {
|
||||
const { addToast } = useToast();
|
||||
const allLessons = useMemo(
|
||||
() => course.modules?.flatMap((m) => m.lessons) ?? [],
|
||||
[course.modules],
|
||||
);
|
||||
const firstLessonId = allLessons[0]?.id ?? '';
|
||||
|
||||
const [activeLessonId, setActiveLessonId] = useState(firstLessonId);
|
||||
const [completedLessons, setCompletedLessons] = useState<string[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<CourseLearningTab>('overview');
|
||||
const [showQuiz, setShowQuiz] = useState(false);
|
||||
const [activeQuiz, setActiveQuiz] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
passingScore: number;
|
||||
questions: { id: string; question: string; options: string[]; correctIndex: number }[];
|
||||
} | null>(null);
|
||||
const [showCertificate, setShowCertificate] = useState(false);
|
||||
|
||||
const currentLessonIndex = allLessons.findIndex((l) => l.id === activeLessonId);
|
||||
const currentLesson = allLessons[currentLessonIndex] ?? null;
|
||||
|
||||
const progress = allLessons.length
|
||||
? Math.round((completedLessons.length / allLessons.length) * 100)
|
||||
: 0;
|
||||
|
||||
const markComplete = useCallback((id: string) => {
|
||||
setCompletedLessons((prev) =>
|
||||
prev.includes(id) ? prev : [...prev, id],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (!currentLesson) return;
|
||||
if (currentLessonIndex < allLessons.length - 1) {
|
||||
const nextLesson = allLessons[currentLessonIndex + 1];
|
||||
if (nextLesson) setActiveLessonId(nextLesson.id);
|
||||
markComplete(currentLesson.id);
|
||||
} else {
|
||||
markComplete(currentLesson.id);
|
||||
addToast('Course Completed! 🎉', 'success');
|
||||
if (course.certificateAvailable) setShowCertificate(true);
|
||||
}
|
||||
}, [currentLesson, currentLessonIndex, allLessons, markComplete, course.certificateAvailable, addToast]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentLessonIndex > 0) {
|
||||
const prevLesson = allLessons[currentLessonIndex - 1];
|
||||
if (prevLesson) setActiveLessonId(prevLesson.id);
|
||||
}
|
||||
}, [currentLessonIndex, allLessons]);
|
||||
|
||||
const startQuiz = useCallback((quizId: string) => {
|
||||
setActiveQuiz({
|
||||
id: quizId,
|
||||
title: 'Module Assessment',
|
||||
passingScore: 70,
|
||||
questions: [
|
||||
{ id: 'q1', question: 'What is the frequency range of a sub-bass?', options: ['20-60Hz', '200-500Hz', '1-2kHz'], correctIndex: 0 },
|
||||
{ id: 'q2', question: 'Which plugin is best for sidechaining?', options: ['Reverb', 'Compressor', 'Delay'], correctIndex: 1 },
|
||||
],
|
||||
});
|
||||
setShowQuiz(true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
allLessons,
|
||||
currentLesson,
|
||||
currentLessonIndex,
|
||||
activeLessonId,
|
||||
setActiveLessonId,
|
||||
completedLessons,
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
progress,
|
||||
showQuiz,
|
||||
setShowQuiz,
|
||||
activeQuiz,
|
||||
showCertificate,
|
||||
setShowCertificate,
|
||||
markComplete,
|
||||
handleNext,
|
||||
handlePrev,
|
||||
startQuiz,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue