Compare commits
6 Commits
dab726e8d1
...
c809d049fe
| Author | SHA1 | Date | |
|---|---|---|---|
| c809d049fe | |||
| 5241144bc5 | |||
| f39680d714 | |||
| bed7e440c1 | |||
| 5ddb3db1ac | |||
| 2ed65f5678 |
@ -143,10 +143,10 @@ export class OpenRouterProvider {
|
|||||||
Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы,
|
Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы,
|
||||||
чтобы лучше понять его потребности и создать максимально релевантный курс.
|
чтобы лучше понять его потребности и создать максимально релевантный курс.
|
||||||
|
|
||||||
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса:
|
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса (важно для длины):
|
||||||
- Короткий (2-4 главы, введение в тему)
|
- Короткий (3-4 главы, по 2-4 урока — только введение в тему)
|
||||||
- Средний (4-7 глав, хорошее покрытие)
|
- Средний (5-7 глав, по 4-6 уроков — полноценное покрытие)
|
||||||
- Длинный / полный (6-12 глав, глубокое погружение)
|
- Длинный / полный (7-12 глав, по 5-8 уроков — глубокое погружение, рекомендуемый вариант для серьёзного обучения)
|
||||||
|
|
||||||
Остальные вопросы: целевая аудитория, глубина материала, специфические темы.
|
Остальные вопросы: целевая аудитория, глубина материала, специфические темы.
|
||||||
|
|
||||||
@ -198,13 +198,13 @@ export class OpenRouterProvider {
|
|||||||
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
|
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
|
||||||
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
|
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
|
||||||
|
|
||||||
ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя):
|
ОБЪЁМ КУРСА (соблюдай по ответам; при сомнении выбирай более полный вариант):
|
||||||
- Если пользователь выбрал короткий курс / введение: 2–4 главы, в каждой 2–4 урока. estimatedTotalHours: 2–8.
|
- Короткий / введение: не менее 3 глав, в каждой по 3–4 урока. estimatedTotalHours: 4–10.
|
||||||
- Если средний курс: 4–7 глав, в каждой 3–5 уроков. estimatedTotalHours: 8–20.
|
- Средний: 5–7 глав, в каждой по 4–6 уроков. estimatedTotalHours: 10–25.
|
||||||
- Если длинный / полный курс: 6–12 глав, в каждой 4–8 уроков. estimatedTotalHours: 15–40.
|
- Длинный / полный: 7–12 глав, в каждой по 5–8 уроков. estimatedTotalHours: 20–45.
|
||||||
- Если объём не указан — предложи средний (4–6 глав по 3–5 уроков).
|
- Если объём не указан — делай средний или длинный: 5–7 глав, по 4–6 уроков в главе (не менее 25 уроков в курсе).
|
||||||
|
|
||||||
Укажи примерное время на каждый урок (estimatedMinutes: 10–45). estimatedTotalHours = сумма уроков.
|
Укажи примерное время на каждый урок (estimatedMinutes: 15–45, чаще 20–35). estimatedTotalHours = сумма уроков.
|
||||||
Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги.
|
Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги.
|
||||||
|
|
||||||
Ответь в формате JSON со структурой:
|
Ответь в формате JSON со структурой:
|
||||||
@ -281,32 +281,27 @@ ${Object.entries(answers)
|
|||||||
log.request('generateLessonContent', model);
|
log.request('generateLessonContent', model);
|
||||||
log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`);
|
log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`);
|
||||||
|
|
||||||
const systemPrompt = `Ты - эксперт по созданию образовательного контента.
|
const systemPrompt = `Ты - эксперт по созданию образовательного контента. Пиши ПОЛНЫЙ, ПОДРОБНЫЙ материал урока — не поверхностный обзор, а глубокое раскрытие темы с объяснениями и примерами.
|
||||||
Пиши содержание урока СРАЗУ в форматированном виде — в формате TipTap JSON. Каждый блок контента должен быть правильным узлом TipTap (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, image).
|
|
||||||
|
|
||||||
ФОРМАТИРОВАНИЕ (используй обязательно):
|
ГЛАВНОЕ ТРЕБОВАНИЕ — СОДЕРЖАТЕЛЬНОСТЬ:
|
||||||
- Заголовки: { "type": "heading", "attrs": { "level": 1|2|3 }, "content": [{ "type": "text", "text": "..." }] }
|
- Материал должен быть полным и подробным: объясняй понятия по шагам, раскрывай причины и следствия, давай контекст.
|
||||||
- Параграфы: { "type": "paragraph", "content": [{ "type": "text", "text": "..." }] } — для выделения используй "marks": [{ "type": "bold" }] или [{ "type": "italic" }]
|
- Обязательно включай практические примеры: код, числа, сценарии использования. Без примеров урок считается неполным.
|
||||||
- Списки: bulletList > listItem > paragraph; orderedList > listItem > paragraph
|
- Описывай не только "что", но и "зачем" и "как": типичные ошибки, лучшие практики, нюансы.
|
||||||
- Цитаты: { "type": "blockquote", "content": [{ "type": "paragraph", "content": [...] }] }
|
- Каждую важную мысль подкрепляй пояснением или примером. Избегай перечисления фактов без раскрытия.
|
||||||
- Код: { "type": "codeBlock", "attrs": { "language": "javascript"|"python"|"text" }, "content": [{ "type": "text", "text": "код" }] }
|
|
||||||
- Mermaid-диаграммы: { "type": "codeBlock", "attrs": { "language": "mermaid" }, "content": [{ "type": "text", "text": "graph LR\\n A --> B" }] } — вставляй где уместно (схемы, процессы, связи)
|
|
||||||
- Картинки не генерируй (src нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения
|
|
||||||
|
|
||||||
ОБЪЁМ: зависит от темы. Короткий урок: 300–600 слов (3–5 блоков). Средний: 600–1200 слов. Длинный: 1200–2500 слов. Структура: заголовок, введение, 2–4 секции с подзаголовками, примеры/код/списки, резюме.
|
СТРУКТУРА УРОКА (соблюдай):
|
||||||
|
- Заголовок (h1), краткое введение в тему (2–3 абзаца).
|
||||||
|
- 4–7 смысловых секций с подзаголовками (h2/h3). В каждой секции: развёрнутый текст, при необходимости списки, примеры, блоки кода.
|
||||||
|
- Примеры и код: минимум 1–2 рабочих примера на урок (codeBlock с пояснением до/после). Для технических тем — больше.
|
||||||
|
- Резюме или выводы в конце (что важно запомнить, как применить).
|
||||||
|
|
||||||
|
ФОРМАТ — TipTap JSON (heading, paragraph, bulletList, orderedList, blockquote, codeBlock с language). Mermaid — где уместно (схемы, процессы). Картинки не выдумывай.
|
||||||
|
|
||||||
|
ОБЪЁМ: не менее 1000–1500 слов на урок. Сложные темы — 1800–3000 слов. Короткие абзацы из 1–2 предложений без примеров запрещены.
|
||||||
|
|
||||||
Уровень: ${context.difficulty}. ${context.targetAudience ? `Аудитория: ${context.targetAudience}` : ''}
|
Уровень: ${context.difficulty}. ${context.targetAudience ? `Аудитория: ${context.targetAudience}` : ''}
|
||||||
|
|
||||||
Ответь только валидным JSON:
|
Ответь только валидным JSON: { "content": { "type": "doc", "content": [ ... ] } }`;
|
||||||
{
|
|
||||||
"content": {
|
|
||||||
"type": "doc",
|
|
||||||
"content": [
|
|
||||||
{ "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Заголовок" }] },
|
|
||||||
{ "type": "paragraph", "content": [{ "type": "text", "text": "Текст..." }] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
return this.withRetry(async () => {
|
return this.withRetry(async () => {
|
||||||
const response = await this.client.chat.completions.create({
|
const response = await this.client.chat.completions.create({
|
||||||
@ -319,7 +314,7 @@ ${Object.entries(answers)
|
|||||||
Глава: "${chapterTitle}"
|
Глава: "${chapterTitle}"
|
||||||
Урок: "${lessonTitle}"
|
Урок: "${lessonTitle}"
|
||||||
|
|
||||||
Создай содержание урока сразу в TipTap JSON: заголовки (heading), параграфы (paragraph), списки (bulletList/orderedList), цитаты (blockquote), блоки кода (codeBlock) и при необходимости Mermaid-диаграммы (codeBlock с "language": "mermaid"). Объём — по теме урока (короткий/средний/длинный).`,
|
Создай полный и подробный урок в TipTap JSON. Обязательно: развёрнутые объяснения, минимум 1–2 примера или блока кода с пояснениями, описание нюансов и практические советы. Не пиши поверхностно — материал должен быть глубоким и пригодным для самостоятельного изучения. Объём не менее 1000–1500 слов.`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
@ -5,6 +5,12 @@ import { join } from 'path';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { CoursesModule } from './courses/courses.module';
|
import { CoursesModule } from './courses/courses.module';
|
||||||
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
|
import { EnrollmentModule } from './enrollment/enrollment.module';
|
||||||
|
import { CertificatesModule } from './certificates/certificates.module';
|
||||||
|
import { GroupsModule } from './groups/groups.module';
|
||||||
|
import { SupportModule } from './support/support.module';
|
||||||
|
import { ModerationModule } from './moderation/moderation.module';
|
||||||
import { GenerationModule } from './generation/generation.module';
|
import { GenerationModule } from './generation/generation.module';
|
||||||
import { PaymentsModule } from './payments/payments.module';
|
import { PaymentsModule } from './payments/payments.module';
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
@ -38,6 +44,12 @@ import { PrismaModule } from './common/prisma/prisma.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
CoursesModule,
|
CoursesModule,
|
||||||
|
CatalogModule,
|
||||||
|
EnrollmentModule,
|
||||||
|
CertificatesModule,
|
||||||
|
GroupsModule,
|
||||||
|
SupportModule,
|
||||||
|
ModerationModule,
|
||||||
GenerationModule,
|
GenerationModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
|||||||
46
apps/api/src/catalog/catalog.controller.ts
Normal file
46
apps/api/src/catalog/catalog.controller.ts
Normal file
@ -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<any> {
|
||||||
|
return this.catalogService.getPublishedCourses({ page, limit, search, difficulty });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get public course details' })
|
||||||
|
async getCourse(@Param('id') id: string): Promise<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
return this.catalogService.toggleVerification(id, user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/catalog/catalog.module.ts
Normal file
10
apps/api/src/catalog/catalog.module.ts
Normal file
@ -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 {}
|
||||||
122
apps/api/src/catalog/catalog.service.ts
Normal file
122
apps/api/src/catalog/catalog.service.ts
Normal file
@ -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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
return this.prisma.course.update({
|
||||||
|
where: { id: courseId, authorId: userId },
|
||||||
|
data: { status: CourseStatus.PENDING_REVIEW },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishCourse(courseId: string, userId: string): Promise<any> {
|
||||||
|
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<any> {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/api/src/certificates/certificates.controller.ts
Normal file
24
apps/api/src/certificates/certificates.controller.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { CertificatesService } from './certificates.service';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { User } from '@coursecraft/database';
|
||||||
|
|
||||||
|
@ApiTags('certificates')
|
||||||
|
@Controller('certificates')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class CertificatesController {
|
||||||
|
constructor(private certificatesService: CertificatesService) {}
|
||||||
|
|
||||||
|
@Get(':courseId/data')
|
||||||
|
@ApiOperation({ summary: 'Get certificate data for display/print page' })
|
||||||
|
async getCertificateData(@Param('courseId') courseId: string, @CurrentUser() user: User) {
|
||||||
|
return this.certificatesService.getCertificateData(user.id, courseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':courseId')
|
||||||
|
@ApiOperation({ summary: 'Generate certificate for completed course' })
|
||||||
|
async getCertificate(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.certificatesService.generateCertificate(user.id, courseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/certificates/certificates.module.ts
Normal file
10
apps/api/src/certificates/certificates.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CertificatesController } from './certificates.controller';
|
||||||
|
import { CertificatesService } from './certificates.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CertificatesController],
|
||||||
|
providers: [CertificatesService],
|
||||||
|
exports: [CertificatesService],
|
||||||
|
})
|
||||||
|
export class CertificatesModule {}
|
||||||
148
apps/api/src/certificates/certificates.service.ts
Normal file
148
apps/api/src/certificates/certificates.service.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../common/prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CertificatesService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getCertificateData(userId: string, courseId: string): Promise<{
|
||||||
|
userName: string;
|
||||||
|
courseTitle: string;
|
||||||
|
completedAt: string;
|
||||||
|
}> {
|
||||||
|
const enrollment = await this.prisma.enrollment.findUnique({
|
||||||
|
where: { userId_courseId: { userId, courseId } },
|
||||||
|
include: { course: true, user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enrollment) throw new NotFoundException('Not enrolled');
|
||||||
|
if (!enrollment.completedAt) throw new Error('Course not completed yet');
|
||||||
|
|
||||||
|
return {
|
||||||
|
userName: enrollment.user.name || enrollment.user.email || 'Слушатель',
|
||||||
|
courseTitle: enrollment.course.title,
|
||||||
|
completedAt: new Date(enrollment.completedAt).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateCertificate(userId: string, courseId: string): Promise<any> {
|
||||||
|
const data = await this.getCertificateData(userId, courseId);
|
||||||
|
const completionDate = new Date(data.completedAt);
|
||||||
|
|
||||||
|
const certificateHtml = this.renderCertificateHTML(
|
||||||
|
data.userName,
|
||||||
|
data.courseTitle,
|
||||||
|
completionDate
|
||||||
|
);
|
||||||
|
|
||||||
|
const certificateUrl = `data:text/html;base64,${Buffer.from(certificateHtml).toString('base64')}`;
|
||||||
|
|
||||||
|
await this.prisma.enrollment.update({
|
||||||
|
where: { userId_courseId: { userId, courseId } },
|
||||||
|
data: { certificateUrl },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { certificateUrl, html: certificateHtml };
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCertificateHTML(userName: string, courseTitle: string, completionDate: Date): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Сертификат - ${courseTitle}</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.certificate {
|
||||||
|
background: white;
|
||||||
|
width: 1000px;
|
||||||
|
padding: 80px 100px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
|
border: 12px solid #f8f9fa;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.certificate::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 30px;
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: #667eea;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 40px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.recipient {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 36px;
|
||||||
|
color: #1a202c;
|
||||||
|
margin: 40px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.course-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #4a5568;
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
text-align: center;
|
||||||
|
color: #718096;
|
||||||
|
margin-top: 60px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate">
|
||||||
|
<div class="logo">✨ CourseCraft</div>
|
||||||
|
<h1>Сертификат</h1>
|
||||||
|
<h2>о прохождении курса</h2>
|
||||||
|
<div style="text-align: center; margin: 40px 0;">
|
||||||
|
<div style="font-size: 18px; color: #718096; margin-bottom: 10px;">Настоящий сертификат подтверждает, что</div>
|
||||||
|
<div class="recipient">${userName}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<div style="font-size: 18px; color: #718096; margin-bottom: 10px;">успешно завершил(а) курс</div>
|
||||||
|
<div class="course-title">${courseTitle}</div>
|
||||||
|
</div>
|
||||||
|
<div class="date">
|
||||||
|
Дата выдачи: ${completionDate.toLocaleDateString('ru-RU', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -64,4 +64,10 @@ export class LessonsController {
|
|||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return this.lessonsService.reorder(chapterId, user.id, lessonIds);
|
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<any> {
|
||||||
|
return this.lessonsService.generateQuiz(lessonId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,4 +138,134 @@ export class LessonsService {
|
|||||||
orderBy: { order: 'asc' },
|
orderBy: { order: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateQuiz(lessonId: string): Promise<any> {
|
||||||
|
const lesson = await this.prisma.lesson.findUnique({
|
||||||
|
where: { id: lessonId },
|
||||||
|
include: { quiz: true },
|
||||||
|
});
|
||||||
|
if (!lesson) throw new NotFoundException('Lesson not found');
|
||||||
|
|
||||||
|
// Return cached quiz if exists
|
||||||
|
if (lesson.quiz) {
|
||||||
|
return { questions: lesson.quiz.questions };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate quiz using AI
|
||||||
|
const questions = await this.generateQuizWithAI(lesson.content, lesson.title);
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
await this.prisma.quiz.create({
|
||||||
|
data: { lessonId, questions: questions as any },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { questions };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateQuizWithAI(content: any, lessonTitle: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
// Extract text from TipTap JSON content
|
||||||
|
const textContent = this.extractTextFromContent(content);
|
||||||
|
if (!textContent || textContent.length < 50) {
|
||||||
|
// Not enough content, return simple quiz
|
||||||
|
return this.getDefaultQuiz();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call OpenRouter to generate quiz
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
const baseUrl = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
|
||||||
|
const model = process.env.AI_MODEL_FREE || 'openai/gpt-4o-mini';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return this.getDefaultQuiz();
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `На основе следующего содержания урока "${lessonTitle}" сгенерируй 3-5 вопросов с вариантами ответов для теста.
|
||||||
|
|
||||||
|
Содержание урока:
|
||||||
|
${textContent.slice(0, 3000)}
|
||||||
|
|
||||||
|
Верни JSON массив вопросов в формате:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"question": "Вопрос?",
|
||||||
|
"options": ["Вариант А", "Вариант Б", "Вариант В", "Вариант Г"],
|
||||||
|
"correctAnswer": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Только JSON, без markdown.`;
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: 0.7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return this.getDefaultQuiz();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
const aiResponse = data.choices?.[0]?.message?.content || '';
|
||||||
|
|
||||||
|
// Parse JSON from response
|
||||||
|
const jsonMatch = aiResponse.match(/\[[\s\S]*\]/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
const questions = JSON.parse(jsonMatch[0]);
|
||||||
|
return questions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getDefaultQuiz();
|
||||||
|
} catch {
|
||||||
|
return this.getDefaultQuiz();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTextFromContent(content: any): string {
|
||||||
|
if (!content || typeof content !== 'object') return '';
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
const traverse = (node: any) => {
|
||||||
|
if (node.type === 'text' && node.text) {
|
||||||
|
text += node.text + ' ';
|
||||||
|
}
|
||||||
|
if (node.content && Array.isArray(node.content)) {
|
||||||
|
node.content.forEach(traverse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
traverse(content);
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultQuiz(): any[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
question: 'Вы изучили материал урока?',
|
||||||
|
options: ['Да, всё понятно', 'Частично', 'Нужно повторить', 'Нет'],
|
||||||
|
correctAnswer: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
question: 'Какие основные темы были рассмотрены?',
|
||||||
|
options: ['Теория и практика', 'Только теория', 'Только примеры', 'Не помню'],
|
||||||
|
correctAnswer: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
question: 'Готовы ли вы применить полученные знания?',
|
||||||
|
options: ['Да, готов', 'Нужна практика', 'Нужно повторить', 'Нет'],
|
||||||
|
correctAnswer: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
apps/api/src/enrollment/enrollment.controller.ts
Normal file
84
apps/api/src/enrollment/enrollment.controller.ts
Normal file
@ -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<any> {
|
||||||
|
return this.enrollmentService.enroll(user.id, courseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get my enrolled courses' })
|
||||||
|
async myEnrollments(@CurrentUser() user: User): Promise<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
return this.enrollmentService.getCourseReviews(courseId, page, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/enrollment/enrollment.module.ts
Normal file
10
apps/api/src/enrollment/enrollment.module.ts
Normal file
@ -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 {}
|
||||||
140
apps/api/src/enrollment/enrollment.service.ts
Normal file
140
apps/api/src/enrollment/enrollment.service.ts
Normal file
@ -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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/api/src/groups/groups.controller.ts
Normal file
32
apps/api/src/groups/groups.controller.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { GroupsService } from './groups.service';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { User } from '@coursecraft/database';
|
||||||
|
|
||||||
|
@ApiTags('groups')
|
||||||
|
@Controller('groups')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class GroupsController {
|
||||||
|
constructor(private groupsService: GroupsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createGroup(@Body() body: { courseId: string; name: string; description?: string }, @CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.groupsService.createGroup(body.courseId, user.id, body.name, body.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':groupId/members')
|
||||||
|
async addMember(@Param('groupId') groupId: string, @Body('userId') userId: string): Promise<any> {
|
||||||
|
return this.groupsService.addMember(groupId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':groupId/messages')
|
||||||
|
async getMessages(@Param('groupId') groupId: string, @CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.groupsService.getGroupMessages(groupId, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':groupId/messages')
|
||||||
|
async sendMessage(@Param('groupId') groupId: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.groupsService.sendMessage(groupId, user.id, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/groups/groups.module.ts
Normal file
10
apps/api/src/groups/groups.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GroupsController } from './groups.controller';
|
||||||
|
import { GroupsService } from './groups.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [GroupsController],
|
||||||
|
providers: [GroupsService],
|
||||||
|
exports: [GroupsService],
|
||||||
|
})
|
||||||
|
export class GroupsModule {}
|
||||||
48
apps/api/src/groups/groups.service.ts
Normal file
48
apps/api/src/groups/groups.service.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../common/prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GroupsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async createGroup(courseId: string, userId: string, name: string, description?: string): Promise<any> {
|
||||||
|
const course = await this.prisma.course.findFirst({ where: { id: courseId, authorId: userId } });
|
||||||
|
if (!course) throw new ForbiddenException('Only course author can create groups');
|
||||||
|
|
||||||
|
return this.prisma.courseGroup.create({
|
||||||
|
data: { courseId, name, description },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(groupId: string, userId: string, role = 'student'): Promise<any> {
|
||||||
|
return this.prisma.groupMember.create({
|
||||||
|
data: { groupId, userId, role },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMessages(groupId: string, userId: string): Promise<any> {
|
||||||
|
const member = await this.prisma.groupMember.findUnique({
|
||||||
|
where: { groupId_userId: { groupId, userId } },
|
||||||
|
});
|
||||||
|
if (!member) throw new ForbiddenException('Not a member of this group');
|
||||||
|
|
||||||
|
return this.prisma.groupMessage.findMany({
|
||||||
|
where: { groupId },
|
||||||
|
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(groupId: string, userId: string, content: string): Promise<any> {
|
||||||
|
const member = await this.prisma.groupMember.findUnique({
|
||||||
|
where: { groupId_userId: { groupId, userId } },
|
||||||
|
});
|
||||||
|
if (!member) throw new ForbiddenException('Not a member of this group');
|
||||||
|
|
||||||
|
return this.prisma.groupMessage.create({
|
||||||
|
data: { groupId, userId, content },
|
||||||
|
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/api/src/moderation/moderation.controller.ts
Normal file
27
apps/api/src/moderation/moderation.controller.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { ModerationService } from './moderation.service';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { User } from '@coursecraft/database';
|
||||||
|
|
||||||
|
@ApiTags('moderation')
|
||||||
|
@Controller('moderation')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class ModerationController {
|
||||||
|
constructor(private moderationService: ModerationService) {}
|
||||||
|
|
||||||
|
@Get('pending')
|
||||||
|
async getPendingCourses(@CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.moderationService.getPendingCourses(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':courseId/approve')
|
||||||
|
async approveCourse(@Param('courseId') courseId: string, @Body('note') note: string, @CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.moderationService.approveCourse(user.id, courseId, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':courseId/reject')
|
||||||
|
async rejectCourse(@Param('courseId') courseId: string, @Body('reason') reason: string, @CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.moderationService.rejectCourse(user.id, courseId, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/moderation/moderation.module.ts
Normal file
10
apps/api/src/moderation/moderation.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ModerationController } from './moderation.controller';
|
||||||
|
import { ModerationService } from './moderation.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ModerationController],
|
||||||
|
providers: [ModerationService],
|
||||||
|
exports: [ModerationService],
|
||||||
|
})
|
||||||
|
export class ModerationModule {}
|
||||||
58
apps/api/src/moderation/moderation.service.ts
Normal file
58
apps/api/src/moderation/moderation.service.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../common/prisma/prisma.service';
|
||||||
|
import { CourseStatus, UserRole } from '@coursecraft/database';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ModerationService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getPendingCourses(userId: string): Promise<any> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
|
||||||
|
throw new ForbiddenException('Moderators only');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.course.findMany({
|
||||||
|
where: { status: CourseStatus.PENDING_REVIEW },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, email: true } },
|
||||||
|
_count: { select: { chapters: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveCourse(userId: string, courseId: string, note?: string): Promise<any> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
|
||||||
|
throw new ForbiddenException('Moderators only');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.course.update({
|
||||||
|
where: { id: courseId },
|
||||||
|
data: {
|
||||||
|
status: CourseStatus.PUBLISHED,
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: new Date(),
|
||||||
|
moderationNote: note,
|
||||||
|
moderatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectCourse(userId: string, courseId: string, reason: string): Promise<any> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user || (user.role !== UserRole.MODERATOR && user.role !== UserRole.ADMIN)) {
|
||||||
|
throw new ForbiddenException('Moderators only');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.course.update({
|
||||||
|
where: { id: courseId },
|
||||||
|
data: {
|
||||||
|
status: CourseStatus.REJECTED,
|
||||||
|
moderationNote: reason,
|
||||||
|
moderatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/api/src/support/support.controller.ts
Normal file
32
apps/api/src/support/support.controller.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { SupportService } from './support.service';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { User } from '@coursecraft/database';
|
||||||
|
|
||||||
|
@ApiTags('support')
|
||||||
|
@Controller('support')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class SupportController {
|
||||||
|
constructor(private supportService: SupportService) {}
|
||||||
|
|
||||||
|
@Post('tickets')
|
||||||
|
async createTicket(@Body('title') title: string, @CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.supportService.createTicket(user.id, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tickets')
|
||||||
|
async getMyTickets(@CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.supportService.getUserTickets(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tickets/:id/messages')
|
||||||
|
async getMessages(@Param('id') id: string, @CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.supportService.getTicketMessages(id, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('tickets/:id/messages')
|
||||||
|
async sendMessage(@Param('id') id: string, @Body('content') content: string, @CurrentUser() user: User): Promise<any> {
|
||||||
|
return this.supportService.sendMessage(id, user.id, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/support/support.module.ts
Normal file
10
apps/api/src/support/support.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SupportController } from './support.controller';
|
||||||
|
import { SupportService } from './support.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SupportController],
|
||||||
|
providers: [SupportService],
|
||||||
|
exports: [SupportService],
|
||||||
|
})
|
||||||
|
export class SupportModule {}
|
||||||
40
apps/api/src/support/support.service.ts
Normal file
40
apps/api/src/support/support.service.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../common/prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupportService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async createTicket(userId: string, title: string): Promise<any> {
|
||||||
|
return this.prisma.supportTicket.create({
|
||||||
|
data: { userId, title },
|
||||||
|
include: { messages: { include: { user: { select: { name: true } } } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserTickets(userId: string): Promise<any> {
|
||||||
|
return this.prisma.supportTicket.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { messages: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTicketMessages(ticketId: string, userId: string): Promise<any> {
|
||||||
|
const ticket = await this.prisma.supportTicket.findFirst({ where: { id: ticketId, userId } });
|
||||||
|
if (!ticket) throw new Error('Ticket not found');
|
||||||
|
|
||||||
|
return this.prisma.ticketMessage.findMany({
|
||||||
|
where: { ticketId },
|
||||||
|
include: { user: { select: { id: true, name: true, avatarUrl: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(ticketId: string, userId: string, content: string): Promise<any> {
|
||||||
|
return this.prisma.ticketMessage.create({
|
||||||
|
data: { ticketId, userId, content, isStaff: false },
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
117
apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx
Normal file
117
apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Printer, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
|
||||||
|
interface CertificateData {
|
||||||
|
userName: string;
|
||||||
|
courseTitle: string;
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CertificatePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const courseId = params?.courseId as string | undefined;
|
||||||
|
const { loading: authLoading, user } = useAuth();
|
||||||
|
const [data, setData] = useState<CertificateData | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading || !user || !courseId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.getCertificateData(courseId);
|
||||||
|
setData(res);
|
||||||
|
} catch {
|
||||||
|
setError('Сертификат недоступен. Завершите курс и попробуйте снова.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [authLoading, user, courseId]);
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateStr = data?.completedAt
|
||||||
|
? new Date(data.completedAt).toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-100">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-100 p-4">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<AlertCircle className="h-12 w-12 mx-auto text-amber-600 mb-4" />
|
||||||
|
<p className="text-lg text-slate-700">{error || 'Данные не найдены'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `@media print { body * { visibility: hidden; } .certificate-wrap, .certificate-wrap * { visibility: visible; } .certificate-wrap { position: absolute; left: 0; top: 0; width: 100%; } .no-print { display: none !important; } }`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-700 flex flex-col items-center justify-center p-6 print:bg-white print:p-0">
|
||||||
|
<div className="no-print absolute top-4 right-4">
|
||||||
|
<Button onClick={handlePrint} variant="secondary" className="shadow-lg">
|
||||||
|
<Printer className="h-4 w-4 mr-2" />
|
||||||
|
Печать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="certificate-wrap bg-white w-full max-w-[1000px] py-16 px-20 shadow-2xl border-[12px] border-slate-100 relative print:shadow-none print:border-0">
|
||||||
|
<div className="absolute inset-8 border-2 border-indigo-500 pointer-events-none print:border-indigo-600" aria-hidden />
|
||||||
|
|
||||||
|
<div className="relative z-10 text-center">
|
||||||
|
<div className="text-xl font-bold text-indigo-600 mb-8">CourseCraft</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-indigo-600 uppercase tracking-widest mb-4">
|
||||||
|
Сертификат
|
||||||
|
</h1>
|
||||||
|
<h2 className="text-2xl text-slate-700 font-normal mb-10">о прохождении курса</h2>
|
||||||
|
|
||||||
|
<div className="my-8">
|
||||||
|
<p className="text-lg text-slate-500 mb-2">Настоящий сертификат подтверждает, что</p>
|
||||||
|
<p className="text-3xl md:text-4xl font-bold text-slate-900 border-b-2 border-indigo-500 pb-2 inline-block w-full">
|
||||||
|
{data.userName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-6">
|
||||||
|
<p className="text-lg text-slate-500 mb-2">успешно завершил(а) курс</p>
|
||||||
|
<p className="text-2xl md:text-3xl text-slate-600 font-medium">{data.courseTitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-slate-500 mt-12 text-base">
|
||||||
|
Дата выдачи: {dateStr}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/web/src/app/(certificate)/layout.tsx
Normal file
7
apps/web/src/app/(certificate)/layout.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function CertificateLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
305
apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx
Normal file
305
apps/web/src/app/(dashboard)/dashboard/catalog/[id]/page.tsx
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
'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<any>(null);
|
||||||
|
const [reviews, setReviews] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [enrolling, setEnrolling] = useState(false);
|
||||||
|
const [enrolled, setEnrolled] = useState(false);
|
||||||
|
const [expandedChapters, setExpandedChapters] = useState<string[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!course) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<p className="text-muted-foreground">Курс не найден</p>
|
||||||
|
<Link href="/dashboard/catalog" className="text-primary hover:underline mt-2 inline-block">
|
||||||
|
Вернуться к каталогу
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalLessons = course.chapters.reduce((acc: number, ch: any) => acc + ch.lessons.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/dashboard/catalog">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
К каталогу
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Course header */}
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
<div className="md:col-span-2 space-y-4">
|
||||||
|
{/* Cover */}
|
||||||
|
<div className="aspect-video rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 overflow-hidden">
|
||||||
|
{course.coverImage ? (
|
||||||
|
<img src={course.coverImage} alt={course.title} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<BookOpen className="h-16 w-16 text-primary/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & meta */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{course.isVerified && (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
Проверен автором
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.difficulty && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-muted text-xs font-medium">
|
||||||
|
{course.difficulty === 'beginner' ? 'Начинающий' : course.difficulty === 'intermediate' ? 'Средний' : 'Продвинутый'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold">{course.title}</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">{course.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
{course.averageRating && (
|
||||||
|
<div className="flex items-center gap-1 text-yellow-600 font-medium">
|
||||||
|
<Star className="h-4 w-4 fill-current" />
|
||||||
|
{course.averageRating.toFixed(1)} ({course._count.reviews} отзывов)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
{course._count.enrollments} студентов
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{totalLessons} уроков</span>
|
||||||
|
{course.estimatedHours && (
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
{course.estimatedHours}ч
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-border my-4" />
|
||||||
|
|
||||||
|
{/* Author */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary">
|
||||||
|
{course.author.name?.[0] || 'A'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{course.author.name || 'Автор'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Преподаватель</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar: Enroll / Price */}
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<Card className="sticky top-4">
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{enrolled ? (
|
||||||
|
<Button className="w-full" asChild>
|
||||||
|
<Link href={`/dashboard/courses/${id}`}>Продолжить обучение</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="w-full" onClick={handleEnroll} disabled={enrolling}>
|
||||||
|
{enrolling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
{course.price ? 'Купить курс' : 'Записаться'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
<span>{course.chapters.length} глав</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
<span>{totalLessons} видеоуроков</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
<span>Сертификат по окончании</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
<span>Пожизненный доступ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course content (chapters & lessons) */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Содержание курса</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{course.chapters.map((chapter: any) => {
|
||||||
|
const expanded = expandedChapters.includes(chapter.id);
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setExpandedChapters(prev =>
|
||||||
|
prev.includes(chapter.id)
|
||||||
|
? prev.filter(id => id !== chapter.id)
|
||||||
|
: [...prev, chapter.id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={chapter.id} className="border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-between w-full p-4 hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BookOpen className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-medium">{chapter.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{chapter.lessons.length} уроков</span>
|
||||||
|
<ChevronDown className={cn('h-4 w-4 transition-transform', expanded && 'rotate-180')} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t bg-muted/20 p-2">
|
||||||
|
{chapter.lessons.map((lesson: any) => (
|
||||||
|
<div key={lesson.id} className="flex items-center gap-2 px-3 py-2 text-sm">
|
||||||
|
<Check className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span>{lesson.title}</span>
|
||||||
|
{lesson.durationMinutes && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">{lesson.durationMinutes} мин</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Reviews */}
|
||||||
|
{reviews.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Отзывы студентов</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{reviews.map((review: any) => (
|
||||||
|
<div key={review.id} className="border-b last:border-b-0 pb-4 last:pb-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center font-bold text-primary text-sm">
|
||||||
|
{review.user.name?.[0] || 'U'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-sm">{review.user.name || 'Пользователь'}</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5',
|
||||||
|
i < review.rating ? 'text-yellow-500 fill-current' : 'text-muted-foreground/30'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{review.title && <p className="font-medium text-sm mb-1">{review.title}</p>}
|
||||||
|
{review.content && <p className="text-sm text-muted-foreground">{review.content}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
apps/web/src/app/(dashboard)/dashboard/catalog/page.tsx
Normal file
185
apps/web/src/app/(dashboard)/dashboard/catalog/page.tsx
Normal file
@ -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<string, { label: string; color: string }> = {
|
||||||
|
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<CatalogCourse[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Каталог курсов</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Изучайте курсы от других авторов</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск курсов..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">Найти</Button>
|
||||||
|
</form>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['', 'beginner', 'intermediate', 'advanced'].map((d) => (
|
||||||
|
<Button
|
||||||
|
key={d}
|
||||||
|
variant={difficulty === d ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDifficulty(d)}
|
||||||
|
>
|
||||||
|
{d ? difficultyLabels[d]?.label : 'Все'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courses grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : courses.length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<BookOpen className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||||
|
<p className="text-muted-foreground">Пока нет опубликованных курсов</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{courses.map((course) => (
|
||||||
|
<Link key={course.id} href={`/dashboard/catalog/${course.id}`}>
|
||||||
|
<Card className="group overflow-hidden transition-all hover:shadow-lg hover:-translate-y-0.5 cursor-pointer h-full">
|
||||||
|
{/* Cover image */}
|
||||||
|
<div className="aspect-video bg-gradient-to-br from-primary/20 to-primary/5 relative overflow-hidden">
|
||||||
|
{course.coverImage ? (
|
||||||
|
<img src={course.coverImage} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<BookOpen className="h-10 w-10 text-primary/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.isVerified && (
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-600 text-white text-xs font-medium">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
Проверен
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.difficulty && difficultyLabels[course.difficulty] && (
|
||||||
|
<div className={cn(
|
||||||
|
'absolute top-2 left-2 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
difficultyLabels[course.difficulty].color
|
||||||
|
)}>
|
||||||
|
{difficultyLabels[course.difficulty].label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-semibold line-clamp-1 group-hover:text-primary transition-colors">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
{course.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">{course.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Author */}
|
||||||
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
|
{course.author.name || 'Автор'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
|
||||||
|
{course.averageRating && (
|
||||||
|
<span className="flex items-center gap-0.5 text-yellow-600 font-medium">
|
||||||
|
<Star className="h-3.5 w-3.5 fill-current" />
|
||||||
|
{course.averageRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{course.enrollmentCount}
|
||||||
|
</span>
|
||||||
|
<span>{course.lessonsCount} уроков</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="mt-3 pt-3 border-t">
|
||||||
|
<span className="font-bold text-lg">
|
||||||
|
{course.price ? `${course.price} ${course.currency}` : 'Бесплатно'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { CourseEditor } from '@/components/editor/course-editor';
|
import { CourseEditor } from '@/components/editor/course-editor';
|
||||||
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
||||||
@ -20,6 +21,7 @@ const emptyDoc = { type: 'doc', content: [] };
|
|||||||
|
|
||||||
export default function CourseEditPage() {
|
export default function CourseEditPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const { toast } = useToast();
|
||||||
const { loading: authLoading } = useAuth();
|
const { loading: authLoading } = useAuth();
|
||||||
const courseId = params?.id as string;
|
const courseId = params?.id as string;
|
||||||
|
|
||||||
@ -33,6 +35,8 @@ export default function CourseEditPage() {
|
|||||||
const [contentLoading, setContentLoading] = useState(false);
|
const [contentLoading, setContentLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [readOnly, setReadOnly] = useState(false);
|
const [readOnly, setReadOnly] = useState(false);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!courseId || authLoading) return;
|
if (!courseId || authLoading) return;
|
||||||
@ -104,13 +108,42 @@ export default function CourseEditPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.updateLesson(courseId, activeLesson.lessonId, { content });
|
await api.updateLesson(courseId, activeLesson.lessonId, { content });
|
||||||
|
toast({ title: 'Сохранено', description: 'Изменения успешно сохранены' });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Save failed:', e);
|
toast({ title: 'Ошибка', description: 'Не удалось сохранить', variant: 'destructive' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
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) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||||
@ -196,6 +229,14 @@ export default function CourseEditPage() {
|
|||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleToggleVerify} disabled={verifying}>
|
||||||
|
<Shield className="mr-2 h-4 w-4" />
|
||||||
|
{verifying ? 'Обработка...' : 'Верификация'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="default" onClick={handlePublish} disabled={publishing}>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
{publishing ? 'Публикация...' : 'Опубликовать'}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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 { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -19,7 +35,7 @@ import {
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
|
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
|
||||||
@ -29,23 +45,45 @@ type CourseData = {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
authorId: string;
|
||||||
chapters: Chapter[];
|
chapters: Chapter[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CoursePage() {
|
export default function CoursePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { loading: authLoading } = useAuth();
|
const { loading: authLoading, backendUser } = useAuth();
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
const [course, setCourse] = useState<CourseData | null>(null);
|
const [course, setCourse] = useState<CourseData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
||||||
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
||||||
const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null);
|
const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null);
|
||||||
const [lessonContentLoading, setLessonContentLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!id || authLoading) return;
|
if (!id || authLoading) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -53,11 +91,24 @@ export default function CoursePage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.getCourse(id);
|
const [courseData, progressData] = await Promise.all([
|
||||||
|
api.getCourse(id),
|
||||||
|
api.getEnrollmentProgress(id).catch(() => null),
|
||||||
|
]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCourse(data);
|
setCourse(courseData);
|
||||||
const first = data.chapters?.[0]?.lessons?.[0];
|
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);
|
if (first) setSelectedLessonId(first.id);
|
||||||
|
setExpandedChapters(courseData.chapters.map((ch: Chapter) => ch.id));
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
|
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
|
||||||
@ -68,6 +119,7 @@ export default function CoursePage() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [id, authLoading]);
|
}, [id, authLoading]);
|
||||||
|
|
||||||
|
// Load lesson content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || !selectedLessonId) {
|
if (!id || !selectedLessonId) {
|
||||||
setLessonContent(null);
|
setLessonContent(null);
|
||||||
@ -106,11 +158,71 @@ 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 handleGetCertificate = () => {
|
||||||
|
if (!id) return;
|
||||||
|
window.open(`/certificate/${id}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
<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>
|
<p className="text-muted-foreground">Загрузка курса...</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,40 +237,57 @@ export default function CoursePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeLessonTitle = selectedLessonId
|
const activeLessonMeta = selectedLessonId
|
||||||
? (() => {
|
? flatLessons.find((l) => l.id === selectedLessonId)
|
||||||
for (const ch of course.chapters) {
|
|
||||||
const lesson = ch.lessons.find((l) => l.id === selectedLessonId);
|
|
||||||
if (lesson) return lesson.title;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const isAuthor = course && backendUser && course.authorId === backendUser.id;
|
||||||
|
const courseCompleted = completedCount >= totalLessons && totalLessons > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
|
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
|
||||||
{/* Top bar */}
|
{/* 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">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||||
К курсам
|
Мои курсы
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-muted-foreground">/</span>
|
<div className="h-5 w-px bg-border" />
|
||||||
<span className="font-medium truncate max-w-[200px] sm:max-w-xs">{course.title}</span>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
||||||
|
|
||||||
|
{/* Certificate button - show when course completed */}
|
||||||
|
{courseCompleted && (
|
||||||
|
<Button size="sm" variant="default" onClick={handleGetCertificate}>
|
||||||
|
<GraduationCap className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Получить сертификат
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit/Delete - only for author */}
|
||||||
|
{isAuthor && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
<Link href={`/dashboard/courses/${course.id}/edit`}>
|
<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>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
@ -181,52 +310,231 @@ export default function CoursePage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex flex-1 min-h-0">
|
<div className="relative flex flex-1 min-h-0">
|
||||||
{/* Left: list of lessons (paragraphs) */}
|
{/* ─── Left sidebar: course navigation ─── */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-r bg-muted/20 flex flex-col transition-[width] duration-200',
|
'border-r bg-muted/10 flex flex-col transition-all duration-300 ease-in-out',
|
||||||
sidebarOpen ? 'w-72 shrink-0' : 'w-0 overflow-hidden'
|
sidebarOpen ? 'w-80 shrink-0' : 'w-0 overflow-hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<LessonSidebar
|
<div className="flex flex-col h-full">
|
||||||
course={course}
|
{/* Course progress */}
|
||||||
activeLesson={selectedLessonId ?? ''}
|
<div className="p-4 border-b bg-background">
|
||||||
onSelectLesson={setSelectedLessonId}
|
<div className="flex items-center justify-between mb-2">
|
||||||
readOnly
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar toggle */}
|
||||||
<button
|
<button
|
||||||
type="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"
|
className={cn(
|
||||||
style={{ left: sidebarOpen ? '17rem' : 0 }}
|
'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)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
>
|
>
|
||||||
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Center: lesson content (read-only) */}
|
{/* ─── Main content area ─── */}
|
||||||
<main className="flex-1 flex flex-col min-h-0 overflow-auto">
|
<main className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="max-w-3xl mx-auto w-full px-6 py-8">
|
{/* Lesson content */}
|
||||||
{activeLessonTitle && (
|
<div className="flex-1 overflow-auto">
|
||||||
<h1 className="text-2xl font-bold mb-6 text-foreground">{activeLessonTitle}</h1>
|
<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 ? (
|
{lessonContentLoading ? (
|
||||||
<p className="text-muted-foreground">Загрузка...</p>
|
<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 ? (
|
) : selectedLessonId ? (
|
||||||
|
<>
|
||||||
<LessonContentViewer
|
<LessonContentViewer
|
||||||
content={lessonContent}
|
content={lessonContent}
|
||||||
className="prose-reader min-h-[400px] [&_.ProseMirror]:min-h-[300px]"
|
className="min-h-[400px]"
|
||||||
/>
|
/>
|
||||||
) : (
|
{!showQuiz && !completedLessons.has(selectedLessonId) && (
|
||||||
<p className="text-muted-foreground">Выберите урок в списке слева.</p>
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
125
apps/web/src/app/(dashboard)/dashboard/learning/page.tsx
Normal file
125
apps/web/src/app/(dashboard)/dashboard/learning/page.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { GraduationCap, BookOpen, Loader2, Trophy, Download } from 'lucide-react';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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<EnrollmentData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const handleOpenCertificate = (courseId: string) => {
|
||||||
|
window.open(`/certificate/${courseId}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Мои обучения</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Курсы, на которые вы записаны</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enrollments.length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<GraduationCap className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||||
|
<p className="text-lg font-medium">Пока нет записей</p>
|
||||||
|
<p className="text-muted-foreground mb-4">Найдите курсы в каталоге</p>
|
||||||
|
<Link href="/dashboard/catalog" className="text-primary hover:underline">
|
||||||
|
Открыть каталог
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{enrollments.map((enrollment) => {
|
||||||
|
const lessonsCount = enrollment.course.chapters.reduce(
|
||||||
|
(acc, ch) => acc + ch._count.lessons, 0
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Link key={enrollment.id} href={`/dashboard/courses/${enrollment.course.id}`}>
|
||||||
|
<Card className="group overflow-hidden transition-all hover:shadow-md cursor-pointer h-full">
|
||||||
|
<div className="aspect-[3/1] bg-gradient-to-br from-primary/20 to-primary/5 relative">
|
||||||
|
{enrollment.course.coverImage ? (
|
||||||
|
<img src={enrollment.course.coverImage} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<BookOpen className="h-8 w-8 text-primary/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{enrollment.completedAt && (
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-600 text-white text-xs font-medium">
|
||||||
|
<Trophy className="h-3 w-3" />
|
||||||
|
Пройден
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-semibold line-clamp-1">{enrollment.course.title}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{enrollment.course.author.name}</p>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{enrollment.progress}%</span>
|
||||||
|
<span>{lessonsCount} уроков</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={enrollment.progress} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
{enrollment.completedAt && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mt-3"
|
||||||
|
onClick={(e) => { e.preventDefault(); handleOpenCertificate(enrollment.course.id); }}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Скачать сертификат
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -104,3 +104,58 @@ html {
|
|||||||
::selection {
|
::selection {
|
||||||
background: hsl(var(--primary) / 0.2);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
88
apps/web/src/components/dashboard/course-chat.tsx
Normal file
88
apps/web/src/components/dashboard/course-chat.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Send, MessageCircle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: { id: string; name: string | null; avatarUrl: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourseChatProps {
|
||||||
|
groupId: string;
|
||||||
|
userId: string;
|
||||||
|
onSendMessage: (content: string) => Promise<void>;
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourseChat({ groupId, userId, onSendMessage, messages }: CourseChatProps) {
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!newMessage.trim() || sending) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await onSendMessage(newMessage);
|
||||||
|
setNewMessage('');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col h-[500px]">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
Чат курса
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col min-h-0 p-4">
|
||||||
|
<div className="flex-1 overflow-auto space-y-3 mb-3">
|
||||||
|
{messages.map((msg) => {
|
||||||
|
const isOwn = msg.user.id === userId;
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={cn('flex gap-2', isOwn && 'flex-row-reverse')}>
|
||||||
|
<div className="h-8 w-8 shrink-0 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
|
||||||
|
{msg.user.name?.[0] || 'U'}
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex flex-col gap-1 max-w-[70%]', isOwn && 'items-end')}>
|
||||||
|
<span className="text-xs text-muted-foreground">{msg.user.name || 'Аноним'}</span>
|
||||||
|
<div className={cn('rounded-lg px-3 py-2 text-sm', isOwn ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||||
|
placeholder="Написать сообщение..."
|
||||||
|
className="flex-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={handleSend} disabled={sending || !newMessage.trim()}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/web/src/components/dashboard/lesson-quiz.tsx
Normal file
137
apps/web/src/components/dashboard/lesson-quiz.tsx
Normal file
@ -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<number[]>([]);
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(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 (
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardContent className="p-8 text-center">
|
||||||
|
<div className={cn(
|
||||||
|
'mx-auto h-16 w-16 rounded-full flex items-center justify-center mb-4',
|
||||||
|
passed ? 'bg-green-100 dark:bg-green-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||||
|
)}>
|
||||||
|
{passed ? (
|
||||||
|
<Trophy className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-8 w-8 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-2">
|
||||||
|
{passed ? 'Отлично!' : 'Неплохо!'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Ваш результат: {correct} из {questions.length} ({score}%)
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 max-w-md mx-auto">
|
||||||
|
{questions.map((q, idx) => {
|
||||||
|
const isCorrect = answers[idx] === q.correctAnswer;
|
||||||
|
return (
|
||||||
|
<div key={q.id} className={cn(
|
||||||
|
'flex items-center gap-2 p-2 rounded text-sm',
|
||||||
|
isCorrect ? 'bg-green-50 dark:bg-green-900/30' : 'bg-red-50 dark:bg-red-900/30'
|
||||||
|
)}>
|
||||||
|
{isCorrect ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
<span>Вопрос {idx + 1}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = questions[currentQuestion];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Тест по уроку ({currentQuestion + 1}/{questions.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="font-medium">{question.question}</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{question.options.map((option, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => handleSelectAnswer(idx)}
|
||||||
|
className={cn(
|
||||||
|
'p-3 rounded-lg border text-left transition-colors',
|
||||||
|
selectedAnswer === idx
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'hover:bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={selectedAnswer === null}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{currentQuestion < questions.length - 1 ? 'Следующий вопрос' : 'Завершить тест'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,18 +6,20 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
GraduationCap,
|
||||||
|
Compass,
|
||||||
Settings,
|
Settings,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Обзор', href: '/dashboard', icon: LayoutDashboard },
|
{ name: 'Обзор', href: '/dashboard', icon: LayoutDashboard, exact: true },
|
||||||
{ name: 'Мои курсы', href: '/dashboard', icon: BookOpen },
|
{ name: 'Мои курсы', href: '/dashboard', icon: BookOpen, exact: true },
|
||||||
{ name: 'Поиск', href: '/dashboard/search', icon: Search },
|
{ name: 'Каталог', href: '/dashboard/catalog', icon: Compass },
|
||||||
|
{ name: 'Мои обучения', href: '/dashboard/learning', icon: GraduationCap },
|
||||||
];
|
];
|
||||||
|
|
||||||
const bottomNavigation = [
|
const bottomNavigation = [
|
||||||
@ -53,7 +55,9 @@ export function Sidebar() {
|
|||||||
{/* Main navigation */}
|
{/* Main navigation */}
|
||||||
<nav className="flex-1 px-4 space-y-1">
|
<nav className="flex-1 px-4 space-y-1">
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
const isActive = (item as any).exact
|
||||||
|
? pathname === item.href
|
||||||
|
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import Link from '@tiptap/extension-link';
|
|||||||
import Image from '@tiptap/extension-image';
|
import Image from '@tiptap/extension-image';
|
||||||
import mermaid from 'mermaid';
|
import mermaid from 'mermaid';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
|
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
|
||||||
const emptyDoc = { type: 'doc', content: [] };
|
const emptyDoc = { type: 'doc', content: [] };
|
||||||
@ -27,22 +28,21 @@ export function LessonContentViewer({ content, className }: LessonContentViewerP
|
|||||||
HTMLAttributes: (node: { attrs: { language?: string } }) =>
|
HTMLAttributes: (node: { attrs: { language?: string } }) =>
|
||||||
node.attrs.language === 'mermaid'
|
node.attrs.language === 'mermaid'
|
||||||
? { class: 'mermaid rounded-lg p-4 bg-muted min-h-[80px]', 'data-language': 'mermaid' }
|
? { class: 'mermaid rounded-lg p-4 bg-muted min-h-[80px]', 'data-language': 'mermaid' }
|
||||||
: { class: 'rounded-lg bg-muted p-4 font-mono text-sm', 'data-language': node.attrs.language || '' },
|
: { class: 'rounded-xl bg-muted p-5 font-mono text-sm border', 'data-language': node.attrs.language || '' },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Underline,
|
Underline,
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
HTMLAttributes: { class: 'text-primary underline underline-offset-2' },
|
HTMLAttributes: { class: 'text-primary underline underline-offset-2 hover:text-primary/80 transition-colors' },
|
||||||
}),
|
}),
|
||||||
Image.configure({ HTMLAttributes: { class: 'rounded-lg max-w-full h-auto' } }),
|
Image.configure({ HTMLAttributes: { class: 'rounded-xl max-w-full h-auto shadow-sm my-6' } }),
|
||||||
],
|
],
|
||||||
content: content ?? emptyDoc,
|
content: content ?? emptyDoc,
|
||||||
editable: false,
|
editable: false,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class:
|
class: 'outline-none text-foreground',
|
||||||
'prose prose-lg dark:prose-invert max-w-none focus:outline-none leading-relaxed [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-foreground [&_.ProseMirror_p]:leading-7 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm [&_.ProseMirror_pre]:border [&_.ProseMirror_blockquote]:border-primary [&_.ProseMirror_blockquote]:bg-muted/30',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -63,7 +63,7 @@ export function LessonContentViewer({ content, className }: LessonContentViewerP
|
|||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={className}>
|
<div ref={containerRef} className={cn('prose-course', className)}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,8 +6,17 @@ import { getSupabase } from '@/lib/supabase';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { api, setApiToken } from '@/lib/api';
|
import { api, setApiToken } from '@/lib/api';
|
||||||
|
|
||||||
|
interface BackendUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
subscriptionTier: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
backendUser: BackendUser | null;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null }>;
|
signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null }>;
|
||||||
@ -20,6 +29,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [backendUser, setBackendUser] = useState<BackendUser | null>(null);
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -57,16 +67,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setApiToken(null);
|
setApiToken(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
const maxRetries = 3;
|
||||||
|
|
||||||
|
const tryExchange = () => {
|
||||||
api
|
api
|
||||||
.exchangeToken(session.access_token)
|
.exchangeToken(session.access_token)
|
||||||
.then(({ accessToken }) => {
|
.then(({ accessToken, user: backendUserData }) => {
|
||||||
setApiToken(accessToken);
|
setApiToken(accessToken);
|
||||||
|
setBackendUser(backendUserData);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
attempt++;
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
// Retry with exponential backoff (500ms, 1500ms, 3500ms)
|
||||||
|
setTimeout(tryExchange, 500 * Math.pow(2, attempt));
|
||||||
|
} else {
|
||||||
setApiToken(null);
|
setApiToken(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
tryExchange();
|
||||||
}, [session?.access_token]);
|
}, [session?.access_token]);
|
||||||
|
|
||||||
// Exchange Supabase token for backend JWT; keep loading true until done so API calls wait for JWT
|
// Exchange Supabase token for backend JWT; keep loading true until done so API calls wait for JWT
|
||||||
@ -138,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
|
backendUser,
|
||||||
session,
|
session,
|
||||||
loading,
|
loading,
|
||||||
signUp,
|
signUp,
|
||||||
|
|||||||
@ -179,6 +179,10 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLessonQuiz(courseId: string, lessonId: string) {
|
||||||
|
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/quiz`);
|
||||||
|
}
|
||||||
|
|
||||||
// Generation
|
// Generation
|
||||||
async startGeneration(prompt: string) {
|
async startGeneration(prompt: string) {
|
||||||
return this.request<{ id: string; status: string; progress: number }>('/generation/start', {
|
return this.request<{ id: string; status: string; progress: number }>('/generation/start', {
|
||||||
@ -231,6 +235,76 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Catalog (public courses)
|
||||||
|
async getCatalog(params?: { page?: number; limit?: number; search?: string; difficulty?: string }) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.page) searchParams.set('page', String(params.page));
|
||||||
|
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||||
|
if (params?.search) searchParams.set('search', params.search);
|
||||||
|
if (params?.difficulty) searchParams.set('difficulty', params.difficulty);
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return this.request<{ data: any[]; meta: any }>(`/catalog${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicCourse(id: string) {
|
||||||
|
return this.request<any>(`/catalog/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishCourse(id: string) {
|
||||||
|
return this.request<any>(`/catalog/${id}/submit`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleCourseVerification(id: string) {
|
||||||
|
return this.request<any>(`/catalog/${id}/verify`, { method: 'PATCH' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrollment & Progress
|
||||||
|
async enrollInCourse(courseId: string) {
|
||||||
|
return this.request<any>(`/enrollment/${courseId}/enroll`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyEnrollments() {
|
||||||
|
return this.request<any[]>('/enrollment');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEnrollmentProgress(courseId: string) {
|
||||||
|
return this.request<any>(`/enrollment/${courseId}/progress`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeLesson(courseId: string, lessonId: string) {
|
||||||
|
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/complete`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitQuizScore(courseId: string, lessonId: string, score: number) {
|
||||||
|
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/quiz`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ score }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReview(courseId: string, data: { rating: number; title?: string; content?: string }) {
|
||||||
|
return this.request<any>(`/enrollment/${courseId}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCourseReviews(courseId: string, page?: number) {
|
||||||
|
const params = page ? `?page=${page}` : '';
|
||||||
|
return this.request<{ data: any[]; meta: any }>(`/enrollment/${courseId}/reviews${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificates
|
||||||
|
async getCertificate(courseId: string) {
|
||||||
|
return this.request<{ certificateUrl: string; html: string }>(`/certificates/${courseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCertificateData(courseId: string) {
|
||||||
|
return this.request<{ userName: string; courseTitle: string; completedAt: string }>(
|
||||||
|
`/certificates/${courseId}/data`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
|
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
|
||||||
const searchParams = new URLSearchParams({ q: query });
|
const searchParams = new URLSearchParams({ q: query });
|
||||||
|
|||||||
@ -82,7 +82,7 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require('tailwindcss-animate')],
|
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@ -30,13 +30,23 @@ model User {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Role
|
||||||
|
role UserRole @default(USER)
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
settings UserSettings?
|
settings UserSettings?
|
||||||
subscription Subscription?
|
subscription Subscription?
|
||||||
courses Course[] @relation("AuthoredCourses")
|
courses Course[] @relation("AuthoredCourses")
|
||||||
|
enrollments Enrollment[]
|
||||||
|
lessonProgress LessonProgress[]
|
||||||
purchases Purchase[]
|
purchases Purchase[]
|
||||||
reviews Review[]
|
reviews Review[]
|
||||||
generations CourseGeneration[]
|
generations CourseGeneration[]
|
||||||
|
groupMembers GroupMember[]
|
||||||
|
groupMessages GroupMessage[]
|
||||||
|
homeworkSubmissions HomeworkSubmission[]
|
||||||
|
supportTickets SupportTicket[]
|
||||||
|
ticketMessages TicketMessage[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -65,6 +75,12 @@ model UserSettings {
|
|||||||
@@map("user_settings")
|
@@map("user_settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
USER
|
||||||
|
MODERATOR
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Subscription & Payments
|
// Subscription & Payments
|
||||||
// ============================================
|
// ============================================
|
||||||
@ -113,7 +129,9 @@ model Subscription {
|
|||||||
enum CourseStatus {
|
enum CourseStatus {
|
||||||
DRAFT
|
DRAFT
|
||||||
GENERATING
|
GENERATING
|
||||||
|
PENDING_REVIEW
|
||||||
PUBLISHED
|
PUBLISHED
|
||||||
|
REJECTED
|
||||||
ARCHIVED
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,11 +148,14 @@ model Course {
|
|||||||
// Status
|
// Status
|
||||||
status CourseStatus @default(DRAFT)
|
status CourseStatus @default(DRAFT)
|
||||||
|
|
||||||
// Marketplace (future)
|
// Marketplace
|
||||||
isPublished Boolean @default(false) @map("is_published")
|
isPublished Boolean @default(false) @map("is_published")
|
||||||
price Decimal? @db.Decimal(10, 2) // null = private course
|
price Decimal? @db.Decimal(10, 2) // null = free course
|
||||||
currency String @default("USD")
|
currency String @default("USD")
|
||||||
|
|
||||||
|
// Author verification — author checked the content and vouches for quality
|
||||||
|
isVerified Boolean @default(false) @map("is_verified")
|
||||||
|
|
||||||
// Categorization
|
// Categorization
|
||||||
categoryId String? @map("category_id")
|
categoryId String? @map("category_id")
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
@ -155,13 +176,19 @@ model Course {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Moderation
|
||||||
|
moderationNote String? @db.Text @map("moderation_note")
|
||||||
|
moderatedAt DateTime? @map("moderated_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
author User @relation("AuthoredCourses", fields: [authorId], references: [id], onDelete: Cascade)
|
author User @relation("AuthoredCourses", fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
chapters Chapter[]
|
chapters Chapter[]
|
||||||
|
enrollments Enrollment[]
|
||||||
purchases Purchase[]
|
purchases Purchase[]
|
||||||
reviews Review[]
|
reviews Review[]
|
||||||
generation CourseGeneration?
|
generation CourseGeneration?
|
||||||
|
groups CourseGroup[]
|
||||||
|
|
||||||
// Vector embedding for semantic search
|
// Vector embedding for semantic search
|
||||||
embedding Unsupported("vector(1536)")?
|
embedding Unsupported("vector(1536)")?
|
||||||
@ -213,6 +240,8 @@ model Lesson {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
|
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
|
||||||
|
homework Homework[]
|
||||||
|
quiz Quiz?
|
||||||
|
|
||||||
// Vector embedding for semantic search
|
// Vector embedding for semantic search
|
||||||
embedding Unsupported("vector(1536)")?
|
embedding Unsupported("vector(1536)")?
|
||||||
@ -221,6 +250,18 @@ model Lesson {
|
|||||||
@@map("lessons")
|
@@map("lessons")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Quiz {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
lessonId String @unique @map("lesson_id")
|
||||||
|
questions Json // Array of {id, question, options, correctAnswer}
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("quizzes")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// AI Course Generation
|
// AI Course Generation
|
||||||
// ============================================
|
// ============================================
|
||||||
@ -360,3 +401,187 @@ model Review {
|
|||||||
@@index([courseId])
|
@@index([courseId])
|
||||||
@@map("reviews")
|
@@map("reviews")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Enrollment & Progress
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model Enrollment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
courseId String @map("course_id")
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
progress Int @default(0) // 0-100
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
|
||||||
|
// Certificate
|
||||||
|
certificateUrl String? @map("certificate_url")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
lessons LessonProgress[]
|
||||||
|
|
||||||
|
@@unique([userId, courseId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([courseId])
|
||||||
|
@@map("enrollments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LessonProgress {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
enrollmentId String @map("enrollment_id")
|
||||||
|
lessonId String @map("lesson_id")
|
||||||
|
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
quizScore Int? @map("quiz_score") // 0-100
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, lessonId])
|
||||||
|
@@index([enrollmentId])
|
||||||
|
@@map("lesson_progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Course Groups & Collaboration
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model CourseGroup {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
courseId String @map("course_id")
|
||||||
|
name String
|
||||||
|
description String? @db.Text
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
members GroupMember[]
|
||||||
|
messages GroupMessage[]
|
||||||
|
|
||||||
|
@@index([courseId])
|
||||||
|
@@map("course_groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GroupMember {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
groupId String @map("group_id")
|
||||||
|
userId String @map("user_id")
|
||||||
|
role String @default("student") // "teacher", "student"
|
||||||
|
|
||||||
|
joinedAt DateTime @default(now()) @map("joined_at")
|
||||||
|
|
||||||
|
group CourseGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([groupId, userId])
|
||||||
|
@@map("group_members")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GroupMessage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
groupId String @map("group_id")
|
||||||
|
userId String @map("user_id")
|
||||||
|
content String @db.Text
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
group CourseGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([groupId])
|
||||||
|
@@map("group_messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Homework & Assignments
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model Homework {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
lessonId String @map("lesson_id")
|
||||||
|
title String
|
||||||
|
description String @db.Text
|
||||||
|
dueDate DateTime? @map("due_date")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
|
submissions HomeworkSubmission[]
|
||||||
|
|
||||||
|
@@index([lessonId])
|
||||||
|
@@map("homework")
|
||||||
|
}
|
||||||
|
|
||||||
|
model HomeworkSubmission {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
homeworkId String @map("homework_id")
|
||||||
|
userId String @map("user_id")
|
||||||
|
content String @db.Text
|
||||||
|
|
||||||
|
// AI grading
|
||||||
|
aiScore Int? @map("ai_score") // 0-100
|
||||||
|
aiFeedback String? @db.Text @map("ai_feedback")
|
||||||
|
|
||||||
|
// Teacher grading
|
||||||
|
teacherScore Int? @map("teacher_score") // 0-100
|
||||||
|
teacherFeedback String? @db.Text @map("teacher_feedback")
|
||||||
|
|
||||||
|
submittedAt DateTime @default(now()) @map("submitted_at")
|
||||||
|
gradedAt DateTime? @map("graded_at")
|
||||||
|
|
||||||
|
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([homeworkId, userId])
|
||||||
|
@@map("homework_submissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Support Tickets
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model SupportTicket {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
title String
|
||||||
|
status String @default("open") // "open", "in_progress", "resolved", "closed"
|
||||||
|
priority String @default("normal") // "low", "normal", "high"
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
messages TicketMessage[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("support_tickets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TicketMessage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
ticketId String @map("ticket_id")
|
||||||
|
userId String @map("user_id")
|
||||||
|
content String @db.Text
|
||||||
|
isStaff Boolean @default(false) @map("is_staff")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([ticketId])
|
||||||
|
@@map("ticket_messages")
|
||||||
|
}
|
||||||
|
|||||||
@ -30,7 +30,9 @@ export type {
|
|||||||
Category,
|
Category,
|
||||||
Purchase,
|
Purchase,
|
||||||
Review,
|
Review,
|
||||||
|
Enrollment,
|
||||||
|
LessonProgress,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
// Enum re-exports
|
// Enum re-exports
|
||||||
export { SubscriptionTier, CourseStatus, GenerationStatus } from '@prisma/client';
|
export { SubscriptionTier, CourseStatus, GenerationStatus, UserRole } from '@prisma/client';
|
||||||
|
|||||||
Reference in New Issue
Block a user