- Generate unique quiz for each lesson using OpenRouter API - Parse lesson content (TipTap JSON) and send to AI for question generation - Cache quiz in database to avoid regeneration - Hide Edit/Delete buttons if current user is not course author - Add backendUser to auth context for proper authorization checks - Show certificate button prominently when course is completed Co-authored-by: Cursor <cursoragent@cursor.com>
553 lines
22 KiB
TypeScript
553 lines
22 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState, useMemo } from 'react';
|
||
import Link from 'next/link';
|
||
import { useParams, useRouter } from 'next/navigation';
|
||
import {
|
||
ArrowLeft,
|
||
Edit,
|
||
Trash2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
CheckCircle2,
|
||
Circle,
|
||
Lock,
|
||
BookOpen,
|
||
Clock,
|
||
GraduationCap,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Play,
|
||
} from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Progress } from '@/components/ui/progress';
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
AlertDialogTrigger,
|
||
} from '@/components/ui/alert-dialog';
|
||
import { api } from '@/lib/api';
|
||
import { useAuth } from '@/contexts/auth-context';
|
||
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
||
import { LessonQuiz } from '@/components/dashboard/lesson-quiz';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
|
||
type Chapter = { id: string; title: string; description?: string | null; order: number; lessons: Lesson[] };
|
||
type CourseData = {
|
||
id: string;
|
||
title: string;
|
||
description?: string | null;
|
||
status: string;
|
||
authorId: string;
|
||
chapters: Chapter[];
|
||
};
|
||
|
||
export default function CoursePage() {
|
||
const params = useParams();
|
||
const router = useRouter();
|
||
const { loading: authLoading, backendUser } = useAuth();
|
||
const id = params?.id as string;
|
||
const [course, setCourse] = useState<CourseData | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
||
const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null);
|
||
const [lessonContentLoading, setLessonContentLoading] = useState(false);
|
||
const [completedLessons, setCompletedLessons] = useState<Set<string>>(new Set());
|
||
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||
const [enrollmentProgress, setEnrollmentProgress] = useState<any>(null);
|
||
const [showQuiz, setShowQuiz] = useState(false);
|
||
const [quizQuestions, setQuizQuestions] = useState<any[]>([]);
|
||
const [generatingCertificate, setGeneratingCertificate] = useState(false);
|
||
|
||
// Flat list of all lessons
|
||
const flatLessons = useMemo(() => {
|
||
if (!course) return [];
|
||
return course.chapters
|
||
.sort((a, b) => a.order - b.order)
|
||
.flatMap((ch) =>
|
||
ch.lessons.sort((a, b) => a.order - b.order).map((l) => ({ ...l, chapterId: ch.id, chapterTitle: ch.title }))
|
||
);
|
||
}, [course]);
|
||
|
||
const currentLessonIndex = flatLessons.findIndex((l) => l.id === selectedLessonId);
|
||
const totalLessons = flatLessons.length;
|
||
const completedCount = completedLessons.size;
|
||
const progressPercent = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
|
||
|
||
// Load course and progress
|
||
useEffect(() => {
|
||
if (!id || authLoading) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [courseData, progressData] = await Promise.all([
|
||
api.getCourse(id),
|
||
api.getEnrollmentProgress(id).catch(() => null),
|
||
]);
|
||
if (!cancelled) {
|
||
setCourse(courseData);
|
||
setEnrollmentProgress(progressData);
|
||
if (progressData?.lessons) {
|
||
const completed: Set<string> = new Set(
|
||
progressData.lessons
|
||
.filter((l: any) => l.completedAt)
|
||
.map((l: any) => String(l.lessonId))
|
||
);
|
||
setCompletedLessons(completed);
|
||
}
|
||
const first = courseData.chapters?.[0]?.lessons?.[0];
|
||
if (first) setSelectedLessonId(first.id);
|
||
setExpandedChapters(courseData.chapters.map((ch: Chapter) => ch.id));
|
||
}
|
||
} catch (e: any) {
|
||
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [id, authLoading]);
|
||
|
||
// Load lesson content
|
||
useEffect(() => {
|
||
if (!id || !selectedLessonId) {
|
||
setLessonContent(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setLessonContentLoading(true);
|
||
(async () => {
|
||
try {
|
||
const data = await api.getLesson(id, selectedLessonId);
|
||
const content = data?.content;
|
||
if (!cancelled)
|
||
setLessonContent(
|
||
typeof content === 'object' && content !== null ? (content as Record<string, unknown>) : null
|
||
);
|
||
} catch {
|
||
if (!cancelled) setLessonContent(null);
|
||
} finally {
|
||
if (!cancelled) setLessonContentLoading(false);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [id, selectedLessonId]);
|
||
|
||
const handleDelete = async () => {
|
||
if (!course?.id || deleting) return;
|
||
setDeleting(true);
|
||
try {
|
||
await api.deleteCourse(course.id);
|
||
router.push('/dashboard');
|
||
router.refresh();
|
||
} catch (e: any) {
|
||
setError(e?.message || 'Не удалось удалить курс');
|
||
} finally {
|
||
setDeleting(false);
|
||
}
|
||
};
|
||
|
||
const markComplete = async () => {
|
||
if (!selectedLessonId || !id) return;
|
||
try {
|
||
await api.completeLesson(id, selectedLessonId);
|
||
setCompletedLessons((prev) => new Set(prev).add(selectedLessonId));
|
||
} catch {
|
||
setCompletedLessons((prev) => new Set(prev).add(selectedLessonId));
|
||
}
|
||
};
|
||
|
||
const handleStartQuiz = async () => {
|
||
if (!selectedLessonId || !id) return;
|
||
try {
|
||
const quiz = await api.getLessonQuiz(id, selectedLessonId);
|
||
setQuizQuestions(quiz.questions || []);
|
||
setShowQuiz(true);
|
||
} catch {
|
||
setQuizQuestions([]);
|
||
}
|
||
};
|
||
|
||
const handleQuizComplete = async (score: number) => {
|
||
if (!selectedLessonId || !id) return;
|
||
try {
|
||
await api.submitQuizScore(id, selectedLessonId, score);
|
||
markComplete();
|
||
} catch {
|
||
markComplete();
|
||
}
|
||
};
|
||
|
||
const handleGetCertificate = async () => {
|
||
if (!id || generatingCertificate) return;
|
||
setGeneratingCertificate(true);
|
||
try {
|
||
const { certificateUrl } = await api.getCertificate(id);
|
||
window.open(certificateUrl, '_blank');
|
||
} catch {
|
||
// silent
|
||
} finally {
|
||
setGeneratingCertificate(false);
|
||
}
|
||
};
|
||
|
||
const goToNextLesson = () => {
|
||
if (currentLessonIndex < flatLessons.length - 1) {
|
||
markComplete();
|
||
setSelectedLessonId(flatLessons[currentLessonIndex + 1].id);
|
||
}
|
||
};
|
||
|
||
const goToPrevLesson = () => {
|
||
if (currentLessonIndex > 0) {
|
||
setSelectedLessonId(flatLessons[currentLessonIndex - 1].id);
|
||
}
|
||
};
|
||
|
||
const toggleChapter = (chapterId: string) => {
|
||
setExpandedChapters((prev) =>
|
||
prev.includes(chapterId)
|
||
? prev.filter((id) => id !== chapterId)
|
||
: [...prev, chapterId]
|
||
);
|
||
};
|
||
|
||
if (authLoading || loading) {
|
||
return (
|
||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||
<p className="text-muted-foreground">Загрузка курса...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !course) {
|
||
return (
|
||
<div className="flex flex-col gap-4 p-6">
|
||
<Button variant="ghost" asChild>
|
||
<Link href="/dashboard"><ArrowLeft className="mr-2 h-4 w-4" />Назад к курсам</Link>
|
||
</Button>
|
||
<p className="text-destructive">{error || 'Курс не найден'}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const activeLessonMeta = selectedLessonId
|
||
? flatLessons.find((l) => l.id === selectedLessonId)
|
||
: null;
|
||
|
||
const isAuthor = course && backendUser && course.authorId === backendUser.id;
|
||
const courseCompleted = completedCount >= totalLessons && totalLessons > 0;
|
||
|
||
return (
|
||
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
|
||
{/* Top bar */}
|
||
<div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-2.5 shadow-sm">
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="ghost" size="sm" asChild>
|
||
<Link href="/dashboard">
|
||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||
Мои курсы
|
||
</Link>
|
||
</Button>
|
||
<div className="h-5 w-px bg-border" />
|
||
<div className="flex items-center gap-2">
|
||
<GraduationCap className="h-4 w-4 text-primary" />
|
||
<span className="font-medium truncate max-w-[300px]">{course.title}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{/* Progress badge */}
|
||
<div className="hidden sm:flex items-center gap-2 mr-2 px-3 py-1.5 bg-muted rounded-full">
|
||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||
<span className="text-xs font-medium">{progressPercent}% пройдено</span>
|
||
</div>
|
||
|
||
{/* Certificate button - show when course completed */}
|
||
{courseCompleted && (
|
||
<Button size="sm" variant="default" onClick={handleGetCertificate} disabled={generatingCertificate}>
|
||
<GraduationCap className="mr-1.5 h-3.5 w-3.5" />
|
||
{generatingCertificate ? 'Генерация...' : 'Получить сертификат'}
|
||
</Button>
|
||
)}
|
||
|
||
{/* Edit/Delete - only for author */}
|
||
{isAuthor && (
|
||
<>
|
||
<Button size="sm" variant="outline" asChild>
|
||
<Link href={`/dashboard/courses/${course.id}/edit`}>
|
||
<Edit className="mr-1.5 h-3.5 w-3.5" />
|
||
Редактировать
|
||
</Link>
|
||
</Button>
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Удалить курс?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Курс «{course.title}» будет удалён безвозвратно.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={(e) => { e.preventDefault(); handleDelete(); }}
|
||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||
disabled={deleting}
|
||
>
|
||
{deleting ? 'Удаление...' : 'Удалить'}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative flex flex-1 min-h-0">
|
||
{/* ─── Left sidebar: course navigation ─── */}
|
||
<div
|
||
className={cn(
|
||
'border-r bg-muted/10 flex flex-col transition-all duration-300 ease-in-out',
|
||
sidebarOpen ? 'w-80 shrink-0' : 'w-0 overflow-hidden'
|
||
)}
|
||
>
|
||
{sidebarOpen && (
|
||
<div className="flex flex-col h-full">
|
||
{/* Course progress */}
|
||
<div className="p-4 border-b bg-background">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Прогресс курса</span>
|
||
<span className="text-xs font-bold text-primary">{completedCount}/{totalLessons}</span>
|
||
</div>
|
||
<Progress value={progressPercent} className="h-2" />
|
||
</div>
|
||
|
||
{/* Chapters & lessons */}
|
||
<div className="flex-1 overflow-auto py-2">
|
||
{course.chapters
|
||
.sort((a, b) => a.order - b.order)
|
||
.map((chapter, chapterIdx) => {
|
||
const isExpanded = expandedChapters.includes(chapter.id);
|
||
const chapterLessons = chapter.lessons.sort((a, b) => a.order - b.order);
|
||
const chapterComplete = chapterLessons.every((l) => completedLessons.has(l.id));
|
||
const chapterStarted = chapterLessons.some((l) => completedLessons.has(l.id));
|
||
|
||
return (
|
||
<div key={chapter.id} className="mb-1">
|
||
<button
|
||
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||
onClick={() => toggleChapter(chapter.id)}
|
||
>
|
||
<div className={cn(
|
||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
|
||
chapterComplete
|
||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||
: chapterStarted
|
||
? 'bg-primary/10 text-primary'
|
||
: 'bg-muted text-muted-foreground'
|
||
)}>
|
||
{chapterComplete ? <CheckCircle2 className="h-4 w-4" /> : chapterIdx + 1}
|
||
</div>
|
||
<span className="flex-1 text-left font-medium truncate">{chapter.title}</span>
|
||
{isExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||
</button>
|
||
|
||
{isExpanded && (
|
||
<div className="ml-4 border-l-2 border-muted pl-2 mb-2">
|
||
{chapterLessons.map((lesson, lessonIdx) => {
|
||
const isActive = selectedLessonId === lesson.id;
|
||
const isCompleted = completedLessons.has(lesson.id);
|
||
const globalIdx = flatLessons.findIndex((l) => l.id === lesson.id);
|
||
// Lesson is locked if sequential mode: all previous must be complete
|
||
// For now, don't lock (allow free navigation)
|
||
const isLocked = false;
|
||
|
||
return (
|
||
<button
|
||
key={lesson.id}
|
||
disabled={isLocked}
|
||
className={cn(
|
||
'flex items-center gap-2.5 w-full rounded-lg px-3 py-2 text-sm transition-all',
|
||
isActive
|
||
? 'bg-primary text-primary-foreground shadow-sm'
|
||
: isLocked
|
||
? 'opacity-40 cursor-not-allowed'
|
||
: 'hover:bg-muted/80 text-foreground/80'
|
||
)}
|
||
onClick={() => !isLocked && setSelectedLessonId(lesson.id)}
|
||
>
|
||
{isCompleted ? (
|
||
<CheckCircle2 className={cn('h-4 w-4 shrink-0', isActive ? 'text-primary-foreground' : 'text-green-500')} />
|
||
) : isLocked ? (
|
||
<Lock className="h-4 w-4 shrink-0" />
|
||
) : isActive ? (
|
||
<Play className="h-4 w-4 shrink-0 fill-current" />
|
||
) : (
|
||
<Circle className="h-4 w-4 shrink-0" />
|
||
)}
|
||
<span className="truncate text-left">{lesson.title}</span>
|
||
{lesson.durationMinutes && (
|
||
<span className={cn('ml-auto text-xs shrink-0', isActive ? 'text-primary-foreground/70' : 'text-muted-foreground')}>
|
||
{lesson.durationMinutes} мин
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Sidebar toggle */}
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-all',
|
||
)}
|
||
style={{ left: sidebarOpen ? '19.9rem' : 0 }}
|
||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||
>
|
||
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||
</button>
|
||
|
||
{/* ─── Main content area ─── */}
|
||
<main className="flex-1 flex flex-col min-h-0">
|
||
{/* Lesson content */}
|
||
<div className="flex-1 overflow-auto">
|
||
<div className="max-w-3xl mx-auto w-full px-8 py-10">
|
||
{/* Chapter & lesson header */}
|
||
{activeLessonMeta && (
|
||
<div className="mb-8">
|
||
<p className="text-sm text-primary font-medium mb-1">
|
||
{activeLessonMeta.chapterTitle}
|
||
</p>
|
||
<h1 className="text-3xl font-bold text-foreground">
|
||
{activeLessonMeta.title}
|
||
</h1>
|
||
<div className="flex items-center gap-4 mt-3 text-sm text-muted-foreground">
|
||
<span className="flex items-center gap-1">
|
||
<BookOpen className="h-4 w-4" />
|
||
Урок {currentLessonIndex + 1} из {totalLessons}
|
||
</span>
|
||
{activeLessonMeta.durationMinutes && (
|
||
<span className="flex items-center gap-1">
|
||
<Clock className="h-4 w-4" />
|
||
{activeLessonMeta.durationMinutes} мин
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-4 h-px bg-border" />
|
||
</div>
|
||
)}
|
||
|
||
{/* Content */}
|
||
{lessonContentLoading ? (
|
||
<div className="flex items-center justify-center py-20">
|
||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||
</div>
|
||
) : selectedLessonId ? (
|
||
<>
|
||
<LessonContentViewer
|
||
content={lessonContent}
|
||
className="min-h-[400px]"
|
||
/>
|
||
{!showQuiz && !completedLessons.has(selectedLessonId) && (
|
||
<div className="mt-8 p-6 border rounded-xl bg-muted/20 text-center">
|
||
<h3 className="font-semibold mb-2">Проверьте свои знания</h3>
|
||
<p className="text-sm text-muted-foreground mb-4">
|
||
Пройдите тест, чтобы закрепить материал и получить сертификат
|
||
</p>
|
||
<Button onClick={handleStartQuiz}>Начать тест</Button>
|
||
</div>
|
||
)}
|
||
{showQuiz && (
|
||
<LessonQuiz
|
||
courseId={id}
|
||
lessonId={selectedLessonId}
|
||
questions={quizQuestions}
|
||
onComplete={handleQuizComplete}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||
<BookOpen className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||
<p className="text-muted-foreground">Выберите урок для начала обучения</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom navigation */}
|
||
<div className="shrink-0 border-t bg-background px-6 py-3">
|
||
<div className="max-w-3xl mx-auto flex items-center justify-between">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={goToPrevLesson}
|
||
disabled={currentLessonIndex <= 0}
|
||
>
|
||
<ChevronLeft className="mr-1.5 h-4 w-4" />
|
||
Предыдущий
|
||
</Button>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={markComplete}
|
||
disabled={!selectedLessonId || completedLessons.has(selectedLessonId)}
|
||
className={cn(
|
||
completedLessons.has(selectedLessonId ?? '')
|
||
? 'text-green-600'
|
||
: 'text-muted-foreground hover:text-primary'
|
||
)}
|
||
>
|
||
<CheckCircle2 className="mr-1.5 h-4 w-4" />
|
||
{completedLessons.has(selectedLessonId ?? '') ? 'Пройден' : 'Отметить пройденным'}
|
||
</Button>
|
||
|
||
{currentLessonIndex < flatLessons.length - 1 ? (
|
||
<Button size="sm" onClick={goToNextLesson}>
|
||
Следующий урок
|
||
<ChevronRight className="ml-1.5 h-4 w-4" />
|
||
</Button>
|
||
) : (
|
||
<div className="text-sm text-muted-foreground">
|
||
{courseCompleted ? 'Курс пройден!' : 'Последний урок'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|