diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1e8cd43..87d40ee 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,8 @@ import { join } from 'path'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { CoursesModule } from './courses/courses.module'; +import { CatalogModule } from './catalog/catalog.module'; +import { EnrollmentModule } from './enrollment/enrollment.module'; import { GenerationModule } from './generation/generation.module'; import { PaymentsModule } from './payments/payments.module'; import { SearchModule } from './search/search.module'; @@ -38,6 +40,8 @@ import { PrismaModule } from './common/prisma/prisma.module'; AuthModule, UsersModule, CoursesModule, + CatalogModule, + EnrollmentModule, GenerationModule, PaymentsModule, SearchModule, diff --git a/apps/api/src/catalog/catalog.controller.ts b/apps/api/src/catalog/catalog.controller.ts new file mode 100644 index 0000000..33b0774 --- /dev/null +++ b/apps/api/src/catalog/catalog.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, Post, Patch, Param, Query, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { CatalogService } from './catalog.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { User } from '@coursecraft/database'; + +@ApiTags('catalog') +@Controller('catalog') +export class CatalogController { + constructor(private catalogService: CatalogService) {} + + @Public() + @Get() + @ApiOperation({ summary: 'Browse published courses (public)' }) + async browseCourses( + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('search') search?: string, + @Query('difficulty') difficulty?: string, + ): Promise { + return this.catalogService.getPublishedCourses({ page, limit, search, difficulty }); + } + + @Public() + @Get(':id') + @ApiOperation({ summary: 'Get public course details' }) + async getCourse(@Param('id') id: string): Promise { + return this.catalogService.getPublicCourse(id); + } + + @Post(':id/submit') + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Submit course for review / publish' }) + async submitForReview(@Param('id') id: string, @CurrentUser() user: User): Promise { + return this.catalogService.publishCourse(id, user.id); + } + + @Patch(':id/verify') + @ApiBearerAuth() + @ApiOperation({ summary: 'Toggle author verification badge' }) + async toggleVerify(@Param('id') id: string, @CurrentUser() user: User): Promise { + return this.catalogService.toggleVerification(id, user.id); + } +} diff --git a/apps/api/src/catalog/catalog.module.ts b/apps/api/src/catalog/catalog.module.ts new file mode 100644 index 0000000..161cdd6 --- /dev/null +++ b/apps/api/src/catalog/catalog.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CatalogController } from './catalog.controller'; +import { CatalogService } from './catalog.service'; + +@Module({ + controllers: [CatalogController], + providers: [CatalogService], + exports: [CatalogService], +}) +export class CatalogModule {} diff --git a/apps/api/src/catalog/catalog.service.ts b/apps/api/src/catalog/catalog.service.ts new file mode 100644 index 0000000..5953d3f --- /dev/null +++ b/apps/api/src/catalog/catalog.service.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { CourseStatus } from '@coursecraft/database'; + +@Injectable() +export class CatalogService { + constructor(private prisma: PrismaService) {} + + async getPublishedCourses(options?: { + page?: number; + limit?: number; + category?: string; + difficulty?: string; + search?: string; + }): Promise { + const page = options?.page || 1; + const limit = options?.limit || 20; + const skip = (page - 1) * limit; + + const where: any = { + status: CourseStatus.PUBLISHED, + isPublished: true, + }; + if (options?.category) where.categoryId = options.category; + if (options?.difficulty) where.difficulty = options.difficulty; + if (options?.search) { + where.OR = [ + { title: { contains: options.search, mode: 'insensitive' } }, + { description: { contains: options.search, mode: 'insensitive' } }, + ]; + } + + const [courses, total] = await Promise.all([ + this.prisma.course.findMany({ + where, + include: { + author: { select: { id: true, name: true, avatarUrl: true } }, + _count: { select: { chapters: true, reviews: true, enrollments: true } }, + chapters: { include: { _count: { select: { lessons: true } } } }, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + this.prisma.course.count({ where }), + ]); + + const data = courses.map((c) => ({ + id: c.id, + title: c.title, + description: c.description, + slug: c.slug, + coverImage: c.coverImage, + price: c.price, + currency: c.currency, + difficulty: c.difficulty, + estimatedHours: c.estimatedHours, + tags: c.tags, + isVerified: c.isVerified, + averageRating: c.averageRating, + enrollmentCount: c._count.enrollments, + reviewCount: c._count.reviews, + chaptersCount: c._count.chapters, + lessonsCount: c.chapters.reduce((acc, ch) => acc + ch._count.lessons, 0), + author: c.author, + publishedAt: c.publishedAt, + })); + + return { data, meta: { page, limit, total, totalPages: Math.ceil(total / limit) } }; + } + + async getPublicCourse(courseId: string): Promise { + return this.prisma.course.findFirst({ + where: { id: courseId, status: CourseStatus.PUBLISHED, isPublished: true }, + include: { + author: { select: { id: true, name: true, avatarUrl: true } }, + chapters: { + include: { + lessons: { select: { id: true, title: true, order: true, durationMinutes: true }, orderBy: { order: 'asc' } }, + }, + orderBy: { order: 'asc' }, + }, + reviews: { + where: { isApproved: true }, + include: { user: { select: { id: true, name: true, avatarUrl: true } } }, + orderBy: { createdAt: 'desc' }, + take: 20, + }, + _count: { select: { reviews: true, enrollments: true } }, + }, + }); + } + + async submitForReview(courseId: string, userId: string): Promise { + return this.prisma.course.update({ + where: { id: courseId, authorId: userId }, + data: { status: CourseStatus.PENDING_REVIEW }, + }); + } + + async publishCourse(courseId: string, userId: string): Promise { + return this.prisma.course.update({ + where: { id: courseId, authorId: userId }, + data: { + status: CourseStatus.PUBLISHED, + isPublished: true, + publishedAt: new Date(), + }, + }); + } + + async toggleVerification(courseId: string, userId: string): Promise { + const course = await this.prisma.course.findFirst({ + where: { id: courseId, authorId: userId }, + }); + if (!course) return null; + return this.prisma.course.update({ + where: { id: courseId }, + data: { isVerified: !course.isVerified }, + }); + } +} diff --git a/apps/api/src/courses/lessons.controller.ts b/apps/api/src/courses/lessons.controller.ts index f49db66..bb21c87 100644 --- a/apps/api/src/courses/lessons.controller.ts +++ b/apps/api/src/courses/lessons.controller.ts @@ -64,4 +64,10 @@ export class LessonsController { ): Promise { return this.lessonsService.reorder(chapterId, user.id, lessonIds); } + + @Get('lessons/:lessonId/quiz') + @ApiOperation({ summary: 'Generate quiz for a lesson' }) + async generateQuiz(@Param('lessonId') lessonId: string): Promise { + return this.lessonsService.generateQuiz(lessonId); + } } diff --git a/apps/api/src/courses/lessons.service.ts b/apps/api/src/courses/lessons.service.ts index 01bf297..9c71a78 100644 --- a/apps/api/src/courses/lessons.service.ts +++ b/apps/api/src/courses/lessons.service.ts @@ -138,4 +138,36 @@ export class LessonsService { orderBy: { order: 'asc' }, }); } + + async generateQuiz(lessonId: string): Promise { + const lesson = await this.prisma.lesson.findUnique({ where: { id: lessonId } }); + if (!lesson) throw new NotFoundException('Lesson not found'); + + // Mock quiz - in production, parse lesson.content and call AI to generate questions + return { + questions: [ + { + id: '1', + type: 'multiple_choice', + question: 'Какой из следующих вариантов верен?', + options: ['Вариант A', 'Вариант B', 'Вариант C', 'Вариант D'], + correctAnswer: 0, + }, + { + id: '2', + type: 'multiple_choice', + question: 'Выберите правильный ответ:', + options: ['Ответ 1', 'Ответ 2', 'Ответ 3'], + correctAnswer: 1, + }, + { + id: '3', + type: 'multiple_choice', + question: 'Что из перечисленного правильно?', + options: ['Первое', 'Второе', 'Третье', 'Четвёртое'], + correctAnswer: 2, + }, + ], + }; + } } diff --git a/apps/api/src/enrollment/enrollment.controller.ts b/apps/api/src/enrollment/enrollment.controller.ts new file mode 100644 index 0000000..daa967b --- /dev/null +++ b/apps/api/src/enrollment/enrollment.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Post, + Get, + Param, + Body, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { EnrollmentService } from './enrollment.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '@coursecraft/database'; + +@ApiTags('enrollment') +@Controller('enrollment') +@ApiBearerAuth() +export class EnrollmentController { + constructor(private enrollmentService: EnrollmentService) {} + + @Post(':courseId/enroll') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Enroll in a course' }) + async enroll(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { + return this.enrollmentService.enroll(user.id, courseId); + } + + @Get() + @ApiOperation({ summary: 'Get my enrolled courses' }) + async myEnrollments(@CurrentUser() user: User): Promise { + return this.enrollmentService.getUserEnrollments(user.id); + } + + @Get(':courseId/progress') + @ApiOperation({ summary: 'Get my progress for a course' }) + async getProgress(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { + return this.enrollmentService.getProgress(user.id, courseId); + } + + @Post(':courseId/lessons/:lessonId/complete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Mark a lesson as completed' }) + async completeLesson( + @Param('courseId') courseId: string, + @Param('lessonId') lessonId: string, + @CurrentUser() user: User, + ): Promise { + return this.enrollmentService.completeLesson(user.id, courseId, lessonId); + } + + @Post(':courseId/lessons/:lessonId/quiz') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Submit quiz score' }) + async submitQuiz( + @Param('courseId') courseId: string, + @Param('lessonId') lessonId: string, + @Body('score') score: number, + @CurrentUser() user: User, + ): Promise { + return this.enrollmentService.saveQuizScore(user.id, courseId, lessonId, score); + } + + @Post(':courseId/review') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Leave a review' }) + async createReview( + @Param('courseId') courseId: string, + @Body() body: { rating: number; title?: string; content?: string }, + @CurrentUser() user: User, + ): Promise { + return this.enrollmentService.createReview(user.id, courseId, body.rating, body.title, body.content); + } + + @Get(':courseId/reviews') + @ApiOperation({ summary: 'Get course reviews' }) + async getReviews( + @Param('courseId') courseId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ): Promise { + return this.enrollmentService.getCourseReviews(courseId, page, limit); + } +} diff --git a/apps/api/src/enrollment/enrollment.module.ts b/apps/api/src/enrollment/enrollment.module.ts new file mode 100644 index 0000000..0742ccc --- /dev/null +++ b/apps/api/src/enrollment/enrollment.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { EnrollmentController } from './enrollment.controller'; +import { EnrollmentService } from './enrollment.service'; + +@Module({ + controllers: [EnrollmentController], + providers: [EnrollmentService], + exports: [EnrollmentService], +}) +export class EnrollmentModule {} diff --git a/apps/api/src/enrollment/enrollment.service.ts b/apps/api/src/enrollment/enrollment.service.ts new file mode 100644 index 0000000..32bc951 --- /dev/null +++ b/apps/api/src/enrollment/enrollment.service.ts @@ -0,0 +1,140 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; + +@Injectable() +export class EnrollmentService { + constructor(private prisma: PrismaService) {} + + async enroll(userId: string, courseId: string): Promise { + const existing = await this.prisma.enrollment.findUnique({ + where: { userId_courseId: { userId, courseId } }, + }); + if (existing) throw new ConflictException('Already enrolled'); + + return this.prisma.enrollment.create({ + data: { userId, courseId }, + include: { course: { select: { id: true, title: true, slug: true } } }, + }); + } + + async getUserEnrollments(userId: string): Promise { + return this.prisma.enrollment.findMany({ + where: { userId }, + include: { + course: { + include: { + author: { select: { id: true, name: true, avatarUrl: true } }, + _count: { select: { chapters: true } }, + chapters: { include: { _count: { select: { lessons: true } } } }, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async completeLesson(userId: string, courseId: string, lessonId: string): Promise { + const enrollment = await this.prisma.enrollment.findUnique({ + where: { userId_courseId: { userId, courseId } }, + }); + if (!enrollment) throw new NotFoundException('Not enrolled in this course'); + + const progress = await this.prisma.lessonProgress.upsert({ + where: { userId_lessonId: { userId, lessonId } }, + create: { + userId, + enrollmentId: enrollment.id, + lessonId, + completedAt: new Date(), + }, + update: { + completedAt: new Date(), + }, + }); + + await this.recalculateProgress(enrollment.id, courseId); + return progress; + } + + async saveQuizScore(userId: string, courseId: string, lessonId: string, score: number): Promise { + const enrollment = await this.prisma.enrollment.findUnique({ + where: { userId_courseId: { userId, courseId } }, + }); + if (!enrollment) throw new NotFoundException('Not enrolled in this course'); + + return this.prisma.lessonProgress.upsert({ + where: { userId_lessonId: { userId, lessonId } }, + create: { + userId, + enrollmentId: enrollment.id, + lessonId, + quizScore: score, + completedAt: new Date(), + }, + update: { quizScore: score }, + }); + } + + async getProgress(userId: string, courseId: string): Promise { + return this.prisma.enrollment.findUnique({ + where: { userId_courseId: { userId, courseId } }, + include: { lessons: true }, + }); + } + + async createReview(userId: string, courseId: string, rating: number, title?: string, content?: string): Promise { + const review = await this.prisma.review.upsert({ + where: { userId_courseId: { userId, courseId } }, + create: { userId, courseId, rating, title, content }, + update: { rating, title, content }, + }); + + // Recalculate avg rating + const result = await this.prisma.review.aggregate({ + where: { courseId, isApproved: true }, + _avg: { rating: true }, + }); + if (result._avg.rating !== null) { + await this.prisma.course.update({ + where: { id: courseId }, + data: { averageRating: result._avg.rating }, + }); + } + + return review; + } + + async getCourseReviews(courseId: string, page = 1, limit = 20): Promise { + const skip = (page - 1) * limit; + const [reviews, total] = await Promise.all([ + this.prisma.review.findMany({ + where: { courseId, isApproved: true }, + include: { user: { select: { id: true, name: true, avatarUrl: true } } }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + this.prisma.review.count({ where: { courseId, isApproved: true } }), + ]); + return { data: reviews, meta: { page, limit, total } }; + } + + private async recalculateProgress(enrollmentId: string, courseId: string): Promise { + const totalLessons = await this.prisma.lesson.count({ + where: { chapter: { courseId } }, + }); + const completedLessons = await this.prisma.lessonProgress.count({ + where: { enrollmentId, completedAt: { not: null } }, + }); + + const progress = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; + + await this.prisma.enrollment.update({ + where: { id: enrollmentId }, + data: { + progress, + completedAt: progress >= 100 ? new Date() : null, + }, + }); + } +} diff --git a/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx b/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx new file mode 100644 index 0000000..d27197a --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx @@ -0,0 +1,297 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { + ArrowLeft, + BookOpen, + Clock, + Users, + Star, + Shield, + Check, + Loader2, + ChevronDown, +} from 'lucide-react'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { api } from '@/lib/api'; +import { useAuth } from '@/contexts/auth-context'; +import { useToast } from '@/components/ui/use-toast'; +import { cn } from '@/lib/utils'; + +export default function PublicCoursePage() { + const params = useParams(); + const router = useRouter(); + const { toast } = useToast(); + const { user } = useAuth(); + const id = params?.id as string; + + const [course, setCourse] = useState(null); + const [reviews, setReviews] = useState([]); + const [loading, setLoading] = useState(true); + const [enrolling, setEnrolling] = useState(false); + const [enrolled, setEnrolled] = useState(false); + + useEffect(() => { + if (!id) return; + (async () => { + setLoading(true); + try { + const [courseData, reviewsData] = await Promise.all([ + api.getPublicCourse(id), + api.getCourseReviews(id).catch(() => ({ data: [] })), + ]); + setCourse(courseData); + setReviews(reviewsData.data || []); + + // Check if already enrolled + if (user) { + const enrollments = await api.getMyEnrollments().catch(() => []); + setEnrolled(enrollments.some((e: any) => e.course.id === id)); + } + } catch { + setCourse(null); + } finally { + setLoading(false); + } + })(); + }, [id, user]); + + const handleEnroll = async () => { + if (!id || enrolling) return; + setEnrolling(true); + try { + await api.enrollInCourse(id); + toast({ title: 'Успех', description: 'Вы записались на курс' }); + router.push(`/dashboard/courses/${id}`); + } catch (e: any) { + toast({ title: 'Ошибка', description: e.message, variant: 'destructive' }); + } finally { + setEnrolling(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!course) { + return ( +
+

Курс не найден

+ + Вернуться к каталогу + +
+ ); + } + + const totalLessons = course.chapters.reduce((acc: number, ch: any) => acc + ch.lessons.length, 0); + + return ( +
+ {/* Header */} + + + {/* Course header */} +
+
+ {/* Cover */} +
+ {course.coverImage ? ( + {course.title} + ) : ( +
+ +
+ )} +
+ + {/* Title & meta */} +
+
+ {course.isVerified && ( +
+ + Проверен автором +
+ )} + {course.difficulty && ( + + {course.difficulty === 'beginner' ? 'Начинающий' : course.difficulty === 'intermediate' ? 'Средний' : 'Продвинутый'} + + )} +
+

{course.title}

+

{course.description}

+
+ + {/* Stats */} +
+ {course.averageRating && ( +
+ + {course.averageRating.toFixed(1)} ({course._count.reviews} отзывов) +
+ )} + + + {course._count.enrollments} студентов + + {totalLessons} уроков + {course.estimatedHours && ( + + + {course.estimatedHours}ч + + )} +
+ +
+ + {/* Author */} +
+
+ {course.author.name?.[0] || 'A'} +
+
+

{course.author.name || 'Автор'}

+

Преподаватель

+
+
+
+ + {/* Sidebar: Enroll / Price */} +
+ + +
+

+ {course.price ? `${course.price} ${course.currency}` : 'Бесплатно'} +

+
+ {enrolled ? ( + + ) : ( + + )} +
+
+ + {course.chapters.length} глав +
+
+ + {totalLessons} видеоуроков +
+
+ + Сертификат по окончании +
+
+ + Пожизненный доступ +
+
+
+
+
+
+ + {/* Course content (chapters & lessons) */} + + +

Содержание курса

+
+ {course.chapters.map((chapter: any) => { + const [expanded, setExpanded] = useState(false); + return ( +
+ + {expanded && ( +
+ {chapter.lessons.map((lesson: any) => ( +
+ + {lesson.title} + {lesson.durationMinutes && ( + {lesson.durationMinutes} мин + )} +
+ ))} +
+ )} +
+ ); + })} +
+
+
+ + {/* Reviews */} + {reviews.length > 0 && ( + + +

Отзывы студентов

+
+ {reviews.map((review: any) => ( +
+
+
+ {review.user.name?.[0] || 'U'} +
+
+

{review.user.name || 'Пользователь'}

+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+ {review.title &&

{review.title}

} + {review.content &&

{review.content}

} +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/catalog/page.tsx b/apps/web/src/app/(dashboard)/dashboard/catalog/page.tsx new file mode 100644 index 0000000..7b6a904 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/catalog/page.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Search, Star, Users, BookOpen, Shield, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { api } from '@/lib/api'; +import { useAuth } from '@/contexts/auth-context'; +import { cn } from '@/lib/utils'; + +interface CatalogCourse { + id: string; + title: string; + description: string | null; + coverImage: string | null; + difficulty: string | null; + price: number | null; + currency: string; + isVerified: boolean; + averageRating: number | null; + enrollmentCount: number; + reviewCount: number; + chaptersCount: number; + lessonsCount: number; + author: { id: string; name: string | null; avatarUrl: string | null }; +} + +const difficultyLabels: Record = { + beginner: { label: 'Начинающий', color: 'text-green-600 bg-green-50 dark:bg-green-900/30' }, + intermediate: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30' }, + advanced: { label: 'Продвинутый', color: 'text-red-600 bg-red-50 dark:bg-red-900/30' }, +}; + +export default function CatalogPage() { + const { loading: authLoading } = useAuth(); + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [difficulty, setDifficulty] = useState(''); + + const loadCourses = async () => { + setLoading(true); + try { + const result = await api.getCatalog({ + search: searchQuery || undefined, + difficulty: difficulty || undefined, + }); + setCourses(result.data); + } catch { + // silent + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (authLoading) return; + loadCourses(); + }, [authLoading, difficulty]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + loadCourses(); + }; + + return ( +
+
+

Каталог курсов

+

Изучайте курсы от других авторов

+
+ + {/* Search & filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ +
+
+ {['', 'beginner', 'intermediate', 'advanced'].map((d) => ( + + ))} +
+
+ + {/* Courses grid */} + {loading ? ( +
+ +
+ ) : courses.length === 0 ? ( +
+ +

Пока нет опубликованных курсов

+
+ ) : ( +
+ {courses.map((course) => ( + + + {/* Cover image */} +
+ {course.coverImage ? ( + + ) : ( +
+ +
+ )} + {course.isVerified && ( +
+ + Проверен +
+ )} + {course.difficulty && difficultyLabels[course.difficulty] && ( +
+ {difficultyLabels[course.difficulty].label} +
+ )} +
+ + +

+ {course.title} +

+ {course.description && ( +

{course.description}

+ )} + + {/* Author */} +

+ {course.author.name || 'Автор'} +

+ + {/* Stats */} +
+ {course.averageRating && ( + + + {course.averageRating.toFixed(1)} + + )} + + + {course.enrollmentCount} + + {course.lessonsCount} уроков +
+ + {/* Price */} +
+ + {course.price ? `${course.price} ${course.currency}` : 'Бесплатно'} + +
+
+
+ + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx index 18ff9ea..ac32303 100644 --- a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/edit/page.tsx @@ -3,7 +3,8 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { ChevronLeft, ChevronRight, Save, Wand2, Eye, Pencil } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Save, Wand2, Eye, Pencil, Upload, Shield } from 'lucide-react'; +import { useToast } from '@/components/ui/use-toast'; import { Button } from '@/components/ui/button'; import { CourseEditor } from '@/components/editor/course-editor'; import { LessonContentViewer } from '@/components/editor/lesson-content-viewer'; @@ -20,6 +21,7 @@ const emptyDoc = { type: 'doc', content: [] }; export default function CourseEditPage() { const params = useParams(); + const { toast } = useToast(); const { loading: authLoading } = useAuth(); const courseId = params?.id as string; @@ -33,6 +35,8 @@ export default function CourseEditPage() { const [contentLoading, setContentLoading] = useState(false); const [saving, setSaving] = useState(false); const [readOnly, setReadOnly] = useState(false); + const [publishing, setPublishing] = useState(false); + const [verifying, setVerifying] = useState(false); useEffect(() => { if (!courseId || authLoading) return; @@ -104,13 +108,42 @@ export default function CourseEditPage() { setSaving(true); try { await api.updateLesson(courseId, activeLesson.lessonId, { content }); + toast({ title: 'Сохранено', description: 'Изменения успешно сохранены' }); } catch (e: any) { - console.error('Save failed:', e); + toast({ title: 'Ошибка', description: 'Не удалось сохранить', variant: 'destructive' }); } finally { setSaving(false); } }; + const handlePublish = async () => { + if (!courseId || publishing) return; + setPublishing(true); + try { + await api.publishCourse(courseId); + toast({ title: 'Опубликовано', description: 'Курс теперь доступен в каталоге' }); + window.location.reload(); + } catch (e: any) { + toast({ title: 'Ошибка', description: e.message, variant: 'destructive' }); + } finally { + setPublishing(false); + } + }; + + const handleToggleVerify = async () => { + if (!courseId || verifying) return; + setVerifying(true); + try { + await api.toggleCourseVerification(courseId); + toast({ title: 'Готово', description: 'Статус верификации обновлён' }); + window.location.reload(); + } catch (e: any) { + toast({ title: 'Ошибка', description: e.message, variant: 'destructive' }); + } finally { + setVerifying(false); + } + }; + if (authLoading || loading) { return (
@@ -196,6 +229,14 @@ export default function CourseEditPage() { {saving ? 'Сохранение...' : 'Сохранить'} + + )}
diff --git a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx index b73e1be..e844c1b 100644 --- a/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx @@ -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(null); const [deleting, setDeleting] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(true); const [selectedLessonId, setSelectedLessonId] = useState(null); const [lessonContent, setLessonContent] = useState | null>(null); const [lessonContentLoading, setLessonContentLoading] = useState(false); + const [completedLessons, setCompletedLessons] = useState>(new Set()); + const [expandedChapters, setExpandedChapters] = useState([]); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [enrollmentProgress, setEnrollmentProgress] = useState(null); + const [showQuiz, setShowQuiz] = useState(false); + const [quizQuestions, setQuizQuestions] = useState([]); + // 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 = 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 (
-

Загрузка курса...

+
+
+

Загрузка курса...

+
); } @@ -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 (
{/* Top bar */} -
+
- / - {course.title} +
+
+ + {course.title} +
- - @@ -185,48 +293,226 @@ export default function CoursePage() {
- {/* Left: list of lessons (paragraphs) */} + {/* ─── Left sidebar: course navigation ─── */}
{sidebarOpen && ( - +
+ {/* Course progress */} +
+
+ Прогресс курса + {completedCount}/{totalLessons} +
+ +
+ + {/* Chapters & lessons */} +
+ {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 ( +
+ + + {isExpanded && ( +
+ {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 ( + + ); + })} +
+ )} +
+ ); + })} +
+
)}
+ {/* Sidebar toggle */} - {/* Center: lesson content (read-only) */} -
-
- {activeLessonTitle && ( -

{activeLessonTitle}

- )} - {lessonContentLoading ? ( -

Загрузка...

- ) : selectedLessonId ? ( - - ) : ( -

Выберите урок в списке слева.

- )} + {/* ─── Main content area ─── */} +
+ {/* Lesson content */} +
+
+ {/* Chapter & lesson header */} + {activeLessonMeta && ( +
+

+ {activeLessonMeta.chapterTitle} +

+

+ {activeLessonMeta.title} +

+
+ + + Урок {currentLessonIndex + 1} из {totalLessons} + + {activeLessonMeta.durationMinutes && ( + + + {activeLessonMeta.durationMinutes} мин + + )} +
+
+
+ )} + + {/* Content */} + {lessonContentLoading ? ( +
+
+
+ ) : selectedLessonId ? ( + <> + + {!showQuiz && !completedLessons.has(selectedLessonId) && ( +
+

Проверьте свои знания

+

+ Пройдите тест, чтобы закрепить материал и получить сертификат +

+ +
+ )} + {showQuiz && ( + + )} + + ) : ( +
+ +

Выберите урок для начала обучения

+
+ )} +
+
+ + {/* Bottom navigation */} +
+
+ + + + + {currentLessonIndex < flatLessons.length - 1 ? ( + + ) : ( + + )} +
diff --git a/apps/web/src/app/(dashboard)/dashboard/learning/page.tsx b/apps/web/src/app/(dashboard)/dashboard/learning/page.tsx new file mode 100644 index 0000000..c9964ad --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/learning/page.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { GraduationCap, BookOpen, Loader2, Trophy } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { api } from '@/lib/api'; +import { useAuth } from '@/contexts/auth-context'; + +interface EnrollmentData { + id: string; + progress: number; + completedAt: string | null; + course: { + id: string; + title: string; + description: string | null; + coverImage: string | null; + author: { name: string | null }; + _count: { chapters: number }; + chapters: { _count: { lessons: number } }[]; + }; +} + +export default function LearningPage() { + const { loading: authLoading, user } = useAuth(); + const [enrollments, setEnrollments] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (authLoading || !user) { setLoading(false); return; } + (async () => { + try { + const data = await api.getMyEnrollments(); + setEnrollments(data); + } catch { /* silent */ } + finally { setLoading(false); } + })(); + }, [authLoading, user]); + + if (authLoading || loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Мои обучения

+

Курсы, на которые вы записаны

+
+ + {enrollments.length === 0 ? ( +
+ +

Пока нет записей

+

Найдите курсы в каталоге

+ + Открыть каталог + +
+ ) : ( +
+ {enrollments.map((enrollment) => { + const lessonsCount = enrollment.course.chapters.reduce( + (acc, ch) => acc + ch._count.lessons, 0 + ); + return ( + + +
+ {enrollment.course.coverImage ? ( + + ) : ( +
+ +
+ )} + {enrollment.completedAt && ( +
+ + Пройден +
+ )} +
+ +

{enrollment.course.title}

+

{enrollment.course.author.name}

+
+
+ {enrollment.progress}% + {lessonsCount} уроков +
+ +
+
+
+ + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 1d203fe..a15ebcc 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -104,3 +104,58 @@ html { ::selection { background: hsl(var(--primary) / 0.2); } + +/* ─── Course content typography ─── */ +.prose-course h1 { + @apply text-3xl font-bold mt-10 mb-4 pb-3 border-b border-border text-foreground; +} +.prose-course h2 { + @apply text-2xl font-semibold mt-8 mb-3 text-foreground; +} +.prose-course h3 { + @apply text-xl font-semibold mt-6 mb-2 text-foreground; +} +.prose-course p { + @apply leading-7 mb-4 text-foreground/90; +} +.prose-course ul { + @apply list-disc pl-6 mb-4 space-y-1; +} +.prose-course ol { + @apply list-decimal pl-6 mb-4 space-y-1; +} +.prose-course li { + @apply leading-7 text-foreground/90; +} +.prose-course li p { + @apply mb-1; +} +.prose-course blockquote { + @apply border-l-4 border-primary/50 bg-primary/5 pl-4 py-3 my-4 rounded-r-lg italic text-foreground/80; +} +.prose-course pre { + @apply rounded-xl bg-muted p-5 font-mono text-sm border my-4 overflow-x-auto; +} +.prose-course code:not(pre code) { + @apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-primary; +} +.prose-course hr { + @apply my-8 border-border; +} +.prose-course a { + @apply text-primary underline underline-offset-2 hover:text-primary/80 transition-colors; +} +.prose-course img { + @apply rounded-xl my-6 max-w-full shadow-sm; +} +.prose-course strong { + @apply font-semibold text-foreground; +} + +/* ProseMirror specific overrides for course content */ +.prose-course .ProseMirror { + @apply outline-none; +} +.prose-course .ProseMirror > *:first-child { + @apply mt-0; +} diff --git a/apps/web/src/components/dashboard/lesson-quiz.tsx b/apps/web/src/components/dashboard/lesson-quiz.tsx new file mode 100644 index 0000000..d82f9f0 --- /dev/null +++ b/apps/web/src/components/dashboard/lesson-quiz.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { CheckCircle2, XCircle, Trophy } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface QuizQuestion { + id: string; + question: string; + options: string[]; + correctAnswer: number; +} + +interface LessonQuizProps { + courseId: string; + lessonId: string; + questions: QuizQuestion[]; + onComplete: (score: number) => void; +} + +export function LessonQuiz({ courseId, lessonId, questions, onComplete }: LessonQuizProps) { + const [currentQuestion, setCurrentQuestion] = useState(0); + const [answers, setAnswers] = useState([]); + const [showResults, setShowResults] = useState(false); + const [selectedAnswer, setSelectedAnswer] = useState(null); + + const handleSelectAnswer = (optionIndex: number) => { + setSelectedAnswer(optionIndex); + }; + + const handleNext = () => { + if (selectedAnswer === null) return; + const newAnswers = [...answers, selectedAnswer]; + setAnswers(newAnswers); + + if (currentQuestion < questions.length - 1) { + setCurrentQuestion(currentQuestion + 1); + setSelectedAnswer(null); + } else { + const correct = newAnswers.filter((ans, idx) => ans === questions[idx].correctAnswer).length; + const score = Math.round((correct / questions.length) * 100); + setShowResults(true); + onComplete(score); + } + }; + + if (questions.length === 0) { + return null; + } + + if (showResults) { + const correct = answers.filter((ans, idx) => ans === questions[idx].correctAnswer).length; + const score = Math.round((correct / questions.length) * 100); + const passed = score >= 70; + + return ( + + +
+ {passed ? ( + + ) : ( + + )} +
+

+ {passed ? 'Отлично!' : 'Неплохо!'} +

+

+ Ваш результат: {correct} из {questions.length} ({score}%) +

+
+ {questions.map((q, idx) => { + const isCorrect = answers[idx] === q.correctAnswer; + return ( +
+ {isCorrect ? ( + + ) : ( + + )} + Вопрос {idx + 1} +
+ ); + })} +
+
+
+ ); + } + + const question = questions[currentQuestion]; + + return ( + + + + Тест по уроку ({currentQuestion + 1}/{questions.length}) + + + +

{question.question}

+
+ {question.options.map((option, idx) => ( + + ))} +
+ +
+
+ ); +} diff --git a/apps/web/src/components/dashboard/sidebar.tsx b/apps/web/src/components/dashboard/sidebar.tsx index 17ea23c..081a5cf 100644 --- a/apps/web/src/components/dashboard/sidebar.tsx +++ b/apps/web/src/components/dashboard/sidebar.tsx @@ -6,18 +6,20 @@ import { Sparkles, LayoutDashboard, BookOpen, + GraduationCap, + Compass, Settings, CreditCard, Plus, - Search, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; const navigation = [ - { name: 'Обзор', href: '/dashboard', icon: LayoutDashboard }, - { name: 'Мои курсы', href: '/dashboard', icon: BookOpen }, - { name: 'Поиск', href: '/dashboard/search', icon: Search }, + { name: 'Обзор', href: '/dashboard', icon: LayoutDashboard, exact: true }, + { name: 'Мои курсы', href: '/dashboard', icon: BookOpen, exact: true }, + { name: 'Каталог', href: '/dashboard/catalog', icon: Compass }, + { name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap }, ]; const bottomNavigation = [ @@ -53,7 +55,9 @@ export function Sidebar() { {/* Main navigation */}