feat: add course catalog, enrollment, progress tracking, quizzes, and reviews

Backend changes:
- Add Enrollment and LessonProgress models to track user progress
- Add UserRole enum (USER, MODERATOR, ADMIN)
- Add course verification and moderation fields
- New CatalogModule: public course browsing, publishing, verification
- New EnrollmentModule: enroll, progress tracking, quiz submission, reviews
- Add quiz generation endpoint to LessonsController

Frontend changes:
- Redesign course viewer: proper course UI with lesson navigation, progress bar
- Add beautiful typography styles for course content (prose-course)
- Fix first-login bug with token exchange retry logic
- New pages: /catalog (public courses), /catalog/[id] (course details), /learning (enrollments)
- Add LessonQuiz component with scoring and results
- Update sidebar navigation: add Catalog and My Learning links
- Add publish/verify buttons in course editor
- Integrate enrollment progress tracking with backend

All courses now support: sequential progression, quiz tests, reviews, ratings,
author verification badges, and full marketplace publishing workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-02-06 10:44:05 +00:00
parent dab726e8d1
commit 2ed65f5678
23 changed files with 1796 additions and 78 deletions

View File

@ -1,10 +1,26 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
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,
@ -19,7 +35,7 @@ import {
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
import { LessonSidebar } from '@/components/editor/lesson-sidebar';
import { LessonQuiz } from '@/components/dashboard/lesson-quiz';
import { cn } from '@/lib/utils';
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
@ -41,11 +57,32 @@ export default function CoursePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
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[]>([]);
// 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;
@ -53,11 +90,24 @@ export default function CoursePage() {
setLoading(true);
setError(null);
try {
const data = await api.getCourse(id);
const [courseData, progressData] = await Promise.all([
api.getCourse(id),
api.getEnrollmentProgress(id).catch(() => null),
]);
if (!cancelled) {
setCourse(data);
const first = data.chapters?.[0]?.lessons?.[0];
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 || 'Не удалось загрузить курс');
@ -68,6 +118,7 @@ export default function CoursePage() {
return () => { cancelled = true; };
}, [id, authLoading]);
// Load lesson content
useEffect(() => {
if (!id || !selectedLessonId) {
setLessonContent(null);
@ -106,10 +157,65 @@ export default function CoursePage() {
}
};
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 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">
<p className="text-muted-foreground">Загрузка курса...</p>
<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>
);
}
@ -125,40 +231,42 @@ export default function CoursePage() {
);
}
const activeLessonTitle = selectedLessonId
? (() => {
for (const ch of course.chapters) {
const lesson = ch.lessons.find((l) => l.id === selectedLessonId);
if (lesson) return lesson.title;
}
return null;
})()
const activeLessonMeta = selectedLessonId
? flatLessons.find((l) => l.id === selectedLessonId)
: null;
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-3">
<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-2 h-4 w-4" />
К курсам
<ArrowLeft className="mr-1.5 h-4 w-4" />
Мои курсы
</Link>
</Button>
<span className="text-muted-foreground">/</span>
<span className="font-medium truncate max-w-[200px] sm:max-w-xs">{course.title}</span>
<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">
<Button size="sm" asChild>
{/* 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>
<Button size="sm" variant="outline" asChild>
<Link href={`/dashboard/courses/${course.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<Edit className="mr-1.5 h-3.5 w-3.5" />
Редактировать
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon" className="text-destructive hover:text-destructive hover:bg-destructive/10">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
@ -185,48 +293,226 @@ export default function CoursePage() {
</div>
<div className="relative flex flex-1 min-h-0">
{/* Left: list of lessons (paragraphs) */}
{/* ─── Left sidebar: course navigation ─── */}
<div
className={cn(
'border-r bg-muted/20 flex flex-col transition-[width] duration-200',
sidebarOpen ? 'w-72 shrink-0' : 'w-0 overflow-hidden'
'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 && (
<LessonSidebar
course={course}
activeLesson={selectedLessonId ?? ''}
onSelectLesson={setSelectedLessonId}
readOnly
/>
<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="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-colors"
style={{ left: sidebarOpen ? '17rem' : 0 }}
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>
{/* Center: lesson content (read-only) */}
<main className="flex-1 flex flex-col min-h-0 overflow-auto">
<div className="max-w-3xl mx-auto w-full px-6 py-8">
{activeLessonTitle && (
<h1 className="text-2xl font-bold mb-6 text-foreground">{activeLessonTitle}</h1>
)}
{lessonContentLoading ? (
<p className="text-muted-foreground">Загрузка...</p>
) : selectedLessonId ? (
<LessonContentViewer
content={lessonContent}
className="prose-reader min-h-[400px] [&_.ProseMirror]:min-h-[300px]"
/>
) : (
<p className="text-muted-foreground">Выберите урок в списке слева.</p>
)}
{/* ─── 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>
) : (
<Button size="sm" variant="outline" disabled={completedCount < totalLessons}>
<GraduationCap className="mr-1.5 h-4 w-4" />
Завершить курс
</Button>
)}
</div>
</div>
</main>
</div>