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:
senke 2026-02-05 23:45:58 +01:00
parent a88d5a3e7c
commit bfd95f2ce8
11 changed files with 608 additions and 354 deletions

View file

@ -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: {

View file

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

View file

@ -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>
);
};

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1,6 @@
/**
* CourseLearningView public API.
*/
export { CourseLearningView } from './CourseLearningView';
export { CourseLearningViewSkeleton } from './CourseLearningViewSkeleton';
export type { CourseLearningViewProps, Course } from './types';

View file

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

View file

@ -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,
};
}