project init

This commit is contained in:
2026-02-06 02:17:59 +03:00
commit b9d9b9ed17
129 changed files with 22835 additions and 0 deletions

View File

@ -0,0 +1,26 @@
{
"name": "@coursecraft/ai-service",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist .turbo",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"start": "node dist/index.js",
"lint": "eslint src/**/*.ts --fix"
},
"dependencies": {
"@coursecraft/database": "workspace:*",
"@coursecraft/shared": "workspace:*",
"bullmq": "^5.1.0",
"dotenv": "^16.4.0",
"ioredis": "^5.3.0",
"openai": "^4.28.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.0"
}
}

View File

@ -0,0 +1,108 @@
import dotenv from 'dotenv';
import path from 'path';
// Load .env from project root
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
import { Worker, Job } from 'bullmq';
import Redis from 'ioredis';
import { CourseGenerationPipeline } from './pipeline/course-generation.pipeline';
import { OpenRouterProvider } from './providers/openrouter.provider';
// Logger helper
const log = {
info: (msg: string) => console.log(`\x1b[36m[AI Service]\x1b[0m ${msg}`),
success: (msg: string) => console.log(`\x1b[32m[AI Service] ✓\x1b[0m ${msg}`),
error: (msg: string, error?: unknown) => console.error(`\x1b[31m[AI Service] ✗\x1b[0m ${msg}`, error || ''),
job: (action: string, job: Job) => {
console.log(`\x1b[33m[AI Service]\x1b[0m ${action}: ${job.name} (ID: ${job.id})`);
console.log(`\x1b[90m Data: ${JSON.stringify(job.data)}\x1b[0m`);
},
};
// Check environment variables
log.info('Checking environment variables...');
log.info(`REDIS_URL: ${process.env.REDIS_URL || 'redis://localhost:6395 (default)'}`);
log.info(`OPENROUTER_API_KEY: ${process.env.OPENROUTER_API_KEY ? 'SET' : 'NOT SET'}`);
log.info(`OPENROUTER_BASE_URL: ${process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1 (default)'}`);
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6395', {
maxRetriesPerRequest: null,
});
redisConnection.on('connect', () => log.success('Connected to Redis'));
redisConnection.on('error', (err) => log.error('Redis connection error', err));
let openRouterProvider: OpenRouterProvider;
let pipeline: CourseGenerationPipeline;
try {
openRouterProvider = new OpenRouterProvider();
pipeline = new CourseGenerationPipeline(openRouterProvider);
} catch (error) {
log.error('Failed to initialize AI providers', error);
process.exit(1);
}
// Worker for course generation
const worker = new Worker(
'course-generation',
async (job) => {
log.job('Processing job', job);
const startTime = Date.now();
try {
let result;
if (job.name === 'generate-course') {
const { generationId, prompt, aiModel } = job.data;
result = await pipeline.generateCourse(generationId, prompt, aiModel);
} else if (job.name === 'continue-generation') {
const { generationId, stage } = job.data;
result = await pipeline.continueGeneration(generationId, stage);
} else {
log.error(`Unknown job name: ${job.name}`);
throw new Error(`Unknown job name: ${job.name}`);
}
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
log.success(`Job ${job.name} completed in ${duration}s`);
return result;
} catch (error) {
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
log.error(`Job ${job.name} failed after ${duration}s`, error);
throw error;
}
},
{
connection: redisConnection,
concurrency: 5,
}
);
worker.on('completed', (job) => {
log.success(`Job ${job.id} completed successfully`);
});
worker.on('failed', (job, error) => {
log.error(`Job ${job?.id} failed: ${error.message}`);
if (error.stack) {
console.error(`\x1b[90m${error.stack}\x1b[0m`);
}
});
worker.on('error', (error) => {
log.error('Worker error', error);
});
console.log('');
console.log('🤖 ═══════════════════════════════════════════════════════');
console.log('🤖 AI Service started and listening for jobs...');
console.log('🤖 ═══════════════════════════════════════════════════════');
console.log('');
// Graceful shutdown
process.on('SIGTERM', async () => {
log.info('Shutting down AI Service...');
await worker.close();
await redisConnection.quit();
process.exit(0);
});

View File

@ -0,0 +1,289 @@
import { prisma, GenerationStatus, CourseStatus } from '@coursecraft/database';
import { generateUniqueSlug } from '@coursecraft/shared';
import { OpenRouterProvider, CourseOutline } from '../providers/openrouter.provider';
// Logger helper
const log = {
step: (step: string, msg: string) => {
console.log(`\x1b[35m[Pipeline]\x1b[0m \x1b[1m${step}\x1b[0m - ${msg}`);
},
info: (msg: string, data?: unknown) => {
console.log(`\x1b[35m[Pipeline]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
},
success: (msg: string) => {
console.log(`\x1b[32m[Pipeline] ✓\x1b[0m ${msg}`);
},
error: (msg: string, error?: unknown) => {
console.error(`\x1b[31m[Pipeline] ✗\x1b[0m ${msg}`, error);
},
progress: (generationId: string, progress: number, step: string) => {
console.log(`\x1b[34m[Pipeline]\x1b[0m [${generationId.substring(0, 8)}...] ${progress}% - ${step}`);
},
};
export class CourseGenerationPipeline {
constructor(private aiProvider: OpenRouterProvider) {
log.info('CourseGenerationPipeline initialized');
}
async generateCourse(
generationId: string,
prompt: string,
aiModel: string
): Promise<void> {
log.step('START', `Beginning course generation`);
log.info(`Generation ID: ${generationId}`);
log.info(`AI Model: ${aiModel}`);
log.info(`Prompt: "${prompt}"`);
try {
// Step 1: Update status to analyzing
log.step('STEP 1', 'Analyzing request...');
await this.updateProgress(generationId, GenerationStatus.ANALYZING, 10, 'Analyzing your request...');
// Step 2: Generate clarifying questions
log.step('STEP 2', 'Generating clarifying questions...');
await this.updateProgress(generationId, GenerationStatus.ASKING_QUESTIONS, 15, 'Generating questions...');
const questionsResult = await this.aiProvider.generateClarifyingQuestions(prompt, aiModel);
log.success(`Generated ${questionsResult.questions.length} clarifying questions`);
// Step 3: Wait for user answers
log.step('STEP 3', 'Waiting for user answers...');
await prisma.courseGeneration.update({
where: { id: generationId },
data: {
status: GenerationStatus.WAITING_FOR_ANSWERS,
progress: 20,
currentStep: 'Waiting for your answers...',
questions: questionsResult.questions as unknown as Record<string, unknown>,
},
});
log.success('Generation paused - waiting for user answers');
// Job will be continued when user answers via continue-generation
} catch (error) {
log.error('Course generation failed', error);
await this.updateProgress(
generationId,
GenerationStatus.FAILED,
0,
'Generation failed',
{ errorMessage: error instanceof Error ? error.message : 'Unknown error' }
);
throw error;
}
}
async continueGeneration(generationId: string, stage: string): Promise<void> {
log.step('CONTINUE', `Continuing generation at stage: ${stage}`);
log.info(`Generation ID: ${generationId}`);
const generation = await prisma.courseGeneration.findUnique({
where: { id: generationId },
});
if (!generation) {
log.error('Generation not found');
throw new Error('Generation not found');
}
log.info(`Current status: ${generation.status}`);
log.info(`AI Model: ${generation.aiModel}`);
try {
if (stage === 'after-questions') {
await this.continueAfterQuestions(generation);
}
} catch (error) {
log.error('Continue generation failed', error);
await this.updateProgress(
generationId,
GenerationStatus.FAILED,
0,
'Generation failed',
{ errorMessage: error instanceof Error ? error.message : 'Unknown error' }
);
throw error;
}
}
private async continueAfterQuestions(generation: {
id: string;
userId: string;
initialPrompt: string;
aiModel: string;
answers: unknown;
}): Promise<void> {
const { id: generationId, userId, initialPrompt, aiModel, answers } = generation;
log.info('User answers received:', answers);
// Step 4: Research (simulated - in production would use web search)
log.step('STEP 4', 'Researching the topic...');
await this.updateProgress(generationId, GenerationStatus.RESEARCHING, 30, 'Researching the topic...');
await this.delay(2000); // Simulate research time
log.success('Research completed');
// Step 5: Generate outline
log.step('STEP 5', 'Generating course outline...');
await this.updateProgress(generationId, GenerationStatus.GENERATING_OUTLINE, 50, 'Creating course structure...');
const outline = await this.aiProvider.generateCourseOutline(
initialPrompt,
(answers as Record<string, string | string[]>) || {},
aiModel
);
log.success(`Course outline generated: "${outline.title}"`);
log.info(`Chapters: ${outline.chapters.length}`);
await prisma.courseGeneration.update({
where: { id: generationId },
data: {
generatedOutline: outline as unknown as Record<string, unknown>,
progress: 55,
},
});
// Step 6: Generate content
log.step('STEP 6', 'Generating course content...');
await this.updateProgress(generationId, GenerationStatus.GENERATING_CONTENT, 60, 'Writing course content...');
// Create course structure first
log.info('Creating course structure in database...');
const course = await this.createCourseFromOutline(userId, outline);
log.success(`Course created with ID: ${course.id}`);
// Link course to generation
await prisma.courseGeneration.update({
where: { id: generationId },
data: { courseId: course.id },
});
// Generate content for each lesson
const totalLessons = outline.chapters.reduce((acc, ch) => acc + ch.lessons.length, 0);
let processedLessons = 0;
log.info(`Total lessons to generate: ${totalLessons}`);
for (const chapter of course.chapters) {
log.info(`Processing chapter: "${chapter.title}" (${chapter.lessons.length} lessons)`);
for (const lesson of chapter.lessons) {
const progress = 60 + Math.floor((processedLessons / totalLessons) * 35);
log.progress(generationId, progress, `Writing: ${lesson.title}`);
await this.updateProgress(
generationId,
GenerationStatus.GENERATING_CONTENT,
progress,
`Writing: ${lesson.title}...`
);
// Generate lesson content
const lessonContent = await this.aiProvider.generateLessonContent(
course.title,
chapter.title,
lesson.title,
{
difficulty: outline.difficulty,
},
aiModel
);
// Update lesson with generated content
await prisma.lesson.update({
where: { id: lesson.id },
data: {
content: lessonContent.content as unknown as Record<string, unknown>,
},
});
processedLessons++;
log.success(`Lesson ${processedLessons}/${totalLessons} completed: "${lesson.title}"`);
}
}
// Increment user's course count
log.info('Updating user subscription...');
await prisma.subscription.update({
where: { userId },
data: {
coursesCreatedThisMonth: {
increment: 1,
},
},
});
// Step 7: Complete
log.step('COMPLETE', 'Course generation finished!');
await this.updateProgress(generationId, GenerationStatus.COMPLETED, 100, 'Course completed!');
log.success(`Course "${course.title}" is ready!`);
}
private async createCourseFromOutline(userId: string, outline: CourseOutline) {
const slug = generateUniqueSlug(outline.title);
return prisma.course.create({
data: {
authorId: userId,
title: outline.title,
description: outline.description,
slug,
status: CourseStatus.DRAFT,
difficulty: outline.difficulty,
estimatedHours: outline.estimatedTotalHours,
tags: outline.tags,
chapters: {
create: outline.chapters.map((chapter, chapterIndex) => ({
title: chapter.title,
description: chapter.description,
order: chapterIndex,
lessons: {
create: chapter.lessons.map((lesson, lessonIndex) => ({
title: lesson.title,
order: lessonIndex,
durationMinutes: lesson.estimatedMinutes,
content: null, // Will be filled later
})),
},
})),
},
},
include: {
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
},
});
}
private async updateProgress(
generationId: string,
status: GenerationStatus,
progress: number,
currentStep: string,
additionalData?: Record<string, unknown>
): Promise<void> {
await prisma.courseGeneration.update({
where: { id: generationId },
data: {
status,
progress,
currentStep,
...(additionalData?.errorMessage && { errorMessage: additionalData.errorMessage as string }),
...(status === GenerationStatus.COMPLETED && { completedAt: new Date() }),
},
});
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@ -0,0 +1,393 @@
import OpenAI from 'openai';
import { z } from 'zod';
// Logger helper
const log = {
info: (msg: string, data?: unknown) => {
console.log(`\x1b[36m[AI Provider]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
},
success: (msg: string, data?: unknown) => {
console.log(`\x1b[32m[AI Provider] ✓\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
},
error: (msg: string, error?: unknown) => {
console.error(`\x1b[31m[AI Provider] ✗\x1b[0m ${msg}`, error);
},
debug: (msg: string, data?: unknown) => {
if (process.env.DEBUG === 'true') {
console.log(`\x1b[90m[AI Provider DEBUG]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
}
},
request: (method: string, model: string) => {
console.log(`\x1b[33m[AI Provider] →\x1b[0m Calling ${method} with model: ${model}`);
},
response: (method: string, tokens?: { prompt?: number; completion?: number }) => {
const tokenInfo = tokens ? ` (tokens: ${tokens.prompt || '?'}/${tokens.completion || '?'})` : '';
console.log(`\x1b[33m[AI Provider] ←\x1b[0m ${method} completed${tokenInfo}`);
},
};
// Course outline schema for structured output
const CourseOutlineSchema = z.object({
title: z.string(),
description: z.string(),
chapters: z.array(
z.object({
title: z.string(),
description: z.string(),
lessons: z.array(
z.object({
title: z.string(),
estimatedMinutes: z.number(),
})
),
})
),
estimatedTotalHours: z.number(),
difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
tags: z.array(z.string()),
});
const ClarifyingQuestionItemSchema = z.object({
id: z.string().optional(),
question: z.string().optional(),
text: z.string().optional(),
type: z.enum(['single_choice', 'multiple_choice', 'text']).optional(),
options: z.array(z.string()).optional(),
required: z.boolean().optional(),
}).refine((q) => typeof (q.question ?? q.text) === 'string', { message: 'question or text required' });
const ClarifyingQuestionsSchema = z.object({
questions: z.array(ClarifyingQuestionItemSchema).transform((items) =>
items.map((q, i) => ({
id: q.id ?? `q_${i}`,
question: (q.question ?? q.text ?? '').trim() || `Вопрос ${i + 1}`,
type: (q.type ?? 'text') as 'single_choice' | 'multiple_choice' | 'text',
options: q.options,
required: q.required ?? true,
}))
),
});
const LessonContentSchema = z.object({
content: z.object({
type: z.literal('doc'),
content: z.array(z.any()),
}),
});
export type CourseOutline = z.infer<typeof CourseOutlineSchema>;
export type ClarifyingQuestions = z.infer<typeof ClarifyingQuestionsSchema>;
export type LessonContent = z.infer<typeof LessonContentSchema>;
export class OpenRouterProvider {
private client: OpenAI;
private maxRetries = 3;
private retryDelay = 2000;
constructor() {
const apiKey = process.env.OPENROUTER_API_KEY;
const baseURL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
log.info('Initializing OpenRouter provider...');
log.info(`Base URL: ${baseURL}`);
log.info(`API Key: ${apiKey ? apiKey.substring(0, 15) + '...' : 'NOT SET'}`);
if (!apiKey) {
log.error('OPENROUTER_API_KEY environment variable is required');
throw new Error('OPENROUTER_API_KEY environment variable is required');
}
this.client = new OpenAI({
baseURL,
apiKey,
timeout: 120000, // 2 minute timeout
maxRetries: 3,
defaultHeaders: {
'HTTP-Referer': process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
'X-Title': 'CourseCraft',
},
});
log.success('OpenRouter provider initialized');
}
private async withRetry<T>(fn: () => Promise<T>, operation: string): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
return await fn();
} catch (error: any) {
lastError = error;
log.error(`${operation} attempt ${attempt}/${this.maxRetries} failed: ${error.message}`);
if (attempt < this.maxRetries) {
const delay = this.retryDelay * attempt;
log.info(`Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
async generateClarifyingQuestions(
prompt: string,
model: string
): Promise<ClarifyingQuestions> {
log.request('generateClarifyingQuestions', model);
log.info(`User prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы,
чтобы лучше понять его потребности и создать максимально релевантный курс.
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса:
- Короткий (2-4 главы, введение в тему)
- Средний (4-7 глав, хорошее покрытие)
- Длинный / полный (6-12 глав, глубокое погружение)
Остальные вопросы: целевая аудитория, глубина материала, специфические темы.
Ответь в формате JSON.`;
return this.withRetry(async () => {
const response = await this.client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Запрос пользователя: "${prompt}"` },
],
response_format: { type: 'json_object' },
temperature: 0.7,
});
log.response('generateClarifyingQuestions', {
prompt: response.usage?.prompt_tokens,
completion: response.usage?.completion_tokens,
});
const content = response.choices[0].message.content;
log.debug('Raw AI response:', content);
if (!content) {
log.error('Empty response from AI');
throw new Error('Empty response from AI');
}
const parsed = JSON.parse(content);
const validated = ClarifyingQuestionsSchema.parse(parsed);
log.success(`Generated ${validated.questions.length} clarifying questions`);
log.info('Questions:', validated.questions.map(q => q.question));
return validated;
}, 'generateClarifyingQuestions');
}
async generateCourseOutline(
prompt: string,
answers: Record<string, string | string[]>,
model: string
): Promise<CourseOutline> {
log.request('generateCourseOutline', model);
log.info(`Prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
log.info('User answers:', answers);
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя):
- Если пользователь выбрал короткий курс / введение: 24 главы, в каждой 24 урока. estimatedTotalHours: 28.
- Если средний курс: 47 глав, в каждой 35 уроков. estimatedTotalHours: 820.
- Если длинный / полный курс: 612 глав, в каждой 48 уроков. estimatedTotalHours: 1540.
- Если объём не указан — предложи средний (46 глав по 35 уроков).
Укажи примерное время на каждый урок (estimatedMinutes: 1045). estimatedTotalHours = сумма уроков.
Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги.
Ответь в формате JSON со структурой:
{
"title": "Название курса",
"description": "Подробное описание курса",
"chapters": [
{
"title": "Название главы",
"description": "Описание главы",
"lessons": [
{ "title": "Название урока", "estimatedMinutes": 25 }
]
}
],
"estimatedTotalHours": 20,
"difficulty": "beginner|intermediate|advanced",
"tags": ["тег1", "тег2"]
}`;
const userMessage = `Запрос: "${prompt}"
Ответы пользователя на уточняющие вопросы:
${Object.entries(answers)
.map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
.join('\n')}`;
return this.withRetry(async () => {
const response = await this.client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
response_format: { type: 'json_object' },
temperature: 0.7,
});
log.response('generateCourseOutline', {
prompt: response.usage?.prompt_tokens,
completion: response.usage?.completion_tokens,
});
const content = response.choices[0].message.content;
log.debug('Raw AI response:', content);
if (!content) {
log.error('Empty response from AI');
throw new Error('Empty response from AI');
}
const parsed = JSON.parse(content);
const validated = CourseOutlineSchema.parse(parsed);
const totalLessons = validated.chapters.reduce((acc, ch) => acc + ch.lessons.length, 0);
log.success(`Generated course outline: "${validated.title}"`);
log.info(`Structure: ${validated.chapters.length} chapters, ${totalLessons} lessons`);
log.info(`Difficulty: ${validated.difficulty}, Est. hours: ${validated.estimatedTotalHours}`);
return validated;
}, 'generateCourseOutline');
}
async generateLessonContent(
courseTitle: string,
chapterTitle: string,
lessonTitle: string,
context: {
difficulty: string;
targetAudience?: string;
},
model: string
): Promise<LessonContent> {
log.request('generateLessonContent', model);
log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`);
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 нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения
ОБЪЁМ: зависит от темы. Короткий урок: 300600 слов (35 блоков). Средний: 6001200 слов. Длинный: 12002500 слов. Структура: заголовок, введение, 24 секции с подзаголовками, примеры/код/списки, резюме.
Уровень: ${context.difficulty}. ${context.targetAudience ? `Аудитория: ${context.targetAudience}` : ''}
Ответь только валидным JSON:
{
"content": {
"type": "doc",
"content": [
{ "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Заголовок" }] },
{ "type": "paragraph", "content": [{ "type": "text", "text": "Текст..." }] }
]
}
}`;
return this.withRetry(async () => {
const response = await this.client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: `Курс: "${courseTitle}"
Глава: "${chapterTitle}"
Урок: "${lessonTitle}"
Создай содержание урока сразу в TipTap JSON: заголовки (heading), параграфы (paragraph), списки (bulletList/orderedList), цитаты (blockquote), блоки кода (codeBlock) и при необходимости Mermaid-диаграммы (codeBlock с "language": "mermaid"). Объём — по теме урока (короткий/средний/длинный).`,
},
],
response_format: { type: 'json_object' },
temperature: 0.7,
max_tokens: 8000,
});
log.response('generateLessonContent', {
prompt: response.usage?.prompt_tokens,
completion: response.usage?.completion_tokens,
});
const content = response.choices[0].message.content;
log.debug('Raw AI response:', content?.substring(0, 200) + '...');
if (!content) {
log.error('Empty response from AI');
throw new Error('Empty response from AI');
}
const parsed = JSON.parse(content);
const validated = LessonContentSchema.parse(parsed);
log.success(`Generated content for lesson: "${lessonTitle}"`);
return validated;
}, `generateLessonContent: ${lessonTitle}`);
}
async rewriteText(
text: string,
instruction: string,
model: string
): Promise<string> {
const response = await this.client.chat.completions.create({
model,
messages: [
{
role: 'system',
content:
'Ты - редактор образовательного контента. Переработай текст согласно инструкции пользователя.',
},
{
role: 'user',
content: `Исходный текст:
"${text}"
Инструкция: ${instruction}
Верни только переработанный текст без дополнительных пояснений.`,
},
],
temperature: 0.7,
});
return response.choices[0].message.content || text;
}
async chat(
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
model: string
): Promise<string> {
const response = await this.client.chat.completions.create({
model,
messages,
temperature: 0.7,
});
return response.choices[0].message.content || '';
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

8
apps/api/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

57
apps/api/package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "@coursecraft/api",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"clean": "rm -rf dist .turbo",
"dev": "nest start --watch",
"start": "node dist/main",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
},
"dependencies": {
"@coursecraft/database": "workspace:*",
"@coursecraft/shared": "workspace:*",
"@nestjs/bullmq": "^10.0.0",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/swagger": "^7.3.0",
"@supabase/supabase-js": "^2.39.0",
"bullmq": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^7.1.0",
"ioredis": "^5.3.0",
"meilisearch": "^0.37.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"stripe": "^14.14.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.1",
"jest": "^29.7.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.1.2",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.0"
}
}

View File

@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { join } from 'path';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { CoursesModule } from './courses/courses.module';
import { GenerationModule } from './generation/generation.module';
import { PaymentsModule } from './payments/payments.module';
import { SearchModule } from './search/search.module';
import { PrismaModule } from './common/prisma/prisma.module';
@Module({
imports: [
// Configuration - load from project root
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [
join(__dirname, '../../../.env.local'),
join(__dirname, '../../../.env'),
'.env.local',
'.env',
],
}),
// BullMQ for job queues
BullModule.forRoot({
connection: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6395', 10),
},
}),
// Database
PrismaModule,
// Feature modules
AuthModule,
UsersModule,
CoursesModule,
GenerationModule,
PaymentsModule,
SearchModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,43 @@
import {
Controller,
Post,
Get,
Body,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';
import { User } from '@coursecraft/database';
import { ExchangeTokenDto } from './dto/exchange-token.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@Post('exchange')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Exchange Supabase token for API token' })
async exchangeToken(@Body() dto: ExchangeTokenDto) {
const user = await this.authService.validateSupabaseToken(dto.supabaseToken);
return this.authService.generateTokens(user);
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user' })
async getCurrentUser(@CurrentUser() user: User) {
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl,
subscriptionTier: user.subscriptionTier,
createdAt: user.createdAt,
};
}
}

View File

@ -0,0 +1,40 @@
import { Module, Global } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { SupabaseService } from './supabase.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UsersModule } from '../users/users.module';
@Global()
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: {
expiresIn: '7d',
},
}),
inject: [ConfigService],
}),
UsersModule,
],
controllers: [AuthController],
providers: [
AuthService,
SupabaseService,
JwtAuthGuard,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
exports: [AuthService, SupabaseService, JwtAuthGuard],
})
export class AuthModule {}

View File

@ -0,0 +1,65 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SupabaseService } from './supabase.service';
import { UsersService } from '../users/users.service';
import { User } from '@coursecraft/database';
export interface JwtPayload {
sub: string; // user id
email: string;
supabaseId: string;
}
@Injectable()
export class AuthService {
constructor(
private supabaseService: SupabaseService,
private usersService: UsersService,
private jwtService: JwtService
) {}
async validateSupabaseToken(token: string): Promise<User> {
const supabaseUser = await this.supabaseService.verifyToken(token);
if (!supabaseUser) {
throw new UnauthorizedException('Invalid token');
}
// Find or create user in our database
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name: supabaseUser.user_metadata?.full_name || supabaseUser.user_metadata?.name || null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
return user;
}
async generateTokens(user: User) {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
supabaseId: user.supabaseId,
};
return {
accessToken: this.jwtService.sign(payload),
user: {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl,
subscriptionTier: user.subscriptionTier,
},
};
}
async validateJwtPayload(payload: JwtPayload): Promise<User | null> {
return this.usersService.findById(payload.sub);
}
}

View File

@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '@coursecraft/database';
export const CurrentUser = createParamDecorator(
(data: keyof User | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as User;
return data ? user?.[data] : user;
}
);

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,12 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ExchangeTokenDto {
@ApiProperty({
description: 'Supabase access token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
supabaseToken: string;
}

View File

@ -0,0 +1,83 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from '../auth.service';
import { SupabaseService } from '../supabase.service';
import { UsersService } from '../../users/users.service';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { JwtPayload } from '../auth.service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private reflector: Reflector,
private jwtService: JwtService,
private authService: AuthService,
private supabaseService: SupabaseService,
private usersService: UsersService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
const token = authHeader.replace('Bearer ', '');
try {
// 1) Try our own JWT (from POST /auth/exchange)
try {
const payload = this.jwtService.verify<JwtPayload>(token);
const user = await this.authService.validateJwtPayload(payload);
if (user) {
request.user = user;
return true;
}
} catch {
// Not our JWT or expired — try Supabase below
}
// 2) Fallback: Supabase access_token (for backward compatibility)
const supabaseUser = await this.supabaseService.verifyToken(token);
if (!supabaseUser) {
throw new UnauthorizedException('Invalid token');
}
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name:
supabaseUser.user_metadata?.full_name ||
supabaseUser.user_metadata?.name ||
null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
request.user = user;
return true;
} catch (error) {
if (error instanceof UnauthorizedException) throw error;
throw new UnauthorizedException('Token validation failed');
}
}
}

View File

@ -0,0 +1,72 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SupabaseService } from '../supabase.service';
import { UsersService } from '../../users/users.service';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class SupabaseAuthGuard implements CanActivate {
constructor(
private reflector: Reflector,
private supabaseService: SupabaseService,
private usersService: UsersService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Check if route is public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
const token = authHeader.replace('Bearer ', '');
try {
// Validate token with Supabase
const supabaseUser = await this.supabaseService.verifyToken(token);
if (!supabaseUser) {
throw new UnauthorizedException('Invalid token');
}
// Find or create user in our database
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
// Auto-create user on first API call
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name:
supabaseUser.user_metadata?.full_name ||
supabaseUser.user_metadata?.name ||
null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
// Attach user to request
request.user = user;
return true;
} catch (error) {
throw new UnauthorizedException('Token validation failed');
}
}
}

View File

@ -0,0 +1,62 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { SupabaseService } from '../supabase.service';
import { UsersService } from '../../users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private supabaseService: SupabaseService,
private usersService: UsersService
) {
// Use Supabase JWT secret for validation
const supabaseUrl = configService.get<string>('NEXT_PUBLIC_SUPABASE_URL');
const jwtSecret = configService.get<string>('SUPABASE_JWT_SECRET') ||
configService.get<string>('JWT_SECRET');
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtSecret,
// Pass the request to validate method
passReqToCallback: true,
});
}
async validate(req: any, payload: any) {
// Extract token from header
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new UnauthorizedException('No authorization header');
}
const token = authHeader.replace('Bearer ', '');
// Validate with Supabase
const supabaseUser = await this.supabaseService.verifyToken(token);
if (!supabaseUser) {
throw new UnauthorizedException('Invalid token');
}
// Find or create user in our database
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
if (!user) {
// Auto-create user on first API call
user = await this.usersService.create({
supabaseId: supabaseUser.id,
email: supabaseUser.email!,
name: supabaseUser.user_metadata?.full_name ||
supabaseUser.user_metadata?.name ||
null,
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
});
}
return user;
}
}

View File

@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, SupabaseClient, User as SupabaseUser } from '@supabase/supabase-js';
@Injectable()
export class SupabaseService {
private supabase: SupabaseClient;
constructor(private configService: ConfigService) {
const supabaseUrl = this.configService.get<string>('NEXT_PUBLIC_SUPABASE_URL');
const supabaseServiceKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('Supabase configuration is missing');
}
this.supabase = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
getClient(): SupabaseClient {
return this.supabase;
}
async verifyToken(token: string): Promise<SupabaseUser | null> {
try {
const { data, error } = await this.supabase.auth.getUser(token);
if (error || !data.user) {
return null;
}
return data.user;
} catch {
return null;
}
}
async getUserById(userId: string): Promise<SupabaseUser | null> {
try {
const { data, error } = await this.supabase.auth.admin.getUserById(userId);
if (error || !data.user) {
return null;
}
return data.user;
} catch {
return null;
}
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,19 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@coursecraft/database';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,67 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ChaptersService } from './chapters.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { CreateChapterDto } from './dto/create-chapter.dto';
import { UpdateChapterDto } from './dto/update-chapter.dto';
@ApiTags('chapters')
@Controller('courses/:courseId/chapters')
@ApiBearerAuth()
export class ChaptersController {
constructor(private chaptersService: ChaptersService) {}
@Post()
@ApiOperation({ summary: 'Create a new chapter' })
async create(
@Param('courseId') courseId: string,
@CurrentUser() user: User,
@Body() dto: CreateChapterDto
) {
return this.chaptersService.create(courseId, user.id, dto);
}
@Get()
@ApiOperation({ summary: 'Get all chapters for a course' })
async findAll(@Param('courseId') courseId: string) {
return this.chaptersService.findAllByCourse(courseId);
}
@Patch(':chapterId')
@ApiOperation({ summary: 'Update chapter' })
async update(
@Param('chapterId') chapterId: string,
@CurrentUser() user: User,
@Body() dto: UpdateChapterDto
) {
return this.chaptersService.update(chapterId, user.id, dto);
}
@Delete(':chapterId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete chapter' })
async delete(@Param('chapterId') chapterId: string, @CurrentUser() user: User) {
await this.chaptersService.delete(chapterId, user.id);
}
@Post('reorder')
@ApiOperation({ summary: 'Reorder chapters' })
async reorder(
@Param('courseId') courseId: string,
@CurrentUser() user: User,
@Body('chapterIds') chapterIds: string[]
) {
return this.chaptersService.reorder(courseId, user.id, chapterIds);
}
}

View File

@ -0,0 +1,131 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Chapter } from '@coursecraft/database';
import { CoursesService } from './courses.service';
import { CreateChapterDto } from './dto/create-chapter.dto';
import { UpdateChapterDto } from './dto/update-chapter.dto';
@Injectable()
export class ChaptersService {
constructor(
private prisma: PrismaService,
private coursesService: CoursesService
) {}
async create(courseId: string, userId: string, dto: CreateChapterDto): Promise<Chapter> {
// Check ownership
const isOwner = await this.coursesService.checkOwnership(courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Get max order
const maxOrder = await this.prisma.chapter.aggregate({
where: { courseId },
_max: { order: true },
});
const order = (maxOrder._max.order ?? -1) + 1;
return this.prisma.chapter.create({
data: {
courseId,
title: dto.title,
description: dto.description,
order,
},
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
});
}
async findAllByCourse(courseId: string): Promise<Chapter[]> {
return this.prisma.chapter.findMany({
where: { courseId },
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
});
}
async findById(id: string): Promise<Chapter | null> {
return this.prisma.chapter.findUnique({
where: { id },
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
});
}
async update(
chapterId: string,
userId: string,
dto: UpdateChapterDto
): Promise<Chapter> {
const chapter = await this.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.chapter.update({
where: { id: chapterId },
data: {
title: dto.title,
description: dto.description,
},
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
});
}
async delete(chapterId: string, userId: string): Promise<void> {
const chapter = await this.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await this.prisma.chapter.delete({
where: { id: chapterId },
});
}
async reorder(courseId: string, userId: string, chapterIds: string[]): Promise<Chapter[]> {
const isOwner = await this.coursesService.checkOwnership(courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Update order for each chapter
await Promise.all(
chapterIds.map((id, index) =>
this.prisma.chapter.update({
where: { id },
data: { order: index },
})
)
);
return this.findAllByCourse(courseId);
}
}

View File

@ -0,0 +1,78 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { CoursesService } from './courses.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User, CourseStatus } from '@coursecraft/database';
import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto';
@ApiTags('courses')
@Controller('courses')
@ApiBearerAuth()
export class CoursesController {
constructor(private coursesService: CoursesService) {}
@Post()
@ApiOperation({ summary: 'Create a new course' })
async create(@CurrentUser() user: User, @Body() dto: CreateCourseDto): Promise<any> {
return this.coursesService.create(user.id, dto);
}
@Get()
@ApiOperation({ summary: 'Get all courses for current user' })
@ApiQuery({ name: 'status', required: false, enum: CourseStatus })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async findAll(
@CurrentUser() user: User,
@Query('status') status?: CourseStatus,
@Query('page') page?: number,
@Query('limit') limit?: number
) {
return this.coursesService.findAllByAuthor(user.id, { status, page, limit });
}
@Get(':id')
@ApiOperation({ summary: 'Get course by ID' })
async findOne(@Param('id') id: string): Promise<any> {
return this.coursesService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update course' })
async update(
@Param('id') id: string,
@CurrentUser() user: User,
@Body() dto: UpdateCourseDto
): Promise<any> {
return this.coursesService.update(id, user.id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete course' })
async delete(@Param('id') id: string, @CurrentUser() user: User) {
await this.coursesService.delete(id, user.id);
}
@Patch(':id/status')
@ApiOperation({ summary: 'Update course status' })
async updateStatus(
@Param('id') id: string,
@CurrentUser() user: User,
@Body('status') status: CourseStatus
): Promise<any> {
return this.coursesService.updateStatus(id, user.id, status);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { CoursesController } from './courses.controller';
import { CoursesService } from './courses.service';
import { ChaptersController } from './chapters.controller';
import { ChaptersService } from './chapters.service';
import { LessonsController } from './lessons.controller';
import { LessonsService } from './lessons.service';
@Module({
controllers: [CoursesController, ChaptersController, LessonsController],
providers: [CoursesService, ChaptersService, LessonsService],
exports: [CoursesService, ChaptersService, LessonsService],
})
export class CoursesModule {}

View File

@ -0,0 +1,231 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Course, CourseStatus } from '@coursecraft/database';
import { generateUniqueSlug } from '@coursecraft/shared';
import { CreateCourseDto } from './dto/create-course.dto';
import { UpdateCourseDto } from './dto/update-course.dto';
@Injectable()
export class CoursesService {
constructor(private prisma: PrismaService) {}
async create(authorId: string, dto: CreateCourseDto): Promise<Course> {
const slug = generateUniqueSlug(dto.title);
return this.prisma.course.create({
data: {
authorId,
title: dto.title,
description: dto.description,
slug,
status: CourseStatus.DRAFT,
},
include: {
chapters: {
include: {
lessons: true,
},
orderBy: { order: 'asc' },
},
},
});
}
async findAllByAuthor(
authorId: string,
options?: {
status?: CourseStatus;
page?: number;
limit?: number;
}
) {
const page = options?.page || 1;
const limit = options?.limit || 10;
const skip = (page - 1) * limit;
const where = {
authorId,
...(options?.status && { status: options.status }),
};
const [courses, total] = await Promise.all([
this.prisma.course.findMany({
where,
include: {
_count: {
select: {
chapters: true,
},
},
chapters: {
include: {
_count: {
select: { lessons: true },
},
},
},
},
orderBy: { updatedAt: 'desc' },
skip,
take: limit,
}),
this.prisma.course.count({ where }),
]);
// Transform to include counts
const transformedCourses = courses.map((course) => ({
id: course.id,
title: course.title,
description: course.description,
slug: course.slug,
coverImage: course.coverImage,
status: course.status,
chaptersCount: course._count.chapters,
lessonsCount: course.chapters.reduce((acc, ch) => acc + ch._count.lessons, 0),
createdAt: course.createdAt,
updatedAt: course.updatedAt,
}));
return {
data: transformedCourses,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(id: string): Promise<Course | null> {
return this.prisma.course.findUnique({
where: { id },
include: {
author: {
select: {
id: true,
name: true,
avatarUrl: true,
},
},
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
category: true,
},
});
}
async findBySlug(slug: string): Promise<Course | null> {
return this.prisma.course.findUnique({
where: { slug },
include: {
author: {
select: {
id: true,
name: true,
avatarUrl: true,
},
},
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
},
});
}
async update(id: string, userId: string, dto: UpdateCourseDto): Promise<Course> {
const course = await this.findById(id);
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.course.update({
where: { id },
data: {
title: dto.title,
description: dto.description,
coverImage: dto.coverImage,
status: dto.status,
tags: dto.tags,
difficulty: dto.difficulty,
estimatedHours: dto.estimatedHours,
metaTitle: dto.metaTitle,
metaDescription: dto.metaDescription,
},
include: {
chapters: {
include: {
lessons: {
orderBy: { order: 'asc' },
},
},
orderBy: { order: 'asc' },
},
},
});
}
async delete(id: string, userId: string): Promise<void> {
const course = await this.findById(id);
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('You can only delete your own courses');
}
await this.prisma.course.delete({
where: { id },
});
}
async updateStatus(id: string, userId: string, status: CourseStatus): Promise<Course> {
const course = await this.findById(id);
if (!course) {
throw new NotFoundException('Course not found');
}
if (course.authorId !== userId) {
throw new ForbiddenException('You can only edit your own courses');
}
const updateData: { status: CourseStatus; publishedAt?: Date | null } = { status };
if (status === CourseStatus.PUBLISHED && !course.publishedAt) {
updateData.publishedAt = new Date();
}
return this.prisma.course.update({
where: { id },
data: updateData,
});
}
async checkOwnership(courseId: string, userId: string): Promise<boolean> {
const course = await this.prisma.course.findUnique({
where: { id: courseId },
select: { authorId: true },
});
return course?.authorId === userId;
}
}

View File

@ -0,0 +1,22 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class CreateChapterDto {
@ApiProperty({
description: 'Chapter title',
example: 'Introduction to Digital Marketing',
})
@IsString()
@MinLength(VALIDATION.CHAPTER.TITLE_MIN)
@MaxLength(VALIDATION.CHAPTER.TITLE_MAX)
title: string;
@ApiPropertyOptional({
description: 'Chapter description',
example: 'Learn the basics of digital marketing strategies',
})
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,25 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class CreateCourseDto {
@ApiProperty({
description: 'Course title',
example: 'Introduction to Marketing',
minLength: VALIDATION.COURSE.TITLE_MIN,
maxLength: VALIDATION.COURSE.TITLE_MAX,
})
@IsString()
@MinLength(VALIDATION.COURSE.TITLE_MIN)
@MaxLength(VALIDATION.COURSE.TITLE_MAX)
title: string;
@ApiPropertyOptional({
description: 'Course description',
example: 'Learn the fundamentals of digital marketing...',
})
@IsOptional()
@IsString()
@MaxLength(VALIDATION.COURSE.DESCRIPTION_MAX)
description?: string;
}

View File

@ -0,0 +1,30 @@
import { IsString, IsOptional, IsObject, IsNumber, MinLength, MaxLength, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class CreateLessonDto {
@ApiProperty({
description: 'Lesson title',
example: 'What is Digital Marketing?',
})
@IsString()
@MinLength(VALIDATION.LESSON.TITLE_MIN)
@MaxLength(VALIDATION.LESSON.TITLE_MAX)
title: string;
@ApiPropertyOptional({
description: 'Lesson content in TipTap JSON format',
})
@IsOptional()
@IsObject()
content?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Estimated duration in minutes',
example: 15,
})
@IsOptional()
@IsNumber()
@Min(0)
durationMinutes?: number;
}

View File

@ -0,0 +1,17 @@
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class UpdateChapterDto {
@ApiPropertyOptional({ description: 'Chapter title' })
@IsOptional()
@IsString()
@MinLength(VALIDATION.CHAPTER.TITLE_MIN)
@MaxLength(VALIDATION.CHAPTER.TITLE_MAX)
title?: string;
@ApiPropertyOptional({ description: 'Chapter description' })
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,71 @@
import {
IsString,
IsOptional,
IsArray,
IsNumber,
IsEnum,
IsUrl,
MinLength,
MaxLength,
Min,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { CourseStatus } from '@coursecraft/database';
import { VALIDATION } from '@coursecraft/shared';
export class UpdateCourseDto {
@ApiPropertyOptional({ description: 'Course title' })
@IsOptional()
@IsString()
@MinLength(VALIDATION.COURSE.TITLE_MIN)
@MaxLength(VALIDATION.COURSE.TITLE_MAX)
title?: string;
@ApiPropertyOptional({ description: 'Course description' })
@IsOptional()
@IsString()
@MaxLength(VALIDATION.COURSE.DESCRIPTION_MAX)
description?: string;
@ApiPropertyOptional({ description: 'Cover image URL' })
@IsOptional()
@IsUrl()
coverImage?: string;
@ApiPropertyOptional({ description: 'Course status', enum: CourseStatus })
@IsOptional()
@IsEnum(CourseStatus)
status?: CourseStatus;
@ApiPropertyOptional({ description: 'Course tags', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({
description: 'Course difficulty',
enum: ['beginner', 'intermediate', 'advanced'],
})
@IsOptional()
@IsString()
difficulty?: string;
@ApiPropertyOptional({ description: 'Estimated hours to complete' })
@IsOptional()
@IsNumber()
@Min(0)
estimatedHours?: number;
@ApiPropertyOptional({ description: 'SEO meta title' })
@IsOptional()
@IsString()
@MaxLength(100)
metaTitle?: string;
@ApiPropertyOptional({ description: 'SEO meta description' })
@IsOptional()
@IsString()
@MaxLength(300)
metaDescription?: string;
}

View File

@ -0,0 +1,34 @@
import { IsString, IsOptional, IsObject, IsNumber, IsUrl, MinLength, MaxLength, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class UpdateLessonDto {
@ApiPropertyOptional({ description: 'Lesson title' })
@IsOptional()
@IsString()
@MinLength(VALIDATION.LESSON.TITLE_MIN)
@MaxLength(VALIDATION.LESSON.TITLE_MAX)
title?: string;
@ApiPropertyOptional({
description: 'Lesson content in TipTap JSON format',
})
@IsOptional()
@IsObject()
content?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Estimated duration in minutes',
})
@IsOptional()
@IsNumber()
@Min(0)
durationMinutes?: number;
@ApiPropertyOptional({
description: 'Video URL for the lesson',
})
@IsOptional()
@IsUrl()
videoUrl?: string;
}

View File

@ -0,0 +1,67 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LessonsService } from './lessons.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { CreateLessonDto } from './dto/create-lesson.dto';
import { UpdateLessonDto } from './dto/update-lesson.dto';
@ApiTags('lessons')
@Controller('courses/:courseId')
@ApiBearerAuth()
export class LessonsController {
constructor(private lessonsService: LessonsService) {}
@Post('chapters/:chapterId/lessons')
@ApiOperation({ summary: 'Create a new lesson' })
async create(
@Param('chapterId') chapterId: string,
@CurrentUser() user: User,
@Body() dto: CreateLessonDto
): Promise<any> {
return this.lessonsService.create(chapterId, user.id, dto);
}
@Get('lessons/:lessonId')
@ApiOperation({ summary: 'Get lesson by ID' })
async findOne(@Param('lessonId') lessonId: string): Promise<any> {
return this.lessonsService.findById(lessonId);
}
@Patch('lessons/:lessonId')
@ApiOperation({ summary: 'Update lesson' })
async update(
@Param('lessonId') lessonId: string,
@CurrentUser() user: User,
@Body() dto: UpdateLessonDto
): Promise<any> {
return this.lessonsService.update(lessonId, user.id, dto);
}
@Delete('lessons/:lessonId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete lesson' })
async delete(@Param('lessonId') lessonId: string, @CurrentUser() user: User): Promise<void> {
await this.lessonsService.delete(lessonId, user.id);
}
@Post('chapters/:chapterId/lessons/reorder')
@ApiOperation({ summary: 'Reorder lessons in a chapter' })
async reorder(
@Param('chapterId') chapterId: string,
@CurrentUser() user: User,
@Body('lessonIds') lessonIds: string[]
): Promise<any> {
return this.lessonsService.reorder(chapterId, user.id, lessonIds);
}
}

View File

@ -0,0 +1,141 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { Lesson } from '@coursecraft/database';
import { CoursesService } from './courses.service';
import { ChaptersService } from './chapters.service';
import { CreateLessonDto } from './dto/create-lesson.dto';
import { UpdateLessonDto } from './dto/update-lesson.dto';
@Injectable()
export class LessonsService {
constructor(
private prisma: PrismaService,
private coursesService: CoursesService,
private chaptersService: ChaptersService
) {}
async create(chapterId: string, userId: string, dto: CreateLessonDto): Promise<Lesson> {
const chapter = await this.chaptersService.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
// Get max order
const maxOrder = await this.prisma.lesson.aggregate({
where: { chapterId },
_max: { order: true },
});
const order = (maxOrder._max.order ?? -1) + 1;
return this.prisma.lesson.create({
data: {
chapterId,
title: dto.title,
content: dto.content as any,
order,
durationMinutes: dto.durationMinutes,
},
});
}
async findById(id: string): Promise<Lesson | null> {
return this.prisma.lesson.findUnique({
where: { id },
include: {
chapter: {
select: {
id: true,
title: true,
courseId: true,
},
},
},
});
}
async update(lessonId: string, userId: string, dto: UpdateLessonDto): Promise<Lesson> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: {
chapter: {
select: { courseId: true },
},
},
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
return this.prisma.lesson.update({
where: { id: lessonId },
data: {
title: dto.title,
content: dto.content as any,
durationMinutes: dto.durationMinutes,
videoUrl: dto.videoUrl,
},
});
}
async delete(lessonId: string, userId: string): Promise<void> {
const lesson = await this.prisma.lesson.findUnique({
where: { id: lessonId },
include: {
chapter: {
select: { courseId: true },
},
},
});
if (!lesson) {
throw new NotFoundException('Lesson not found');
}
const isOwner = await this.coursesService.checkOwnership(lesson.chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await this.prisma.lesson.delete({
where: { id: lessonId },
});
}
async reorder(chapterId: string, userId: string, lessonIds: string[]): Promise<Lesson[]> {
const chapter = await this.chaptersService.findById(chapterId);
if (!chapter) {
throw new NotFoundException('Chapter not found');
}
const isOwner = await this.coursesService.checkOwnership(chapter.courseId, userId);
if (!isOwner) {
throw new ForbiddenException('You can only edit your own courses');
}
await Promise.all(
lessonIds.map((id, index) =>
this.prisma.lesson.update({
where: { id },
data: { order: index },
})
)
);
return this.prisma.lesson.findMany({
where: { chapterId },
orderBy: { order: 'asc' },
});
}
}

View File

@ -0,0 +1,15 @@
import { IsObject } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AnswerQuestionsDto {
@ApiProperty({
description: 'Object with question IDs as keys and answers as values',
example: {
target_audience: 'Начинающие',
course_depth: 'Стандартный',
specific_topics: 'React, TypeScript',
},
})
@IsObject()
answers!: Record<string, string | string[]>;
}

View File

@ -0,0 +1,16 @@
import { IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { VALIDATION } from '@coursecraft/shared';
export class StartGenerationDto {
@ApiProperty({
description: 'Prompt describing the course you want to create',
example: 'Сделай курс по маркетингу для начинающих',
minLength: VALIDATION.PROMPT.MIN,
maxLength: VALIDATION.PROMPT.MAX,
})
@IsString()
@MinLength(VALIDATION.PROMPT.MIN)
@MaxLength(VALIDATION.PROMPT.MAX)
prompt: string;
}

View File

@ -0,0 +1,90 @@
import {
Controller,
Get,
Post,
Body,
Param,
Sse,
MessageEvent,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Observable, interval, map, takeWhile, switchMap, from, startWith } from 'rxjs';
import { GenerationService } from './generation.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User, GenerationStatus } from '@coursecraft/database';
import { StartGenerationDto } from './dto/start-generation.dto';
import { AnswerQuestionsDto } from './dto/answer-questions.dto';
@ApiTags('generation')
@Controller('generation')
@ApiBearerAuth()
export class GenerationController {
constructor(private generationService: GenerationService) {}
@Post('start')
@ApiOperation({ summary: 'Start course generation' })
async startGeneration(
@CurrentUser() user: User,
@Body() dto: StartGenerationDto
): Promise<any> {
return this.generationService.startGeneration(user.id, dto);
}
@Get(':id/status')
@ApiOperation({ summary: 'Get generation status' })
async getStatus(
@Param('id') id: string,
@CurrentUser() user: User
): Promise<any> {
return this.generationService.getStatus(id, user.id);
}
@Sse(':id/stream')
@ApiOperation({ summary: 'Stream generation progress (SSE)' })
streamProgress(
@Param('id') id: string,
@CurrentUser() user: User
): Observable<MessageEvent> {
const terminalStatuses: string[] = [
GenerationStatus.COMPLETED,
GenerationStatus.FAILED,
GenerationStatus.CANCELLED,
];
let isComplete = false;
return interval(1000).pipe(
startWith(0),
switchMap(() => from(this.generationService.getStatus(id, user.id))),
takeWhile((status) => {
if (terminalStatuses.includes(status.status as string)) {
isComplete = true;
return true; // Include the final status
}
return !isComplete;
}, true),
map((status) => ({
data: JSON.stringify(status),
}))
);
}
@Post(':id/answer')
@ApiOperation({ summary: 'Answer clarifying questions' })
async answerQuestions(
@Param('id') id: string,
@CurrentUser() user: User,
@Body() dto: AnswerQuestionsDto
) {
return this.generationService.answerQuestions(id, user.id, dto);
}
@Post(':id/cancel')
@ApiOperation({ summary: 'Cancel generation' })
async cancelGeneration(
@Param('id') id: string,
@CurrentUser() user: User
) {
return this.generationService.cancelGeneration(id, user.id);
}
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { GenerationController } from './generation.controller';
import { GenerationService } from './generation.service';
import { UsersModule } from '../users/users.module';
import { CoursesModule } from '../courses/courses.module';
/**
* Only ai-service processes course-generation jobs (single worker).
* API only enqueues jobs via GenerationService.
*/
@Module({
imports: [
BullModule.registerQueue({
name: 'course-generation',
}),
UsersModule,
CoursesModule,
],
controllers: [GenerationController],
providers: [GenerationService],
exports: [GenerationService],
})
export class GenerationModule {}

View File

@ -0,0 +1,226 @@
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PrismaService } from '../common/prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { GenerationStatus, SubscriptionTier } from '@coursecraft/database';
import { SUBSCRIPTION_LIMITS } from '@coursecraft/shared';
import { StartGenerationDto } from './dto/start-generation.dto';
import { AnswerQuestionsDto } from './dto/answer-questions.dto';
@Injectable()
export class GenerationService {
constructor(
@InjectQueue('course-generation') private generationQueue: Queue,
private prisma: PrismaService,
private usersService: UsersService
) {}
async startGeneration(userId: string, dto: StartGenerationDto) {
// Check if user can create more courses (skip in dev/test when BYPASS_COURSE_LIMIT=true)
const bypassLimit = process.env.BYPASS_COURSE_LIMIT === 'true';
if (!bypassLimit) {
const canCreate = await this.usersService.canCreateCourse(userId);
if (!canCreate) {
throw new ForbiddenException('You have reached your monthly course limit');
}
}
// Get user's AI model preference
const settings = await this.usersService.getSettings(userId);
const user = await this.usersService.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
// Determine AI model to use
const tierLimits = SUBSCRIPTION_LIMITS[user.subscriptionTier as keyof typeof SUBSCRIPTION_LIMITS];
const aiModel = settings.customAiModel || tierLimits.defaultAiModel;
// Create generation record
const generation = await this.prisma.courseGeneration.create({
data: {
userId,
initialPrompt: dto.prompt,
aiModel,
status: GenerationStatus.PENDING,
},
});
// Add job to queue
const job = await this.generationQueue.add(
'generate-course',
{
generationId: generation.id,
userId,
prompt: dto.prompt,
aiModel,
},
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
}
);
// Update with job ID
await this.prisma.courseGeneration.update({
where: { id: generation.id },
data: { jobId: job.id },
});
return {
id: generation.id,
status: generation.status,
progress: 0,
};
}
async getStatus(generationId: string, userId: string): Promise<any> {
const generation = await this.prisma.courseGeneration.findUnique({
where: { id: generationId },
include: {
course: {
select: {
id: true,
slug: true,
},
},
},
});
if (!generation) {
throw new NotFoundException('Generation not found');
}
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
return {
id: generation.id,
status: generation.status,
progress: generation.progress,
currentStep: generation.currentStep,
questions: generation.questions,
generatedOutline: generation.generatedOutline,
errorMessage: generation.errorMessage,
course: generation.course,
};
}
async answerQuestions(generationId: string, userId: string, dto: AnswerQuestionsDto) {
const generation = await this.prisma.courseGeneration.findUnique({
where: { id: generationId },
});
if (!generation) {
throw new NotFoundException('Generation not found');
}
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (generation.status !== GenerationStatus.WAITING_FOR_ANSWERS) {
throw new BadRequestException('Generation is not waiting for answers');
}
// Save answers and continue generation
await this.prisma.courseGeneration.update({
where: { id: generationId },
data: {
answers: dto.answers as any,
status: GenerationStatus.RESEARCHING,
},
});
// Continue the job
await this.generationQueue.add(
'continue-generation',
{
generationId,
stage: 'after-questions',
},
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
}
);
return { success: true };
}
async cancelGeneration(generationId: string, userId: string) {
const generation = await this.prisma.courseGeneration.findUnique({
where: { id: generationId },
});
if (!generation) {
throw new NotFoundException('Generation not found');
}
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Cancel the job if possible
if (generation.jobId) {
const job = await this.generationQueue.getJob(generation.jobId);
if (job) {
await job.remove();
}
}
await this.prisma.courseGeneration.update({
where: { id: generationId },
data: {
status: GenerationStatus.CANCELLED,
},
});
return { success: true };
}
async updateProgress(
generationId: string,
status: GenerationStatus,
progress: number,
currentStep?: string,
additionalData?: {
questions?: unknown;
generatedOutline?: unknown;
errorMessage?: string;
}
): Promise<any> {
const updateData: Record<string, unknown> = {
status,
progress,
currentStep,
};
if (additionalData?.questions) {
updateData.questions = additionalData.questions;
}
if (additionalData?.generatedOutline) {
updateData.generatedOutline = additionalData.generatedOutline;
}
if (additionalData?.errorMessage) {
updateData.errorMessage = additionalData.errorMessage;
}
if (status === GenerationStatus.COMPLETED) {
updateData.completedAt = new Date();
}
return this.prisma.courseGeneration.update({
where: { id: generationId },
data: updateData,
});
}
}

60
apps/api/src/main.ts Normal file
View File

@ -0,0 +1,60 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Security
app.use(helmet());
// CORS (веб на порту 3125)
const allowedOrigins = [
configService.get('NEXT_PUBLIC_APP_URL'),
'http://localhost:3125',
'http://localhost:3000',
].filter(Boolean) as string[];
app.enableCors({
origin: allowedOrigins.length ? allowedOrigins : 'http://localhost:3125',
credentials: true,
});
// Global prefix
app.setGlobalPrefix('api');
// Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
// Swagger
if (configService.get('NODE_ENV') !== 'production') {
const config = new DocumentBuilder()
.setTitle('CourseCraft API')
.setDescription('AI-powered course creation platform API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
}
const port = configService.get('PORT') || 3001;
await app.listen(port);
console.log(`🚀 API is running on: http://localhost:${port}/api`);
console.log(`📚 Swagger docs: http://localhost:${port}/docs`);
}
bootstrap();

View File

@ -0,0 +1,41 @@
import {
Controller,
Get,
Post,
Body,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { User } from '@coursecraft/database';
@ApiTags('subscriptions')
@Controller('subscriptions')
export class PaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Get('plans')
@Public()
@ApiOperation({ summary: 'Get available subscription plans' })
async getPlans() {
return this.paymentsService.getPlans();
}
@Post('checkout')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create Stripe checkout session' })
async createCheckoutSession(
@CurrentUser() user: User,
@Body('tier') tier: 'PREMIUM' | 'PRO'
) {
return this.paymentsService.createCheckoutSession(user.id, tier);
}
@Post('portal')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create Stripe customer portal session' })
async createPortalSession(@CurrentUser() user: User) {
return this.paymentsService.createPortalSession(user.id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { StripeService } from './stripe.service';
import { WebhooksController } from './webhooks.controller';
@Module({
controllers: [PaymentsController, WebhooksController],
providers: [PaymentsService, StripeService],
exports: [PaymentsService, StripeService],
})
export class PaymentsModule {}

View File

@ -0,0 +1,231 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../common/prisma/prisma.service';
import { StripeService } from './stripe.service';
import { SubscriptionTier } from '@coursecraft/database';
import { SUBSCRIPTION_PLANS } from '@coursecraft/shared';
@Injectable()
export class PaymentsService {
constructor(
private prisma: PrismaService,
private stripeService: StripeService,
private configService: ConfigService
) {}
async getPlans() {
return SUBSCRIPTION_PLANS.map((plan) => ({
tier: plan.tier,
name: plan.name,
nameRu: plan.nameRu,
description: plan.description,
descriptionRu: plan.descriptionRu,
price: plan.price,
currency: plan.currency,
features: plan.features,
featuresRu: plan.featuresRu,
}));
}
async createCheckoutSession(userId: string, tier: 'PREMIUM' | 'PRO') {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { subscription: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
// Get or create Stripe customer
let stripeCustomerId = user.subscription?.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await this.stripeService.createCustomer(user.email, user.name || undefined);
stripeCustomerId = customer.id;
await this.prisma.subscription.upsert({
where: { userId },
create: {
userId,
tier: SubscriptionTier.FREE,
stripeCustomerId: customer.id,
},
update: {
stripeCustomerId: customer.id,
},
});
}
// Get price ID for tier
const priceId =
tier === 'PREMIUM'
? this.configService.get<string>('STRIPE_PRICE_PREMIUM')
: this.configService.get<string>('STRIPE_PRICE_PRO');
if (!priceId) {
throw new Error(`Price ID not configured for tier: ${tier}`);
}
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000';
const session = await this.stripeService.createCheckoutSession({
customerId: stripeCustomerId,
priceId,
successUrl: `${appUrl}/dashboard/billing?success=true`,
cancelUrl: `${appUrl}/dashboard/billing?canceled=true`,
metadata: {
userId,
tier,
},
});
return { url: session.url };
}
async createPortalSession(userId: string) {
const subscription = await this.prisma.subscription.findUnique({
where: { userId },
});
if (!subscription?.stripeCustomerId) {
throw new NotFoundException('No subscription found');
}
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000';
const session = await this.stripeService.createPortalSession(
subscription.stripeCustomerId,
`${appUrl}/dashboard/billing`
);
return { url: session.url };
}
async handleWebhookEvent(event: { type: string; data: { object: unknown } }) {
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as {
customer: string;
subscription: string;
metadata: { userId: string; tier: string };
});
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as {
id: string;
customer: string;
status: string;
current_period_end: number;
cancel_at_period_end: boolean;
items: { data: Array<{ price: { id: string } }> };
});
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as {
customer: string;
});
break;
}
}
private async handleCheckoutCompleted(session: {
customer: string;
subscription: string;
metadata: { userId: string; tier: string };
}) {
const { customer, subscription: subscriptionId, metadata } = session;
const tier = metadata.tier as SubscriptionTier;
const stripeSubscription = await this.stripeService.getSubscription(subscriptionId);
await this.prisma.subscription.update({
where: { stripeCustomerId: customer },
data: {
tier,
stripeSubscriptionId: subscriptionId,
stripePriceId: stripeSubscription.items.data[0]?.price.id,
status: 'active',
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
coursesCreatedThisMonth: 0, // Reset on new subscription
},
});
// Update user's subscription tier
await this.prisma.user.update({
where: { id: metadata.userId },
data: { subscriptionTier: tier },
});
}
private async handleSubscriptionUpdated(subscription: {
id: string;
customer: string;
status: string;
current_period_end: number;
cancel_at_period_end: boolean;
items: { data: Array<{ price: { id: string } }> };
}) {
const priceId = subscription.items.data[0]?.price.id;
// Determine tier from price ID
const premiumPriceId = this.configService.get<string>('STRIPE_PRICE_PREMIUM');
const proPriceId = this.configService.get<string>('STRIPE_PRICE_PRO');
let tier: SubscriptionTier = SubscriptionTier.FREE;
if (priceId === premiumPriceId) tier = SubscriptionTier.PREMIUM;
else if (priceId === proPriceId) tier = SubscriptionTier.PRO;
await this.prisma.subscription.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
tier,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
stripePriceId: priceId,
},
});
// Update user's tier
const sub = await this.prisma.subscription.findUnique({
where: { stripeCustomerId: subscription.customer as string },
});
if (sub) {
await this.prisma.user.update({
where: { id: sub.userId },
data: { subscriptionTier: tier },
});
}
}
private async handleSubscriptionDeleted(subscription: { customer: string }) {
await this.prisma.subscription.update({
where: { stripeCustomerId: subscription.customer },
data: {
tier: SubscriptionTier.FREE,
status: 'canceled',
stripeSubscriptionId: null,
stripePriceId: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
},
});
// Update user's tier
const sub = await this.prisma.subscription.findUnique({
where: { stripeCustomerId: subscription.customer },
});
if (sub) {
await this.prisma.user.update({
where: { id: sub.userId },
data: { subscriptionTier: SubscriptionTier.FREE },
});
}
}
}

View File

@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
@Injectable()
export class StripeService {
private stripe: Stripe;
constructor(private configService: ConfigService) {
this.stripe = new Stripe(this.configService.get<string>('STRIPE_SECRET_KEY')!, {
apiVersion: '2023-10-16',
});
}
getClient(): Stripe {
return this.stripe;
}
async createCustomer(email: string, name?: string): Promise<Stripe.Customer> {
return this.stripe.customers.create({
email,
name: name || undefined,
});
}
async createCheckoutSession(params: {
customerId: string;
priceId: string;
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Checkout.Session> {
return this.stripe.checkout.sessions.create({
customer: params.customerId,
mode: 'subscription',
line_items: [
{
price: params.priceId,
quantity: 1,
},
],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata,
});
}
async createPortalSession(customerId: string, returnUrl: string): Promise<Stripe.BillingPortal.Session> {
return this.stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
}
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.retrieve(subscriptionId);
}
async cancelSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event {
const webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET')!;
return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
}
}

View File

@ -0,0 +1,52 @@
import {
Controller,
Post,
Headers,
Req,
HttpCode,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger';
import { Request } from 'express';
import { PaymentsService } from './payments.service';
import { StripeService } from './stripe.service';
@ApiTags('webhooks')
@Controller('webhooks')
export class WebhooksController {
constructor(
private paymentsService: PaymentsService,
private stripeService: StripeService
) {}
@Post('stripe')
@HttpCode(HttpStatus.OK)
@ApiExcludeEndpoint()
@ApiOperation({ summary: 'Handle Stripe webhooks' })
async handleStripeWebhook(
@Req() req: Request,
@Headers('stripe-signature') signature: string
) {
if (!signature) {
throw new BadRequestException('Missing stripe-signature header');
}
let event;
try {
// req.body should be raw buffer for webhook verification
const rawBody = (req as Request & { rawBody?: Buffer }).rawBody;
if (!rawBody) {
throw new BadRequestException('Missing raw body');
}
event = this.stripeService.constructWebhookEvent(rawBody, signature);
} catch (err) {
throw new BadRequestException(`Webhook signature verification failed: ${err}`);
}
await this.paymentsService.handleWebhookEvent(event);
return { received: true };
}
}

View File

@ -0,0 +1,156 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MeiliSearch, Index } from 'meilisearch';
export interface CourseDocument {
id: string;
title: string;
description: string | null;
slug: string;
authorId: string;
authorName: string | null;
status: string;
categoryId: string | null;
categoryName: string | null;
tags: string[];
difficulty: string | null;
price: number | null;
isPublished: boolean;
createdAt: number;
updatedAt: number;
}
@Injectable()
export class MeilisearchService implements OnModuleInit {
private readonly logger = new Logger(MeilisearchService.name);
private client: MeiliSearch;
private coursesIndex: Index<CourseDocument> | null = null;
private isAvailable = false;
constructor(private configService: ConfigService) {
this.client = new MeiliSearch({
host: this.configService.get<string>('MEILISEARCH_HOST') || 'http://localhost:7700',
apiKey: this.configService.get<string>('MEILISEARCH_API_KEY'),
});
}
async onModuleInit() {
try {
await this.setupIndexes();
this.isAvailable = true;
this.logger.log('Meilisearch connected successfully');
} catch (error) {
this.logger.warn('Meilisearch is not available. Search functionality will be disabled.');
this.isAvailable = false;
}
}
private async setupIndexes() {
// Create courses index
try {
await this.client.createIndex('courses', { primaryKey: 'id' });
} catch {
// Index might already exist
}
this.coursesIndex = this.client.index<CourseDocument>('courses');
// Configure searchable attributes
await this.coursesIndex.updateSearchableAttributes([
'title',
'description',
'tags',
'authorName',
'categoryName',
]);
// Configure filterable attributes
await this.coursesIndex.updateFilterableAttributes([
'authorId',
'status',
'categoryId',
'tags',
'difficulty',
'isPublished',
'price',
]);
// Configure sortable attributes
await this.coursesIndex.updateSortableAttributes([
'createdAt',
'updatedAt',
'price',
'title',
]);
// Configure ranking rules
await this.coursesIndex.updateRankingRules([
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
]);
}
async indexCourse(course: CourseDocument): Promise<void> {
if (!this.isAvailable || !this.coursesIndex) return;
await this.coursesIndex.addDocuments([course]);
}
async updateCourse(course: CourseDocument): Promise<void> {
if (!this.isAvailable || !this.coursesIndex) return;
await this.coursesIndex.updateDocuments([course]);
}
async deleteCourse(courseId: string): Promise<void> {
if (!this.isAvailable || !this.coursesIndex) return;
await this.coursesIndex.deleteDocument(courseId);
}
async searchCourses(
query: string,
options?: {
filter?: string;
sort?: string[];
limit?: number;
offset?: number;
}
) {
if (!this.isAvailable || !this.coursesIndex) {
return {
hits: [],
query,
processingTimeMs: 0,
total: 0,
};
}
const results = await this.coursesIndex.search(query, {
filter: options?.filter,
sort: options?.sort,
limit: options?.limit || 20,
offset: options?.offset || 0,
attributesToHighlight: ['title', 'description'],
});
return {
hits: results.hits,
query: results.query,
processingTimeMs: results.processingTimeMs,
total: results.estimatedTotalHits,
};
}
async indexAllCourses(courses: CourseDocument[]): Promise<void> {
if (!this.isAvailable || !this.coursesIndex || courses.length === 0) return;
// Batch index in chunks of 1000
const chunkSize = 1000;
for (let i = 0; i < courses.length; i += chunkSize) {
const chunk = courses.slice(i, i + chunkSize);
await this.coursesIndex.addDocuments(chunk);
}
}
}

View File

@ -0,0 +1,58 @@
import {
Controller,
Get,
Post,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { SearchService } from './search.service';
import { Public } from '../auth/decorators/public.decorator';
@ApiTags('search')
@Controller('search')
export class SearchController {
constructor(private searchService: SearchService) {}
@Get('courses')
@Public()
@ApiOperation({ summary: 'Search courses' })
@ApiQuery({ name: 'q', required: true, description: 'Search query' })
@ApiQuery({ name: 'categoryId', required: false })
@ApiQuery({ name: 'difficulty', required: false, enum: ['beginner', 'intermediate', 'advanced'] })
@ApiQuery({ name: 'tags', required: false, type: [String] })
@ApiQuery({ name: 'priceMin', required: false, type: Number })
@ApiQuery({ name: 'priceMax', required: false, type: Number })
@ApiQuery({ name: 'sort', required: false, enum: ['newest', 'oldest', 'price_asc', 'price_desc', 'title_asc', 'title_desc'] })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async searchCourses(
@Query('q') query: string,
@Query('categoryId') categoryId?: string,
@Query('difficulty') difficulty?: string,
@Query('tags') tags?: string[],
@Query('priceMin') priceMin?: number,
@Query('priceMax') priceMax?: number,
@Query('sort') sort?: string,
@Query('page') page?: number,
@Query('limit') limit?: number
) {
return this.searchService.searchCourses(query, {
categoryId,
difficulty,
tags: tags ? (Array.isArray(tags) ? tags : [tags]) : undefined,
priceMin,
priceMax,
sort,
page,
limit,
publishedOnly: true,
});
}
@Post('reindex')
@ApiBearerAuth()
@ApiOperation({ summary: 'Reindex all courses (admin only)' })
async reindexAll() {
return this.searchService.reindexAllCourses();
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { MeilisearchService } from './meilisearch.service';
@Module({
controllers: [SearchController],
providers: [SearchService, MeilisearchService],
exports: [SearchService, MeilisearchService],
})
export class SearchModule {}

View File

@ -0,0 +1,181 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { MeilisearchService, CourseDocument } from './meilisearch.service';
@Injectable()
export class SearchService {
constructor(
private prisma: PrismaService,
private meilisearch: MeilisearchService
) {}
async searchCourses(
query: string,
options?: {
categoryId?: string;
difficulty?: string;
tags?: string[];
priceMin?: number;
priceMax?: number;
authorId?: string;
publishedOnly?: boolean;
sort?: string;
page?: number;
limit?: number;
}
) {
const filters: string[] = [];
if (options?.publishedOnly !== false) {
filters.push('isPublished = true');
}
if (options?.categoryId) {
filters.push(`categoryId = "${options.categoryId}"`);
}
if (options?.difficulty) {
filters.push(`difficulty = "${options.difficulty}"`);
}
if (options?.tags && options.tags.length > 0) {
const tagFilters = options.tags.map((tag) => `tags = "${tag}"`).join(' OR ');
filters.push(`(${tagFilters})`);
}
if (options?.priceMin !== undefined) {
filters.push(`price >= ${options.priceMin}`);
}
if (options?.priceMax !== undefined) {
filters.push(`price <= ${options.priceMax}`);
}
if (options?.authorId) {
filters.push(`authorId = "${options.authorId}"`);
}
// Parse sort option
let sort: string[] | undefined;
if (options?.sort) {
switch (options.sort) {
case 'newest':
sort = ['createdAt:desc'];
break;
case 'oldest':
sort = ['createdAt:asc'];
break;
case 'price_asc':
sort = ['price:asc'];
break;
case 'price_desc':
sort = ['price:desc'];
break;
case 'title_asc':
sort = ['title:asc'];
break;
case 'title_desc':
sort = ['title:desc'];
break;
}
}
const page = options?.page || 1;
const limit = options?.limit || 20;
const offset = (page - 1) * limit;
const results = await this.meilisearch.searchCourses(query, {
filter: filters.length > 0 ? filters.join(' AND ') : undefined,
sort,
limit,
offset,
});
return {
data: results.hits,
meta: {
query: results.query,
processingTimeMs: results.processingTimeMs,
total: results.total,
page,
limit,
totalPages: Math.ceil((results.total || 0) / limit),
},
};
}
async indexCourse(courseId: string) {
const course = await this.prisma.course.findUnique({
where: { id: courseId },
include: {
author: {
select: { id: true, name: true },
},
category: {
select: { id: true, name: true },
},
},
});
if (!course) return;
const document: CourseDocument = {
id: course.id,
title: course.title,
description: course.description,
slug: course.slug,
authorId: course.authorId,
authorName: course.author.name,
status: course.status,
categoryId: course.categoryId,
categoryName: course.category?.name || null,
tags: course.tags,
difficulty: course.difficulty,
price: course.price ? Number(course.price) : null,
isPublished: course.isPublished,
createdAt: course.createdAt.getTime(),
updatedAt: course.updatedAt.getTime(),
};
await this.meilisearch.indexCourse(document);
}
async deleteCourseFromIndex(courseId: string) {
await this.meilisearch.deleteCourse(courseId);
}
async reindexAllCourses() {
const courses = await this.prisma.course.findMany({
include: {
author: {
select: { id: true, name: true },
},
category: {
select: { id: true, name: true },
},
},
});
const documents: CourseDocument[] = courses.map((course) => ({
id: course.id,
title: course.title,
description: course.description,
slug: course.slug,
authorId: course.authorId,
authorName: course.author.name,
status: course.status,
categoryId: course.categoryId,
categoryName: course.category?.name || null,
tags: course.tags,
difficulty: course.difficulty,
price: course.price ? Number(course.price) : null,
isPublished: course.isPublished,
createdAt: course.createdAt.getTime(),
updatedAt: course.updatedAt.getTime(),
}));
await this.meilisearch.indexAllCourses(documents);
return { indexed: documents.length };
}
}

View File

@ -0,0 +1,17 @@
import { IsString, IsEmail, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsString()
supabaseId: string;
@IsEmail()
email: string;
@IsOptional()
@IsString()
name?: string | null;
@IsOptional()
@IsString()
avatarUrl?: string | null;
}

View File

@ -0,0 +1,35 @@
import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateSettingsDto {
@ApiPropertyOptional({
description: 'Custom AI model override (e.g., "qwen/qwen3-coder-next")',
example: 'anthropic/claude-3.5-sonnet',
})
@IsOptional()
@IsString()
customAiModel?: string;
@ApiPropertyOptional({ description: 'Enable email notifications' })
@IsOptional()
@IsBoolean()
emailNotifications?: boolean;
@ApiPropertyOptional({ description: 'Enable marketing emails' })
@IsOptional()
@IsBoolean()
marketingEmails?: boolean;
@ApiPropertyOptional({
description: 'UI theme',
enum: ['light', 'dark', 'system'],
})
@IsOptional()
@IsIn(['light', 'dark', 'system'])
theme?: 'light' | 'dark' | 'system';
@ApiPropertyOptional({ description: 'Preferred language' })
@IsOptional()
@IsString()
language?: string;
}

View File

@ -0,0 +1,14 @@
import { IsString, IsOptional, IsUrl } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateUserDto {
@ApiPropertyOptional({ description: 'User display name' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: 'User avatar URL' })
@IsOptional()
@IsUrl()
avatarUrl?: string;
}

View File

@ -0,0 +1,60 @@
import {
Controller,
Get,
Patch,
Body,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { User } from '@coursecraft/database';
import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateSettingsDto } from './dto/update-settings.dto';
@ApiTags('users')
@Controller('users')
@ApiBearerAuth()
export class UsersController {
constructor(private usersService: UsersService) {}
@Get('profile')
@ApiOperation({ summary: 'Get user profile' })
async getProfile(@CurrentUser() user: User) {
const fullUser = await this.usersService.findById(user.id);
const subscription = await this.usersService.getSubscriptionInfo(user.id);
return {
id: fullUser!.id,
email: fullUser!.email,
name: fullUser!.name,
avatarUrl: fullUser!.avatarUrl,
subscriptionTier: fullUser!.subscriptionTier,
subscription,
createdAt: fullUser!.createdAt,
};
}
@Patch('profile')
@ApiOperation({ summary: 'Update user profile' })
async updateProfile(
@CurrentUser() user: User,
@Body() dto: UpdateUserDto
) {
return this.usersService.update(user.id, dto);
}
@Get('settings')
@ApiOperation({ summary: 'Get user settings' })
async getSettings(@CurrentUser() user: User) {
return this.usersService.getSettings(user.id);
}
@Patch('settings')
@ApiOperation({ summary: 'Update user settings' })
async updateSettings(
@CurrentUser() user: User,
@Body() dto: UpdateSettingsDto
) {
return this.usersService.updateSettings(user.id, dto);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,150 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.service';
import { User, UserSettings, SubscriptionTier } from '@coursecraft/database';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateSettingsDto } from './dto/update-settings.dto';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async create(data: CreateUserDto): Promise<User> {
return this.prisma.user.create({
data: {
supabaseId: data.supabaseId,
email: data.email,
name: data.name,
avatarUrl: data.avatarUrl,
settings: {
create: {}, // Create default settings
},
subscription: {
create: {
tier: SubscriptionTier.FREE,
},
},
},
include: {
settings: true,
subscription: true,
},
});
}
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: { id },
include: {
settings: true,
subscription: true,
},
});
}
async findBySupabaseId(supabaseId: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: { supabaseId },
include: {
settings: true,
subscription: true,
},
});
}
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: { email },
});
}
async update(id: string, data: UpdateUserDto): Promise<User> {
const user = await this.findById(id);
if (!user) {
throw new NotFoundException('User not found');
}
return this.prisma.user.update({
where: { id },
data: {
name: data.name,
avatarUrl: data.avatarUrl,
},
});
}
async getSettings(userId: string): Promise<UserSettings> {
const settings = await this.prisma.userSettings.findUnique({
where: { userId },
});
if (!settings) {
// Create default settings if not found
return this.prisma.userSettings.create({
data: { userId },
});
}
return settings;
}
async updateSettings(userId: string, data: UpdateSettingsDto): Promise<UserSettings> {
return this.prisma.userSettings.upsert({
where: { userId },
create: {
userId,
...data,
},
update: data,
});
}
async getSubscriptionInfo(userId: string) {
const subscription = await this.prisma.subscription.findUnique({
where: { userId },
});
if (!subscription) {
// Return default FREE subscription info
return {
tier: SubscriptionTier.FREE,
status: 'active',
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
coursesCreatedThisMonth: 0,
coursesLimit: 2,
};
}
const limits = {
[SubscriptionTier.FREE]: 2,
[SubscriptionTier.PREMIUM]: 5,
[SubscriptionTier.PRO]: 15,
};
return {
tier: subscription.tier,
status: subscription.status,
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
coursesCreatedThisMonth: subscription.coursesCreatedThisMonth,
coursesLimit: limits[subscription.tier],
};
}
async canCreateCourse(userId: string): Promise<boolean> {
const subscription = await this.getSubscriptionInfo(userId);
return subscription.coursesCreatedThisMonth < subscription.coursesLimit;
}
async incrementCoursesCreated(userId: string): Promise<void> {
await this.prisma.subscription.update({
where: { userId },
data: {
coursesCreatedThisMonth: {
increment: 1,
},
},
});
}
}

18
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"baseUrl": "./",
"module": "CommonJS",
"moduleResolution": "Node",
"isolatedModules": false,
"strictPropertyInitialization": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

5
apps/web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

19
apps/web/next.config.js Normal file
View File

@ -0,0 +1,19 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ['@coursecraft/shared'],
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.supabase.co',
},
{
protocol: 'https',
hostname: '**.r2.cloudflarestorage.com',
},
],
},
};
module.exports = nextConfig;

68
apps/web/package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "@coursecraft/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3125",
"build": "next build",
"start": "next start -p 3125",
"lint": "next lint",
"clean": "rm -rf .next .turbo node_modules"
},
"dependencies": {
"@coursecraft/shared": "workspace:*",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.39.0",
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-image": "^2.2.0",
"@tiptap/extension-link": "^2.2.0",
"@tiptap/extension-placeholder": "^2.2.0",
"@tiptap/extension-underline": "^2.2.0",
"@tiptap/pm": "^2.2.0",
"@tiptap/react": "^2.2.0",
"@tiptap/starter-kit": "^2.2.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^11.0.0",
"lucide-react": "^0.334.0",
"mermaid": "^11.12.2",
"next": "14.1.0",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.50.0",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.17",
"eslint": "^8.57.0",
"eslint-config-next": "14.1.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,13 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30">
<div className="w-full max-w-md p-6">
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,133 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Sparkles, Mail, Lock, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/components/ui/use-toast';
import { useAuth } from '@/contexts/auth-context';
export default function LoginPage() {
const router = useRouter();
const { toast } = useToast();
const { signIn } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const { error } = await signIn(email, password);
if (error) {
toast({
title: 'Ошибка входа',
description: error.message || 'Неверный email или пароль.',
variant: 'destructive',
});
return;
}
toast({
title: 'Добро пожаловать!',
description: 'Вы успешно вошли в систему.',
});
router.push('/dashboard');
} catch (error) {
toast({
title: 'Ошибка',
description: 'Произошла непредвиденная ошибка.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader className="text-center">
<Link href="/" className="flex items-center justify-center gap-2 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">CourseCraft</span>
</Link>
<CardTitle>Вход в аккаунт</CardTitle>
<CardDescription>
Введите email и пароль для входа
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Пароль</Label>
<Link
href="/forgot-password"
className="text-sm text-primary hover:underline"
>
Забыли пароль?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="••••••••"
className="pl-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Вход...
</>
) : (
'Войти'
)}
</Button>
<p className="text-sm text-center text-muted-foreground">
Нет аккаунта?{' '}
<Link href="/register" className="text-primary hover:underline">
Зарегистрироваться
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

View File

@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Sparkles, Mail, Lock, User, Loader2, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/components/ui/use-toast';
import { useAuth } from '@/contexts/auth-context';
export default function RegisterPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const { signUp } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const plan = searchParams.get('plan');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const { error } = await signUp(email, password, name);
if (error) {
toast({
title: 'Ошибка регистрации',
description: error.message || 'Не удалось создать аккаунт.',
variant: 'destructive',
});
return;
}
toast({
title: 'Аккаунт создан!',
description: 'Проверьте email для подтверждения (если включено) или войдите в систему.',
});
router.push('/dashboard');
} catch (error) {
toast({
title: 'Ошибка',
description: 'Произошла непредвиденная ошибка.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader className="text-center">
<Link href="/" className="flex items-center justify-center gap-2 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">CourseCraft</span>
</Link>
<CardTitle>Создать аккаунт</CardTitle>
<CardDescription>
{plan ? (
<>Регистрация с планом <span className="font-medium capitalize">{plan}</span></>
) : (
'Начните создавать курсы с AI бесплатно'
)}
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Имя</Label>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="name"
type="text"
placeholder="Ваше имя"
className="pl-10"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Пароль</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="Минимум 6 символов"
className="pl-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={6}
required
disabled={isLoading}
/>
</div>
</div>
{/* Benefits */}
<div className="rounded-lg bg-muted p-4 space-y-2">
<p className="text-sm font-medium">Что вы получите:</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
2 бесплатных курса в месяц
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
AI-генерация контента
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
WYSIWYG редактор
</li>
</ul>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Создание аккаунта...
</>
) : (
'Создать аккаунт'
)}
</Button>
<p className="text-sm text-center text-muted-foreground">
Уже есть аккаунт?{' '}
<Link href="/login" className="text-primary hover:underline">
Войти
</Link>
</p>
<p className="text-xs text-center text-muted-foreground">
Регистрируясь, вы соглашаетесь с{' '}
<Link href="/terms" className="underline">
условиями использования
</Link>{' '}
и{' '}
<Link href="/privacy" className="underline">
политикой конфиденциальности
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

View File

@ -0,0 +1,139 @@
'use client';
import { Check, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { SUBSCRIPTION_PLANS, formatPlanPrice } from '@coursecraft/shared';
const locale = 'ru';
const plans = SUBSCRIPTION_PLANS.map((plan) => ({
tier: plan.tier,
name: plan.nameRu,
priceFormatted: formatPlanPrice(plan, locale).formatted,
features: plan.featuresRu,
}));
const currentPlan = {
tier: 'FREE' as const,
coursesUsed: 1,
coursesLimit: 2,
renewalDate: null as string | null,
};
export default function BillingPage() {
const usagePercent = (currentPlan.coursesUsed / currentPlan.coursesLimit) * 100;
return (
<div className="space-y-6 max-w-4xl">
<div>
<h1 className="text-3xl font-bold">Подписка</h1>
<p className="text-muted-foreground">
Управляйте вашей подпиской и лимитами
</p>
</div>
{/* Current usage */}
<Card>
<CardHeader>
<CardTitle>Текущее использование</CardTitle>
<CardDescription>
Ваш тарифный план: {plans.find((p) => p.tier === currentPlan.tier)?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Курсы в этом месяце</span>
<span className="text-sm text-muted-foreground">
{currentPlan.coursesUsed} / {currentPlan.coursesLimit}
</span>
</div>
<Progress value={usagePercent} className="h-2" />
</div>
{currentPlan.renewalDate && (
<p className="text-sm text-muted-foreground">
Следующее обновление: {currentPlan.renewalDate}
</p>
)}
</CardContent>
</Card>
{/* Plans */}
<div className="grid gap-6 md:grid-cols-3">
{plans.map((plan) => {
const isCurrent = plan.tier === currentPlan.tier;
const isUpgrade =
(currentPlan.tier === 'FREE' && plan.tier !== 'FREE') ||
(currentPlan.tier === 'PREMIUM' && plan.tier === 'PRO');
return (
<Card
key={plan.tier}
className={cn(isCurrent && 'border-primary')}
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
{plan.name}
{isCurrent && (
<span className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded-full">
Текущий
</span>
)}
</CardTitle>
<CardDescription>
<span className="text-3xl font-bold">{plan.priceFormatted}</span>
{plan.priceFormatted !== 'Бесплатно' && (
<span className="text-muted-foreground">/месяц</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<Check className="h-4 w-4 text-primary shrink-0" />
{feature}
</li>
))}
</ul>
</CardContent>
<CardFooter>
{isCurrent ? (
<Button variant="outline" className="w-full" disabled>
Текущий план
</Button>
) : isUpgrade ? (
<Button className="w-full">
<Zap className="mr-2 h-4 w-4" />
Улучшить
</Button>
) : (
<Button variant="outline" className="w-full">
Выбрать
</Button>
)}
</CardFooter>
</Card>
);
})}
</div>
{currentPlan.tier !== 'FREE' && (
<Card>
<CardHeader>
<CardTitle>Управление подпиской</CardTitle>
</CardHeader>
<CardContent className="flex gap-4">
<Button variant="outline">Изменить способ оплаты</Button>
<Button variant="outline" className="text-destructive">
Отменить подписку
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,218 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ChevronLeft, ChevronRight, Save, Wand2, Eye, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CourseEditor } from '@/components/editor/course-editor';
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
import { LessonSidebar } from '@/components/editor/lesson-sidebar';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
type Lesson = { id: string; title: string };
type Chapter = { id: string; title: string; lessons: Lesson[] };
type CourseData = { id: string; title: string; chapters: Chapter[] };
const emptyDoc = { type: 'doc', content: [] };
export default function CourseEditPage() {
const params = useParams();
const { loading: authLoading } = useAuth();
const courseId = params?.id as string;
const [course, setCourse] = useState<CourseData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [activeLesson, setActiveLesson] = useState<{ chapterId: string; lessonId: string } | null>(null);
const [content, setContent] = useState<Record<string, unknown>>(emptyDoc);
const [contentLoading, setContentLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [readOnly, setReadOnly] = useState(false);
useEffect(() => {
if (!courseId || authLoading) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getCourse(courseId);
if (!cancelled) {
setCourse(data);
const firstChapter = data.chapters?.[0];
const firstLesson = firstChapter?.lessons?.[0];
if (firstChapter && firstLesson) {
setActiveLesson({ chapterId: firstChapter.id, lessonId: firstLesson.id });
}
}
} catch (e: any) {
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [courseId, authLoading]);
// Load lesson content when active lesson changes
useEffect(() => {
if (!courseId || !activeLesson) {
setContent(emptyDoc);
return;
}
let cancelled = false;
setContentLoading(true);
(async () => {
try {
const lessonData = await api.getLesson(courseId, activeLesson.lessonId);
if (!cancelled && lessonData?.content) {
setContent(
typeof lessonData.content === 'object' && lessonData.content !== null
? (lessonData.content as Record<string, unknown>)
: emptyDoc
);
} else if (!cancelled) {
setContent(emptyDoc);
}
} catch {
if (!cancelled) setContent(emptyDoc);
} finally {
if (!cancelled) setContentLoading(false);
}
})();
return () => { cancelled = true; };
}, [courseId, activeLesson?.lessonId]);
const handleSelectLesson = (lessonId: string) => {
if (!course) return;
for (const ch of course.chapters) {
const lesson = ch.lessons.find((l) => l.id === lessonId);
if (lesson) {
setActiveLesson({ chapterId: ch.id, lessonId: lesson.id });
return;
}
}
};
const handleSave = async () => {
if (!courseId || !activeLesson || saving) return;
setSaving(true);
try {
await api.updateLesson(courseId, activeLesson.lessonId, { content });
} catch (e: any) {
console.error('Save failed:', e);
} finally {
setSaving(false);
}
};
if (authLoading || loading) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<p className="text-muted-foreground">Загрузка курса...</p>
</div>
);
}
if (error || !course) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<p className="text-destructive">{error || 'Курс не найден'}</p>
</div>
);
}
const flatLessons = course.chapters.flatMap((ch) =>
ch.lessons.map((l) => ({ ...l, chapterId: ch.id }))
);
const activeLessonMeta = activeLesson
? flatLessons.find((l) => l.id === activeLesson.lessonId)
: null;
return (
<div className="relative flex h-[calc(100vh-4rem)] -m-6">
<div
className={cn(
'border-r bg-muted/30 transition-all duration-300',
sidebarOpen ? 'w-72' : 'w-0'
)}
>
{sidebarOpen && (
<LessonSidebar
course={course}
activeLesson={activeLesson?.lessonId ?? ''}
onSelectLesson={handleSelectLesson}
/>
)}
</div>
<button
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-colors"
style={{ left: sidebarOpen ? '288px' : '0px' }}
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
<h2 className="font-medium truncate">
{activeLessonMeta?.title ?? 'Выберите урок'}
</h2>
<div className="flex items-center gap-2">
{readOnly ? (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/dashboard/courses/${courseId}`}>
<Eye className="mr-2 h-4 w-4" />
Просмотр курса
</Link>
</Button>
<Button size="sm" onClick={() => setReadOnly(false)}>
<Pencil className="mr-2 h-4 w-4" />
Редактировать
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" onClick={() => setReadOnly(true)}>
<Eye className="mr-2 h-4 w-4" />
Режим просмотра
</Button>
<Button variant="outline" size="sm">
<Wand2 className="mr-2 h-4 w-4" />
AI помощник
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !activeLesson}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</Button>
</>
)}
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 overflow-auto p-6">
<div className="w-full max-w-3xl mx-auto flex-1 min-h-0 flex flex-col">
{contentLoading ? (
<p className="text-muted-foreground">Загрузка контента...</p>
) : readOnly ? (
<LessonContentViewer content={content} className="prose-reader min-h-[400px]" />
) : (
<CourseEditor content={content} onChange={setContent} />
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,235 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { api } from '@/lib/api';
import { useAuth } from '@/contexts/auth-context';
import { LessonContentViewer } from '@/components/editor/lesson-content-viewer';
import { LessonSidebar } from '@/components/editor/lesson-sidebar';
import { cn } from '@/lib/utils';
type Lesson = { id: string; title: string; durationMinutes?: number | null; order: number };
type Chapter = { id: string; title: string; description?: string | null; order: number; lessons: Lesson[] };
type CourseData = {
id: string;
title: string;
description?: string | null;
status: string;
chapters: Chapter[];
};
export default function CoursePage() {
const params = useParams();
const router = useRouter();
const { loading: authLoading } = useAuth();
const id = params?.id as string;
const [course, setCourse] = useState<CourseData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
const [lessonContent, setLessonContent] = useState<Record<string, unknown> | null>(null);
const [lessonContentLoading, setLessonContentLoading] = useState(false);
useEffect(() => {
if (!id || authLoading) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getCourse(id);
if (!cancelled) {
setCourse(data);
const first = data.chapters?.[0]?.lessons?.[0];
if (first) setSelectedLessonId(first.id);
}
} catch (e: any) {
if (!cancelled) setError(e?.message || 'Не удалось загрузить курс');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [id, authLoading]);
useEffect(() => {
if (!id || !selectedLessonId) {
setLessonContent(null);
return;
}
let cancelled = false;
setLessonContentLoading(true);
(async () => {
try {
const data = await api.getLesson(id, selectedLessonId);
const content = data?.content;
if (!cancelled)
setLessonContent(
typeof content === 'object' && content !== null ? (content as Record<string, unknown>) : null
);
} catch {
if (!cancelled) setLessonContent(null);
} finally {
if (!cancelled) setLessonContentLoading(false);
}
})();
return () => { cancelled = true; };
}, [id, selectedLessonId]);
const handleDelete = async () => {
if (!course?.id || deleting) return;
setDeleting(true);
try {
await api.deleteCourse(course.id);
router.push('/dashboard');
router.refresh();
} catch (e: any) {
setError(e?.message || 'Не удалось удалить курс');
} finally {
setDeleting(false);
}
};
if (authLoading || loading) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<p className="text-muted-foreground">Загрузка курса...</p>
</div>
);
}
if (error || !course) {
return (
<div className="flex flex-col gap-4 p-6">
<Button variant="ghost" asChild>
<Link href="/dashboard"><ArrowLeft className="mr-2 h-4 w-4" />Назад к курсам</Link>
</Button>
<p className="text-destructive">{error || 'Курс не найден'}</p>
</div>
);
}
const activeLessonTitle = selectedLessonId
? (() => {
for (const ch of course.chapters) {
const lesson = ch.lessons.find((l) => l.id === selectedLessonId);
if (lesson) return lesson.title;
}
return null;
})()
: null;
return (
<div className="flex h-[calc(100vh-4rem)] -m-6 flex-col">
{/* Top bar */}
<div className="flex shrink-0 items-center justify-between border-b bg-background px-4 py-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard">
<ArrowLeft className="mr-2 h-4 w-4" />
К курсам
</Link>
</Button>
<span className="text-muted-foreground">/</span>
<span className="font-medium truncate max-w-[200px] sm:max-w-xs">{course.title}</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" asChild>
<Link href={`/dashboard/courses/${course.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Редактировать
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon" className="text-destructive hover:text-destructive hover:bg-destructive/10">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить курс?</AlertDialogTitle>
<AlertDialogDescription>
Курс «{course.title}» будет удалён безвозвратно.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => { e.preventDefault(); handleDelete(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleting}
>
{deleting ? 'Удаление...' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="relative flex flex-1 min-h-0">
{/* Left: list of lessons (paragraphs) */}
<div
className={cn(
'border-r bg-muted/20 flex flex-col transition-[width] duration-200',
sidebarOpen ? 'w-72 shrink-0' : 'w-0 overflow-hidden'
)}
>
{sidebarOpen && (
<LessonSidebar
course={course}
activeLesson={selectedLessonId ?? ''}
onSelectLesson={setSelectedLessonId}
readOnly
/>
)}
</div>
<button
type="button"
className="absolute top-4 z-10 flex h-8 w-6 items-center justify-center rounded-r-md border border-l-0 bg-background shadow-sm hover:bg-muted transition-colors"
style={{ left: sidebarOpen ? '17rem' : 0 }}
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{/* Center: lesson content (read-only) */}
<main className="flex-1 flex flex-col min-h-0 overflow-auto">
<div className="max-w-3xl mx-auto w-full px-6 py-8">
{activeLessonTitle && (
<h1 className="text-2xl font-bold mb-6 text-foreground">{activeLessonTitle}</h1>
)}
{lessonContentLoading ? (
<p className="text-muted-foreground">Загрузка...</p>
) : selectedLessonId ? (
<LessonContentViewer
content={lessonContent}
className="prose-reader min-h-[400px] [&_.ProseMirror]:min-h-[300px]"
/>
) : (
<p className="text-muted-foreground">Выберите урок в списке слева.</p>
)}
</div>
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,512 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Send, Sparkles, Loader2, Check, ArrowRight, X, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { useToast } from '@/components/ui/use-toast';
type Step = 'prompt' | 'questions' | 'generating' | 'complete' | 'error';
interface ClarifyingQuestion {
id: string;
question: string;
type: 'single_choice' | 'multiple_choice' | 'text';
options?: string[];
required: boolean;
}
export default function NewCoursePage() {
const router = useRouter();
const { toast } = useToast();
const [step, setStep] = useState<Step>('prompt');
const [prompt, setPrompt] = useState('');
const [generationId, setGenerationId] = useState<string | null>(null);
const [questions, setQuestions] = useState<ClarifyingQuestion[]>([]);
const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
const [progress, setProgress] = useState(0);
const [currentStepText, setCurrentStepText] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [courseId, setCourseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Poll for generation status
const pollStatus = useCallback(async () => {
if (!generationId) return;
try {
const status = await api.getGenerationStatus(generationId);
setProgress(status.progress);
setCurrentStepText(status.currentStep || '');
// Normalize status to uppercase for comparison
const normalizedStatus = status.status?.toUpperCase();
switch (normalizedStatus) {
case 'WAITING_FOR_ANSWERS':
if (status.questions) {
// questions can be array or object with questions array
const questionsArray = Array.isArray(status.questions)
? status.questions
: (status.questions as any)?.questions || [];
if (questionsArray.length > 0) {
setQuestions(questionsArray as ClarifyingQuestion[]);
setStep('questions');
}
}
break;
case 'COMPLETED':
setStep('complete');
if (status.course) {
setCourseId(status.course.id);
}
break;
case 'FAILED':
case 'CANCELLED':
setStep('error');
setErrorMessage(status.errorMessage || 'Генерация не удалась');
break;
default:
// Continue polling for other statuses (PENDING, ANALYZING, ASKING_QUESTIONS, RESEARCHING, GENERATING_OUTLINE, GENERATING_CONTENT)
if (step !== 'questions') {
setStep('generating');
}
}
} catch (error) {
console.error('Failed to get status:', error);
}
}, [generationId, step]);
// Start polling when we have a generation ID
useEffect(() => {
if (!generationId || step === 'complete' || step === 'error' || step === 'questions') {
return;
}
const interval = setInterval(pollStatus, 2000);
return () => clearInterval(interval);
}, [generationId, step, pollStatus]);
const handleSubmitPrompt = async () => {
if (!prompt.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
const result = await api.startGeneration(prompt);
setGenerationId(result.id);
setStep('generating');
setProgress(result.progress);
// Start polling immediately
setTimeout(pollStatus, 1000);
} catch (error: any) {
toast({
title: 'Ошибка',
description: error.message || 'Не удалось начать генерацию',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
const handleAnswerQuestion = (questionId: string, answer: string) => {
setAnswers((prev) => ({ ...prev, [questionId]: answer }));
};
const handleAnswerMultiple = (questionId: string, option: string) => {
setAnswers((prev) => {
const current = prev[questionId] as string[] || [];
const updated = current.includes(option)
? current.filter((o) => o !== option)
: [...current, option];
return { ...prev, [questionId]: updated };
});
};
const handleSubmitAnswers = async () => {
if (!generationId || isSubmitting) return;
setIsSubmitting(true);
try {
await api.answerQuestions(generationId, answers);
setStep('generating');
// Resume polling
setTimeout(pollStatus, 1000);
} catch (error: any) {
toast({
title: 'Ошибка',
description: error.message || 'Не удалось отправить ответы',
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
const handleCancel = async () => {
if (!generationId) return;
try {
await api.cancelGeneration(generationId);
router.push('/dashboard');
} catch (error) {
router.push('/dashboard');
}
};
const handleRetry = () => {
setStep('prompt');
setGenerationId(null);
setQuestions([]);
setAnswers({});
setProgress(0);
setCurrentStepText('');
setErrorMessage('');
setCourseId(null);
};
const allRequiredAnswered = questions
.filter((q) => q.required)
.every((q) => {
const answer = answers[q.id];
if (Array.isArray(answer)) return answer.length > 0;
return Boolean(answer);
});
return (
<div className="max-w-3xl mx-auto space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold">Создать новый курс</h1>
<p className="text-muted-foreground mt-2">
Опишите тему курса, и AI создаст его за вас
</p>
</div>
<AnimatePresence mode="wait">
{/* Step 1: Prompt */}
{step === 'prompt' && (
<motion.div
key="prompt"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 space-y-4">
<p className="text-sm text-muted-foreground">
Привет! Я помогу вам создать курс. Просто опишите, о чём
должен быть ваш курс.
</p>
<textarea
className="w-full min-h-[120px] rounded-lg border bg-background p-4 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="Например: Сделай курс по маркетингу для начинающих с акцентом на социальные сети..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={isSubmitting}
/>
<div className="flex justify-end">
<Button
onClick={handleSubmitPrompt}
disabled={!prompt.trim() || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отправка...
</>
) : (
<>
Продолжить
<Send className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Step 2: Questions */}
{step === 'questions' && (
<motion.div
key="questions"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-4"
>
{/* User prompt */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<p className="text-sm">{prompt}</p>
</CardContent>
</Card>
{/* AI questions */}
<Card>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 space-y-6">
<p className="text-sm text-muted-foreground">
Отлично! Чтобы создать идеальный курс, мне нужно уточнить
несколько деталей:
</p>
{questions.map((question, index) => (
<div key={question.id} className="space-y-3">
<p className="font-medium">
{index + 1}. {question.question}
{question.required && (
<span className="text-destructive">*</span>
)}
</p>
{question.type === 'single_choice' && question.options && (
<div className="grid gap-2">
{question.options.map((option) => (
<button
key={option}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 text-left text-sm transition-colors',
answers[question.id] === option
? 'border-primary bg-primary/5'
: 'hover:bg-muted'
)}
onClick={() =>
handleAnswerQuestion(question.id, option)
}
>
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full border-2',
answers[question.id] === option
? 'border-primary bg-primary'
: 'border-muted-foreground'
)}
>
{answers[question.id] === option && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
{option}
</button>
))}
</div>
)}
{question.type === 'multiple_choice' && question.options && (
<div className="grid gap-2">
{question.options.map((option) => {
const selected = (answers[question.id] as string[] || []).includes(option);
return (
<button
key={option}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 text-left text-sm transition-colors',
selected
? 'border-primary bg-primary/5'
: 'hover:bg-muted'
)}
onClick={() =>
handleAnswerMultiple(question.id, option)
}
>
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded border-2',
selected
? 'border-primary bg-primary'
: 'border-muted-foreground'
)}
>
{selected && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
{option}
</button>
);
})}
</div>
)}
{question.type === 'text' && (
<textarea
className="w-full rounded-lg border bg-background p-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="Введите ответ..."
rows={3}
value={(answers[question.id] as string) || ''}
onChange={(e) =>
handleAnswerQuestion(question.id, e.target.value)
}
/>
)}
</div>
))}
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={handleCancel}>
<X className="mr-2 h-4 w-4" />
Отменить
</Button>
<Button
onClick={handleSubmitAnswers}
disabled={!allRequiredAnswered || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отправка...
</>
) : (
<>
Создать курс
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Step 3: Generating */}
{step === 'generating' && (
<motion.div
key="generating"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-8 text-center space-y-6">
<div className="flex justify-center">
<div className="relative">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<Loader2 className="h-10 w-10 text-primary animate-spin" />
</div>
</div>
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold">Генерация курса</h2>
<p className="text-muted-foreground">{currentStepText || 'Подготовка...'}</p>
</div>
<div className="max-w-md mx-auto space-y-2">
<Progress value={progress} className="h-2" />
<p className="text-sm text-muted-foreground">{progress}%</p>
</div>
<Button variant="ghost" onClick={handleCancel}>
<X className="mr-2 h-4 w-4" />
Отменить генерацию
</Button>
</CardContent>
</Card>
</motion.div>
)}
{/* Step 4: Complete */}
{step === 'complete' && (
<motion.div
key="complete"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-8 text-center space-y-6">
<div className="flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check className="h-10 w-10 text-green-600 dark:text-green-400" />
</div>
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold">Курс готов!</h2>
<p className="text-muted-foreground">
Ваш курс успешно создан. Теперь вы можете просмотреть и
отредактировать его.
</p>
</div>
<div className="flex justify-center gap-4">
<Button variant="outline" asChild>
<a href="/dashboard">К списку курсов</a>
</Button>
{courseId && (
<Button asChild>
<a href={`/dashboard/courses/${courseId}/edit`}>
Редактировать курс
<ArrowRight className="ml-2 h-4 w-4" />
</a>
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Error state */}
{step === 'error' && (
<motion.div
key="error"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Card>
<CardContent className="p-8 text-center space-y-6">
<div className="flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<AlertCircle className="h-10 w-10 text-red-600 dark:text-red-400" />
</div>
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold">Ошибка генерации</h2>
<p className="text-muted-foreground">
{errorMessage || 'Произошла ошибка при генерации курса.'}
</p>
</div>
<div className="flex justify-center gap-4">
<Button variant="outline" asChild>
<a href="/dashboard">К списку курсов</a>
</Button>
<Button onClick={handleRetry}>
Попробовать снова
</Button>
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,139 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Plus, BookOpen, Clock, TrendingUp, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CourseCard } from '@/components/dashboard/course-card';
import { api } from '@/lib/api';
import { useToast } from '@/components/ui/use-toast';
import { useAuth } from '@/contexts/auth-context';
interface Course {
id: string;
title: string;
description: string | null;
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' | 'GENERATING';
chaptersCount: number;
lessonsCount: number;
updatedAt: string;
}
export default function DashboardPage() {
const { toast } = useToast();
const { loading: authLoading, user } = useAuth();
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
total: 0,
drafts: 0,
published: 0,
});
const loadCourses = async () => {
setLoading(true);
try {
const result = await api.getCourses();
setCourses(result.data);
const total = result.data.length;
const drafts = result.data.filter((c: Course) => c.status === 'DRAFT').length;
const published = result.data.filter((c: Course) => c.status === 'PUBLISHED').length;
setStats({ total, drafts, published });
} catch (error: any) {
if (error.message !== 'Unauthorized') {
toast({
title: 'Ошибка загрузки',
description: 'Не удалось загрузить курсы',
variant: 'destructive',
});
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (authLoading) return;
if (!user) {
setLoading(false);
return;
}
loadCourses();
}, [toast, authLoading, user]);
const statsCards = [
{ name: 'Всего курсов', value: stats.total.toString(), icon: BookOpen },
{ name: 'Черновики', value: stats.drafts.toString(), icon: Clock },
{ name: 'Опубликовано', value: stats.published.toString(), icon: TrendingUp },
];
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-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Мои курсы</h1>
<p className="text-muted-foreground">
Управляйте своими курсами и создавайте новые
</p>
</div>
<Button asChild>
<Link href="/dashboard/courses/new">
<Plus className="mr-2 h-4 w-4" />
Создать курс
</Link>
</Button>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
{statsCards.map((stat) => (
<Card key={stat.name}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.name}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
</CardContent>
</Card>
))}
</div>
{/* Courses grid */}
{courses.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{courses.map((course) => (
<CourseCard key={course.id} course={course} onDeleted={loadCourses} />
))}
</div>
) : (
<Card className="p-12 text-center">
<CardHeader>
<CardTitle>Нет курсов</CardTitle>
<CardDescription>
Создайте свой первый курс с помощью AI
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/dashboard/courses/new">
<Plus className="mr-2 h-4 w-4" />
Создать курс
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,137 @@
'use client';
import { useState } from 'react';
import { Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast';
export default function SettingsPage() {
const { toast } = useToast();
const [settings, setSettings] = useState({
name: 'John Doe',
email: 'john@example.com',
customAiModel: '',
emailNotifications: true,
marketingEmails: false,
});
const handleSave = () => {
toast({
title: 'Настройки сохранены',
description: 'Ваши настройки успешно обновлены.',
});
};
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-3xl font-bold">Настройки</h1>
<p className="text-muted-foreground">
Управляйте настройками вашего аккаунта
</p>
</div>
{/* Profile */}
<Card>
<CardHeader>
<CardTitle>Профиль</CardTitle>
<CardDescription>Информация о вашем аккаунте</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Имя</Label>
<Input
id="name"
value={settings.name}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={settings.email} disabled />
<p className="text-xs text-muted-foreground">
Email нельзя изменить
</p>
</div>
</CardContent>
</Card>
{/* AI Settings */}
<Card>
<CardHeader>
<CardTitle>Настройки AI</CardTitle>
<CardDescription>
Настройте модель нейросети для генерации курсов
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="aiModel">Пользовательская модель</Label>
<Input
id="aiModel"
placeholder="qwen/qwen3-coder-next"
value={settings.customAiModel}
onChange={(e) =>
setSettings({ ...settings, customAiModel: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
Укажите модель в формате provider/model-name. Если оставить пустым,
будет использована модель по умолчанию для вашего тарифа.
</p>
</div>
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle>Уведомления</CardTitle>
<CardDescription>Настройки email уведомлений</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Уведомления о курсах</Label>
<p className="text-sm text-muted-foreground">
Получать уведомления о статусе генерации
</p>
</div>
<input
type="checkbox"
checked={settings.emailNotifications}
onChange={(e) =>
setSettings({ ...settings, emailNotifications: e.target.checked })
}
className="h-4 w-4"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Маркетинговые письма</Label>
<p className="text-sm text-muted-foreground">
Получать новости и специальные предложения
</p>
</div>
<input
type="checkbox"
checked={settings.marketingEmails}
onChange={(e) =>
setSettings({ ...settings, marketingEmails: e.target.checked })
}
className="h-4 w-4"
/>
</div>
</CardContent>
</Card>
<Button onClick={handleSave}>
<Save className="mr-2 h-4 w-4" />
Сохранить настройки
</Button>
</div>
);
}

View File

@ -0,0 +1,18 @@
import { Sidebar } from '@/components/dashboard/sidebar';
import { DashboardHeader } from '@/components/dashboard/header';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex">
<Sidebar />
<div className="flex-1 flex flex-col">
<DashboardHeader />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}

View File

@ -0,0 +1,106 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 263.4 70% 50.4%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Selection */
::selection {
background: hsl(var(--primary) / 0.2);
}

View File

@ -0,0 +1,45 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { AuthProvider } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/toaster';
const inter = Inter({ subsets: ['latin', 'cyrillic'] });
export const metadata: Metadata = {
title: 'CourseCraft - AI-Powered Course Creation',
description:
'Create professional courses in minutes with AI. Generate comprehensive educational content with just a simple prompt.',
keywords: ['course creation', 'AI', 'education', 'online learning', 'course generator'],
authors: [{ name: 'CourseCraft' }],
openGraph: {
title: 'CourseCraft - AI-Powered Course Creation',
description: 'Create professional courses in minutes with AI.',
type: 'website',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
}

23
apps/web/src/app/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import { Header } from '@/components/landing/header';
import { Hero } from '@/components/landing/hero';
import { Features } from '@/components/landing/features';
import { HowItWorks } from '@/components/landing/how-it-works';
import { Pricing } from '@/components/landing/pricing';
import { FAQ } from '@/components/landing/faq';
import { Footer } from '@/components/landing/footer';
export default function LandingPage() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
<Hero />
<Features />
<HowItWorks />
<Pricing />
<FAQ />
</main>
<Footer />
</div>
);
}

View File

@ -0,0 +1,199 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { MoreHorizontal, BookOpen, Clock, Edit, Trash2, Eye } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@coursecraft/shared';
import { api } from '@/lib/api';
interface CourseCardProps {
course: {
id: string;
title: string;
description: string | null;
status: 'DRAFT' | 'GENERATING' | 'PUBLISHED' | 'ARCHIVED';
chaptersCount: number;
lessonsCount: number;
updatedAt: string;
generationProgress?: number;
};
onDeleted?: () => void;
}
const statusConfig = {
DRAFT: {
label: 'Черновик',
className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
},
GENERATING: {
label: 'Генерация',
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
PUBLISHED: {
label: 'Опубликован',
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
},
ARCHIVED: {
label: 'Архив',
className: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
},
};
export function CourseCard({ course, onDeleted }: CourseCardProps) {
const router = useRouter();
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setDeleting(true);
try {
await api.deleteCourse(course.id);
setDeleteOpen(false);
onDeleted?.();
router.refresh();
} finally {
setDeleting(false);
}
};
const status = statusConfig[course.status];
return (
<Card className="group relative overflow-hidden transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
status.className
)}
>
{status.label}
</span>
</div>
<CardTitle className="line-clamp-1">{course.title}</CardTitle>
{course.description && (
<CardDescription className="line-clamp-2 mt-1">
{course.description}
</CardDescription>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Действия</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/courses/${course.id}`}>
<Eye className="mr-2 h-4 w-4" />
Просмотр
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/dashboard/courses/${course.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Редактировать
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => { e.preventDefault(); setDeleteOpen(true); }}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
{course.status === 'GENERATING' && course.generationProgress !== undefined ? (
<div className="space-y-2">
<Progress value={course.generationProgress} className="h-2" />
<p className="text-xs text-muted-foreground">
Генерация: {course.generationProgress}%
</p>
</div>
) : (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<BookOpen className="h-4 w-4" />
<span>{course.chaptersCount} глав</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
<span>{course.lessonsCount} уроков</span>
</div>
</div>
)}
<p className="mt-4 text-xs text-muted-foreground">
Обновлён {formatRelativeTime(course.updatedAt)}
</p>
</CardContent>
{/* Clickable overlay */}
<Link
href={`/dashboard/courses/${course.id}`}
className="absolute inset-0"
aria-label={`Открыть курс ${course.title}`}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Удалить курс?</AlertDialogTitle>
<AlertDialogDescription>
Курс «{course.title}» будет удалён безвозвратно. Все главы и уроки также будут удалены.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleting}
>
{deleting ? 'Удаление...' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}

View File

@ -0,0 +1,95 @@
'use client';
import Link from 'next/link';
import { Bell, Menu, User, LogOut, Settings, CreditCard } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuth } from '@/contexts/auth-context';
export function DashboardHeader() {
const { user, signOut } = useAuth();
const initials = user?.user_metadata?.full_name
?.split(' ')
.map((n: string) => n[0])
.join('')
.toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U';
return (
<header className="flex h-16 items-center justify-between border-b px-6">
{/* Mobile menu button */}
<Button variant="ghost" size="icon" className="lg:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Открыть меню</span>
</Button>
{/* Spacer */}
<div className="flex-1" />
{/* Right side */}
<div className="flex items-center gap-4">
{/* Notifications */}
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
<span className="sr-only">Уведомления</span>
</Button>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-9 w-9 rounded-full">
<Avatar className="h-9 w-9">
<AvatarImage
src={user?.user_metadata?.avatar_url}
alt={user?.user_metadata?.full_name || 'User'}
/>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user?.user_metadata?.full_name || 'Пользователь'}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/dashboard/settings" className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
Настройки
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/dashboard/billing" className="cursor-pointer">
<CreditCard className="mr-2 h-4 w-4" />
Подписка
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive cursor-pointer"
onClick={() => signOut()}
>
<LogOut className="mr-2 h-4 w-4" />
Выйти
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@ -0,0 +1,98 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Sparkles,
LayoutDashboard,
BookOpen,
Settings,
CreditCard,
Plus,
Search,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
const navigation = [
{ name: 'Обзор', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Мои курсы', href: '/dashboard', icon: BookOpen },
{ name: 'Поиск', href: '/dashboard/search', icon: Search },
];
const bottomNavigation = [
{ name: 'Настройки', href: '/dashboard/settings', icon: Settings },
{ name: 'Подписка', href: '/dashboard/billing', icon: CreditCard },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:border-r lg:bg-muted/30">
{/* Logo */}
<div className="flex h-16 items-center gap-2 border-b px-6">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-4 w-4 text-primary-foreground" />
</div>
<span className="text-lg font-bold">CourseCraft</span>
</Link>
</div>
{/* Create button */}
<div className="p-4">
<Button className="w-full" asChild>
<Link href="/dashboard/courses/new">
<Plus className="mr-2 h-4 w-4" />
Создать курс
</Link>
</Button>
</div>
{/* Main navigation */}
<nav className="flex-1 px-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
{/* Bottom navigation */}
<nav className="p-4 border-t space-y-1">
{bottomNavigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@ -0,0 +1,394 @@
'use client';
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import mermaid from 'mermaid';
import {
Bold,
Italic,
Underline as UnderlineIcon,
List,
ListOrdered,
Heading1,
Heading2,
Heading3,
Quote,
Code,
Minus,
Link as LinkIcon,
Wand2,
ImageIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useState, useRef } from 'react';
interface CourseEditorProps {
content: Record<string, unknown>;
onChange: (content: Record<string, unknown>) => void;
}
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
export function CourseEditor({ content, onChange }: CourseEditorProps) {
const [showAiMenu, setShowAiMenu] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [showLinkInput, setShowLinkInput] = useState(false);
const [imageUrl, setImageUrl] = useState('');
const [showImageInput, setShowImageInput] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: {
HTMLAttributes: (node) =>
node.attrs.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 || '' },
},
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { class: 'text-primary underline underline-offset-2' },
}),
Image.configure({
inline: false,
allowBase64: true,
HTMLAttributes: { class: 'rounded-lg max-w-full h-auto' },
}),
Placeholder.configure({
placeholder: 'Начните писать. Поддерживаются: текст, списки, цитаты, блоки кода, Mermaid-диаграммы, картинки, ссылки.',
}),
],
content,
editorProps: {
attributes: {
class:
'prose prose-sm sm:prose lg:prose-lg dark:prose-invert max-w-none focus:outline-none min-h-full [&_.ProseMirror]:min-h-[60vh] [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm [&_.ProseMirror_pre]:border',
},
},
onUpdate: ({ editor }) => {
onChange(editor.getJSON());
},
});
useEffect(() => {
if (editor && content) {
const currentContent = JSON.stringify(editor.getJSON());
const newContent = JSON.stringify(content);
if (currentContent !== newContent) {
editor.commands.setContent(content);
}
}
}, [content, editor]);
// Render Mermaid diagrams in code blocks with language "mermaid"
useEffect(() => {
if (!editorRef.current) return;
const mermaidNodes = editorRef.current.querySelectorAll('pre[data-language="mermaid"]');
if (mermaidNodes.length === 0) return;
mermaid.run({ nodes: Array.from(mermaidNodes), suppressErrors: true }).catch(() => {});
}, [content]);
const setLink = useCallback(() => {
if (!editor) return;
if (linkUrl) {
editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl }).run();
} else {
editor.chain().focus().unsetLink().run();
}
setLinkUrl('');
setShowLinkInput(false);
}, [editor, linkUrl]);
const setImage = useCallback(() => {
if (!editor || !imageUrl) return;
editor.chain().focus().setImage({ src: imageUrl }).run();
setImageUrl('');
setShowImageInput(false);
}, [editor, imageUrl]);
const handleAiRewrite = useCallback(() => {
if (!editor) return;
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to);
if (selectedText) {
console.log('Rewriting:', selectedText);
setShowAiMenu(false);
}
}, [editor]);
if (!editor) {
return null;
}
return (
<div ref={editorRef} className="relative border rounded-lg overflow-hidden flex-1 flex flex-col min-h-0">
{/* Fixed toolbar */}
<div className="flex flex-wrap items-center gap-1 border-b bg-muted/30 p-2 shrink-0">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Жирный"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Курсив"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title="Подчёркнутый"
>
<UnderlineIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title="Зачёркнутый"
>
<span className="text-sm font-bold line-through">S</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive('code')}
title="Код (инлайн)"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<div className="w-px h-6 bg-border mx-1" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Заголовок 1"
>
<Heading1 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Заголовок 2"
>
<Heading2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Заголовок 3"
>
<Heading3 className="h-4 w-4" />
</ToolbarButton>
<div className="w-px h-6 bg-border mx-1" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Маркированный список"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Нумерованный список"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Цитата"
>
<Quote className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock') && editor.getAttributes('codeBlock').language !== 'mermaid'}
title="Блок кода"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock({ language: 'mermaid' }).run()}
isActive={editor.isActive('codeBlock') && editor.getAttributes('codeBlock').language === 'mermaid'}
title="Mermaid-диаграмма"
>
<span className="text-xs font-bold">M</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Разделитель"
>
<Minus className="h-4 w-4" />
</ToolbarButton>
<div className="w-px h-6 bg-border mx-1" />
<div className="relative flex items-center gap-1">
<ToolbarButton
onClick={() => {
const prev = editor.getAttributes('link').href;
setLinkUrl(prev || '');
setShowLinkInput(!showLinkInput);
setShowImageInput(false);
}}
isActive={editor.isActive('link')}
title="Ссылка"
>
<LinkIcon className="h-4 w-4" />
</ToolbarButton>
{showLinkInput && (
<div className="absolute left-0 top-full mt-1 flex gap-1 bg-background border rounded-md p-2 shadow-lg z-10">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..."
className="w-48 px-2 py-1 text-sm border rounded"
onKeyDown={(e) => e.key === 'Enter' && setLink()}
/>
<Button type="button" size="sm" onClick={setLink}>
OK
</Button>
</div>
)}
</div>
<div className="relative flex items-center gap-1">
<ToolbarButton
onClick={() => {
setShowImageInput(!showImageInput);
setShowLinkInput(false);
}}
title="Картинка"
>
<ImageIcon className="h-4 w-4" />
</ToolbarButton>
{showImageInput && (
<div className="absolute left-0 top-full mt-1 flex gap-1 bg-background border rounded-md p-2 shadow-lg z-10">
<input
type="url"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="URL картинки"
className="w-48 px-2 py-1 text-sm border rounded"
onKeyDown={(e) => e.key === 'Enter' && setImage()}
/>
<Button type="button" size="sm" onClick={setImage}>
Вставить
</Button>
</div>
)}
</div>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 text-primary"
onClick={handleAiRewrite}
>
<Wand2 className="h-4 w-4" />
AI
</Button>
</div>
{/* Bubble menu for quick formatting when text selected */}
<BubbleMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="flex items-center gap-1 rounded-lg border bg-background p-1 shadow-lg"
>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Жирный"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Курсив"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive('code')}
title="Код"
>
<Code className="h-4 w-4" />
</ToolbarButton>
</BubbleMenu>
<div className="flex-1 min-h-0 p-4 flex flex-col">
<EditorContent editor={editor} className="flex-1 min-h-0 [&_.ProseMirror]:min-h-[60vh]" />
</div>
{showAiMenu && (
<div className="absolute top-0 right-0 w-64 rounded-lg border bg-background p-4 shadow-lg z-10">
<h3 className="font-medium mb-2">AI помощник</h3>
<div className="space-y-2">
<Button variant="outline" size="sm" className="w-full justify-start">
Улучшить текст
</Button>
<Button variant="outline" size="sm" className="w-full justify-start">
Сделать короче
</Button>
<Button variant="outline" size="sm" className="w-full justify-start">
Сделать длиннее
</Button>
<Button variant="outline" size="sm" className="w-full justify-start">
Упростить
</Button>
</div>
</div>
)}
</div>
);
}
interface ToolbarButtonProps {
onClick: () => void;
isActive?: boolean;
title: string;
children: React.ReactNode;
}
function ToolbarButton({ onClick, isActive, title, children }: ToolbarButtonProps) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
// Defer so editor still has selection and focus is not stolen
setTimeout(() => onClick(), 0);
}}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md transition-colors',
isActive ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'
)}
>
{children}
</button>
);
}

View File

@ -0,0 +1,70 @@
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import mermaid from 'mermaid';
import { useEffect, useRef } from 'react';
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
const emptyDoc = { type: 'doc', content: [] };
interface LessonContentViewerProps {
content: Record<string, unknown> | null;
className?: string;
}
export function LessonContentViewer({ content, className }: LessonContentViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: {
HTMLAttributes: (node: { attrs: { language?: string } }) =>
node.attrs.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 || '' },
},
}),
Underline,
Link.configure({
openOnClick: true,
HTMLAttributes: { class: 'text-primary underline underline-offset-2' },
}),
Image.configure({ HTMLAttributes: { class: 'rounded-lg max-w-full h-auto' } }),
],
content: content ?? emptyDoc,
editable: false,
editorProps: {
attributes: {
class:
'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',
},
},
});
useEffect(() => {
if (editor && content) {
editor.commands.setContent(content);
}
}, [content, editor]);
useEffect(() => {
if (!containerRef.current || !content) return;
const mermaidNodes = containerRef.current.querySelectorAll('pre[data-language="mermaid"]');
if (mermaidNodes.length === 0) return;
mermaid.run({ nodes: Array.from(mermaidNodes), suppressErrors: true }).catch(() => {});
}, [content]);
if (!editor) return null;
return (
<div ref={containerRef} className={className}>
<EditorContent editor={editor} />
</div>
);
}

View File

@ -0,0 +1,118 @@
'use client';
import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface Lesson {
id: string;
title: string;
}
interface Chapter {
id: string;
title: string;
lessons: Lesson[];
}
interface Course {
id: string;
title: string;
chapters: Chapter[];
}
interface LessonSidebarProps {
course: Course;
activeLesson: string;
onSelectLesson: (lessonId: string) => void;
/** Скрыть кнопки «Добавить урок/главу» (режим просмотра) */
readOnly?: boolean;
}
export function LessonSidebar({
course,
activeLesson,
onSelectLesson,
readOnly = false,
}: LessonSidebarProps) {
const [expandedChapters, setExpandedChapters] = useState<string[]>(
course.chapters.map((ch) => ch.id)
);
const toggleChapter = (chapterId: string) => {
setExpandedChapters((prev) =>
prev.includes(chapterId)
? prev.filter((id) => id !== chapterId)
: [...prev, chapterId]
);
};
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b">
<h2 className="font-semibold truncate">{course.title}</h2>
</div>
{/* Chapters list */}
<div className="flex-1 overflow-auto p-2">
{course.chapters.map((chapter) => {
const isExpanded = expandedChapters.includes(chapter.id);
return (
<div key={chapter.id} className="mb-2">
{/* Chapter header */}
<button
className="flex items-center gap-2 w-full rounded-md px-2 py-1.5 text-sm font-medium hover:bg-muted transition-colors"
onClick={() => toggleChapter(chapter.id)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="truncate">{chapter.title}</span>
</button>
{/* Lessons */}
{isExpanded && (
<div className="ml-4 mt-1 space-y-1">
{chapter.lessons.map((lesson) => (
<button
key={lesson.id}
className={cn(
'flex items-center gap-2 w-full rounded-md px-2 py-1.5 text-sm transition-colors text-left',
activeLesson === lesson.id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground'
)}
onClick={() => onSelectLesson(lesson.id)}
>
<FileText className="h-4 w-4 shrink-0" />
<span className="truncate">{lesson.title}</span>
</button>
))}
{!readOnly && (
<button className="flex items-center gap-2 w-full rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-muted transition-colors">
<Plus className="h-4 w-4 shrink-0" />
<span>Добавить урок</span>
</button>
)}
</div>
)}
</div>
);
})}
</div>
{!readOnly && (
<div className="p-2 border-t">
<Button variant="ghost" size="sm" className="w-full justify-start">
<Plus className="mr-2 h-4 w-4" />
Добавить главу
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const faqs = [
{
question: 'Как работает AI-генерация курсов?',
answer:
'Вы описываете тему курса простым текстом, например "Сделай курс по маркетингу". Наша нейросеть анализирует запрос, задаёт уточняющие вопросы, а затем генерирует полную структуру курса с главами, уроками и контентом. Весь процесс занимает несколько минут.',
},
{
question: 'Могу ли я редактировать сгенерированный курс?',
answer:
'Да, конечно! После генерации вы получаете полный доступ к редактированию. Можете изменять текст вручную или использовать AI для переписывания отдельных частей. Просто выделите нужный фрагмент и попросите нейросеть его улучшить.',
},
{
question: 'Какие AI модели используются?',
answer:
'Мы используем передовые языковые модели через OpenRouter. В зависимости от тарифа вам доступны разные модели: от базовых (Llama) до топовых (Claude 3.5 Sonnet). На Pro-тарифе вы можете выбрать модель самостоятельно.',
},
{
question: 'Кому принадлежат созданные курсы?',
answer:
'Все созданные курсы принадлежат вам полностью. Вы можете использовать их как угодно: публиковать, продавать, делиться. В будущем мы планируем маркетплейс, где вы сможете продавать свои курсы.',
},
{
question: 'Можно ли отменить подписку?',
answer:
'Да, вы можете отменить подписку в любое время в личном кабинете. После отмены вы сохраните доступ до конца оплаченного периода и перейдёте на бесплатный план.',
},
{
question: 'Есть ли ограничения на количество курсов?',
answer:
'Да, количество курсов в месяц зависит от тарифа: 2 на бесплатном, 5 на Премиум и 15 на Профессиональном плане. Счётчик обновляется каждый месяц.',
},
];
export function FAQ() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<section id="faq" className="py-20 sm:py-32">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Частые вопросы
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Не нашли ответ? Напишите нам на support@coursecraft.ai
</p>
</div>
<div className="mx-auto mt-16 max-w-3xl">
<div className="space-y-4">
{faqs.map((faq, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<button
className={cn(
'flex w-full items-center justify-between rounded-lg border bg-background p-6 text-left transition-colors',
openIndex === index && 'border-primary'
)}
onClick={() => setOpenIndex(openIndex === index ? null : index)}
>
<span className="font-medium">{faq.question}</span>
<ChevronDown
className={cn(
'h-5 w-5 shrink-0 text-muted-foreground transition-transform',
openIndex === index && 'rotate-180'
)}
/>
</button>
<AnimatePresence>
{openIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-6 py-4 text-muted-foreground">
{faq.answer}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,111 @@
'use client';
import { motion } from 'framer-motion';
import {
Wand2,
Layout,
Edit3,
Search,
Shield,
Zap,
} from 'lucide-react';
const features = [
{
name: 'AI-генерация курсов',
description:
'Просто опишите тему курса, и наша нейросеть создаст полную структуру с главами, уроками и контентом.',
icon: Wand2,
},
{
name: 'Умная структура',
description:
'Автоматическое разбиение на логичные главы и уроки с учётом сложности материала и целевой аудитории.',
icon: Layout,
},
{
name: 'Гибкое редактирование',
description:
'Редактируйте любую часть курса вручную или с помощью AI. Выделите текст и попросите нейросеть переписать.',
icon: Edit3,
},
{
name: 'Уточняющие вопросы',
description:
'AI задаст правильные вопросы, чтобы понять ваши потребности и создать максимально релевантный курс.',
icon: Search,
},
{
name: 'Приватность данных',
description:
'Ваши курсы принадлежат только вам. Полный контроль над контентом и возможность монетизации.',
icon: Shield,
},
{
name: 'Быстрая генерация',
description:
'Создание полноценного курса занимает считанные минуты. Следите за прогрессом в реальном времени.',
icon: Zap,
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
},
},
};
export function Features() {
return (
<section id="features" className="py-20 sm:py-32 bg-muted/30">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Всё, что нужно для создания курсов
</h2>
<p className="mt-4 text-lg text-muted-foreground">
CourseCraft объединяет мощь искусственного интеллекта с интуитивным
интерфейсом для создания профессиональных образовательных материалов.
</p>
</div>
<motion.div
className="mx-auto mt-16 grid max-w-5xl grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3"
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-100px' }}
>
{features.map((feature) => (
<motion.div
key={feature.name}
className="relative rounded-2xl border bg-background p-8 shadow-sm transition-shadow hover:shadow-md"
variants={itemVariants}
>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<feature.icon className="h-6 w-6 text-primary" />
</div>
<h3 className="mt-4 text-lg font-semibold">{feature.name}</h3>
<p className="mt-2 text-muted-foreground">{feature.description}</p>
</motion.div>
))}
</motion.div>
</div>
</section>
);
}

View File

@ -0,0 +1,99 @@
import Link from 'next/link';
import { Sparkles } from 'lucide-react';
const navigation = {
product: [
{ name: 'Возможности', href: '#features' },
{ name: 'Тарифы', href: '#pricing' },
{ name: 'FAQ', href: '#faq' },
],
company: [
{ name: 'О нас', href: '/about' },
{ name: 'Блог', href: '/blog' },
{ name: 'Контакты', href: '/contact' },
],
legal: [
{ name: 'Политика конфиденциальности', href: '/privacy' },
{ name: 'Условия использования', href: '/terms' },
],
};
export function Footer() {
return (
<footer className="border-t bg-muted/30">
<div className="container py-12 md:py-16">
<div className="grid grid-cols-2 gap-8 md:grid-cols-4">
{/* Brand */}
<div className="col-span-2 md:col-span-1">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">CourseCraft</span>
</Link>
<p className="mt-4 text-sm text-muted-foreground">
Создавайте профессиональные курсы за минуты с помощью искусственного интеллекта.
</p>
</div>
{/* Product */}
<div>
<h3 className="text-sm font-semibold">Продукт</h3>
<ul className="mt-4 space-y-3">
{navigation.product.map((item) => (
<li key={item.name}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
{/* Company */}
<div>
<h3 className="text-sm font-semibold">Компания</h3>
<ul className="mt-4 space-y-3">
{navigation.company.map((item) => (
<li key={item.name}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
{/* Legal */}
<div>
<h3 className="text-sm font-semibold">Документы</h3>
<ul className="mt-4 space-y-3">
{navigation.legal.map((item) => (
<li key={item.name}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="mt-12 border-t pt-8">
<p className="text-center text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} CourseCraft. Все права защищены.
</p>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,144 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { Menu, X, Sparkles, BookOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context';
const navigation = [
{ name: 'Возможности', href: '#features' },
{ name: 'Как это работает', href: '#how-it-works' },
{ name: 'Тарифы', href: '#pricing' },
{ name: 'FAQ', href: '#faq' },
];
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { user, loading } = useAuth();
const displayName =
user?.user_metadata?.full_name ||
user?.user_metadata?.name ||
user?.email?.split('@')[0] ||
'Пользователь';
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<nav className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">CourseCraft</span>
</Link>
</div>
{/* Desktop navigation */}
<div className="hidden md:flex md:items-center md:gap-8">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{item.name}
</Link>
))}
</div>
<div className="hidden md:flex md:items-center md:gap-4">
{!loading &&
(user ? (
<Link
href="/dashboard"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground"
>
<Avatar className="h-8 w-8">
<AvatarImage src={user.user_metadata?.avatar_url} alt="" />
<AvatarFallback className="text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="max-w-[120px] truncate">{displayName}</span>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</Link>
) : (
<>
<Button variant="ghost" asChild>
<Link href="/login">Войти</Link>
</Button>
<Button asChild>
<Link href="/register">Начать бесплатно</Link>
</Button>
</>
))}
</div>
{/* Mobile menu button */}
<button
type="button"
className="md:hidden -m-2.5 inline-flex items-center justify-center rounded-md p-2.5"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
<span className="sr-only">Открыть меню</span>
{mobileMenuOpen ? (
<X className="h-6 w-6" aria-hidden="true" />
) : (
<Menu className="h-6 w-6" aria-hidden="true" />
)}
</button>
</nav>
{/* Mobile menu */}
<div
className={cn(
'md:hidden',
mobileMenuOpen ? 'block' : 'hidden'
)}
>
<div className="space-y-1 px-4 pb-4 pt-2">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="block rounded-md px-3 py-2 text-base font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => setMobileMenuOpen(false)}
>
{item.name}
</Link>
))}
<div className="flex flex-col gap-2 pt-4">
{user ? (
<Link
href="/dashboard"
className="flex items-center gap-2 rounded-md px-3 py-2 text-base font-medium hover:bg-accent"
onClick={() => setMobileMenuOpen(false)}
>
<Avatar className="h-8 w-8">
<AvatarImage src={user.user_metadata?.avatar_url} alt="" />
<AvatarFallback className="text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{displayName}</span>
<BookOpen className="h-4 w-4 ml-auto" />
</Link>
) : (
<>
<Button variant="outline" asChild className="w-full">
<Link href="/login">Войти</Link>
</Button>
<Button asChild className="w-full">
<Link href="/register">Начать бесплатно</Link>
</Button>
</>
)}
</div>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,105 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { ArrowRight, Zap, BookOpen, Brain } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function Hero() {
return (
<section className="relative overflow-hidden py-20 sm:py-32">
{/* Background gradient */}
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-background to-primary/10" />
<div className="absolute top-0 right-0 -translate-y-1/4 translate-x-1/4 w-[600px] h-[600px] rounded-full bg-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 translate-y-1/4 -translate-x-1/4 w-[600px] h-[600px] rounded-full bg-primary/5 blur-3xl" />
</div>
<div className="container">
<div className="mx-auto max-w-3xl text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="mb-8 inline-flex items-center gap-2 rounded-full border bg-background/50 px-4 py-1.5 text-sm backdrop-blur">
<Zap className="h-4 w-4 text-primary" />
<span>AI-powered course creation</span>
</div>
</motion.div>
<motion.h1
className="text-4xl font-bold tracking-tight sm:text-6xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
Создавайте курсы за{' '}
<span className="text-primary">минуты</span>,{' '}
<br className="hidden sm:inline" />
не за недели
</motion.h1>
<motion.p
className="mt-6 text-lg leading-8 text-muted-foreground"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
CourseCraft использует искусственный интеллект для создания
профессиональных образовательных курсов. Просто опишите тему,
и наша нейросеть создаст полноценный курс с структурой,
контентом и материалами.
</motion.p>
<motion.div
className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<Button size="xl" asChild>
<Link href="/register">
Создать первый курс
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</Button>
<Button size="xl" variant="outline" asChild>
<Link href="#how-it-works">Как это работает</Link>
</Button>
</motion.div>
{/* Stats */}
<motion.div
className="mt-16 grid grid-cols-3 gap-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<BookOpen className="h-6 w-6 text-primary" />
</div>
<div className="mt-3 text-2xl font-bold">2 мин</div>
<div className="text-sm text-muted-foreground">Среднее время создания</div>
</div>
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Brain className="h-6 w-6 text-primary" />
</div>
<div className="mt-3 text-2xl font-bold">AI</div>
<div className="text-sm text-muted-foreground">Умная генерация</div>
</div>
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Zap className="h-6 w-6 text-primary" />
</div>
<div className="mt-3 text-2xl font-bold">100%</div>
<div className="text-sm text-muted-foreground">Ваш контент</div>
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { motion } from 'framer-motion';
import { MessageSquare, Settings2, Loader2, FileCheck } from 'lucide-react';
const steps = [
{
number: '01',
title: 'Опишите курс',
description: 'Напишите простой промт о том, какой курс вы хотите создать. Например: "Сделай курс по маркетингу для начинающих".',
icon: MessageSquare,
},
{
number: '02',
title: 'Уточните детали',
description: 'AI задаст несколько вопросов, чтобы лучше понять ваши потребности: целевую аудиторию, глубину материала, желаемую длительность.',
icon: Settings2,
},
{
number: '03',
title: 'Генерация',
description: 'Нейросеть создаст структуру курса и наполнит его контентом. Следите за прогрессом в реальном времени.',
icon: Loader2,
},
{
number: '04',
title: 'Редактирование',
description: 'Отредактируйте готовый курс: измените текст вручную или попросите AI переписать отдельные части.',
icon: FileCheck,
},
];
export function HowItWorks() {
return (
<section id="how-it-works" className="py-20 sm:py-32">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Как это работает
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Создание курса с CourseCraft это просто. Всего 4 шага от идеи до готового материала.
</p>
</div>
<div className="mx-auto mt-16 max-w-4xl">
<div className="relative">
{/* Connection line */}
<div className="absolute left-8 top-0 bottom-0 w-px bg-border hidden md:block" />
<div className="space-y-12">
{steps.map((step, index) => (
<motion.div
key={step.number}
className="relative flex gap-8"
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
{/* Step number */}
<div className="relative z-10 flex h-16 w-16 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-background">
<step.icon className="h-6 w-6 text-primary" />
</div>
{/* Content */}
<div className="flex-1 pt-3">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-primary">{step.number}</span>
<h3 className="text-xl font-semibold">{step.title}</h3>
</div>
<p className="mt-2 text-muted-foreground">{step.description}</p>
</div>
</motion.div>
))}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,108 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { SUBSCRIPTION_PLANS, formatPlanPrice } from '@coursecraft/shared';
const locale = 'ru';
const period = 'месяц';
const plans = SUBSCRIPTION_PLANS.map((plan, index) => {
const { formatted } = formatPlanPrice(plan, locale);
const isPopular = index === 1;
return {
name: plan.nameRu,
description: plan.descriptionRu,
priceFormatted: formatted,
period,
features: plan.featuresRu,
cta: plan.tier === 'FREE' ? 'Начать бесплатно' : index === 1 ? 'Выбрать Премиум' : 'Выбрать Pro',
href: plan.tier === 'FREE' ? '/register' : `/register?plan=${plan.tier.toLowerCase()}`,
popular: isPopular,
};
});
export function Pricing() {
return (
<section id="pricing" className="py-20 sm:py-32 bg-muted/30">
<div className="container">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Простые и понятные тарифы
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Выберите план, который подходит именно вам. Начните бесплатно и обновитесь в любое время.
</p>
</div>
<div className="mx-auto mt-16 grid max-w-5xl grid-cols-1 gap-8 md:grid-cols-3">
{plans.map((plan, index) => (
<motion.div
key={plan.name}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Card
className={cn(
'relative flex flex-col h-full',
plan.popular && 'border-primary shadow-lg'
)}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground">
Популярный
</span>
</div>
)}
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<div className="min-h-[3.5rem] flex flex-col justify-end">
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold">
{plan.priceFormatted}
</span>
{plan.priceFormatted !== 'Бесплатно' && (
<span className="text-muted-foreground">/{plan.period}</span>
)}
</div>
</div>
<ul className="mt-8 space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-3">
<Check className="h-5 w-5 text-primary shrink-0" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
variant={plan.popular ? 'default' : 'outline'}
asChild
>
<Link href={plan.href}>{plan.cta}</Link>
</Button>
</CardFooter>
</Card>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,9 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,126 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn('mt-2 sm:mt-0', buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,50 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
xl: 'h-12 rounded-lg px-10 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,56 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,187 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,21 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,25 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-4 w-full overflow-hidden rounded-full bg-secondary', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@ -0,0 +1,124 @@
'use client';
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,33 @@
'use client';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast';
import { useToast } from '@/components/ui/use-toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@ -0,0 +1,188 @@
'use client';
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };

View File

@ -0,0 +1,160 @@
'use client';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { getSupabase } from '@/lib/supabase';
import { useRouter } from 'next/navigation';
import { api, setApiToken } from '@/lib/api';
interface AuthContextType {
user: User | null;
session: Session | null;
loading: boolean;
signUp: (email: string, password: string, name: string) => Promise<{ error: Error | null }>;
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
signOut: () => Promise<void>;
getAccessToken: () => Promise<string | null>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const supabase = getSupabase();
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
// Only set loading false when there's no session; otherwise wait for token exchange
if (!session) {
setApiToken(null);
setLoading(false);
}
});
// Listen for auth changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
if (!session) {
setApiToken(null);
setLoading(false);
}
});
return () => subscription.unsubscribe();
}, [supabase.auth]);
const runExchange = useCallback(() => {
if (!session?.access_token) {
setApiToken(null);
return;
}
api
.exchangeToken(session.access_token)
.then(({ accessToken }) => {
setApiToken(accessToken);
setLoading(false);
})
.catch(() => {
setApiToken(null);
setLoading(false);
});
}, [session?.access_token]);
// Exchange Supabase token for backend JWT; keep loading true until done so API calls wait for JWT
useEffect(() => {
runExchange();
}, [runExchange]);
// Re-exchange on 401 (e.g. JWT expired) so next request gets a fresh token
useEffect(() => {
const handler = () => {
if (session?.access_token) runExchange();
};
window.addEventListener('auth:unauthorized', handler);
return () => window.removeEventListener('auth:unauthorized', handler);
}, [session?.access_token, runExchange]);
const signUp = useCallback(
async (email: string, password: string, name: string) => {
try {
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: name,
},
},
});
if (error) throw error;
return { error: null };
} catch (error) {
return { error: error as Error };
}
},
[supabase.auth]
);
const signIn = useCallback(
async (email: string, password: string) => {
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return { error: null };
} catch (error) {
return { error: error as Error };
}
},
[supabase.auth]
);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
router.push('/');
}, [supabase.auth, router]);
const getAccessToken = useCallback(async () => {
const { data } = await supabase.auth.getSession();
return data.session?.access_token ?? null;
}, [supabase.auth]);
return (
<AuthContext.Provider
value={{
user,
session,
loading,
signUp,
signIn,
signOut,
getAccessToken,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

240
apps/web/src/lib/api.ts Normal file
View File

@ -0,0 +1,240 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const API_URL = `${API_BASE}/api`;
const STORAGE_KEY = 'coursecraft_api_token';
/** JWT from backend (set after exchangeToken). API expects JWT in Authorization header. */
let apiToken: string | null = null;
function getStoredToken(): string | null {
if (typeof window === 'undefined') return null;
try {
return sessionStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
export function setApiToken(token: string | null) {
apiToken = token;
try {
if (typeof window !== 'undefined') {
if (token) sessionStorage.setItem(STORAGE_KEY, token);
else sessionStorage.removeItem(STORAGE_KEY);
}
} catch {}
}
/** Returns current JWT (memory or sessionStorage after reload). */
function getApiToken(): string | null {
if (apiToken) return apiToken;
const stored = getStoredToken();
if (stored) {
apiToken = stored;
return stored;
}
return null;
}
class ApiClient {
private getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
const token = getApiToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const headers = this.getAuthHeaders();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
...headers,
...options.headers,
},
});
if (!response.ok) {
if (response.status === 401) {
setApiToken(null);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
}
}
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
// Auth - Exchange Supabase token for internal JWT (no Authorization header)
async exchangeToken(supabaseToken: string) {
return this.request<{ accessToken: string; user: any }>('/auth/exchange', {
method: 'POST',
body: JSON.stringify({ supabaseToken }),
});
}
// User
async getProfile() {
return this.request<any>('/users/me');
}
async updateProfile(data: { name?: string; bio?: string }) {
return this.request<any>('/users/me', {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async getSettings() {
return this.request<any>('/users/me/settings');
}
async updateSettings(data: { customAiModel?: string; language?: string; theme?: string }) {
return this.request<any>('/users/me/settings', {
method: 'PATCH',
body: JSON.stringify(data),
});
}
// Courses
async getCourses(params?: { status?: string; page?: number; limit?: number }) {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set('status', params.status);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.request<{ data: any[]; meta: any }>(`/courses${query ? `?${query}` : ''}`);
}
async getCourse(id: string) {
return this.request<any>(`/courses/${id}`);
}
async createCourse(data: { title: string; description?: string }) {
return this.request<any>('/courses', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateCourse(id: string, data: any) {
return this.request<any>(`/courses/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteCourse(id: string) {
return this.request<void>(`/courses/${id}`, {
method: 'DELETE',
});
}
// Chapters
async createChapter(courseId: string, data: { title: string; description?: string }) {
return this.request<any>(`/courses/${courseId}/chapters`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateChapter(courseId: string, chapterId: string, data: any) {
return this.request<any>(`/courses/${courseId}/chapters/${chapterId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteChapter(courseId: string, chapterId: string) {
return this.request<void>(`/courses/${courseId}/chapters/${chapterId}`, {
method: 'DELETE',
});
}
// Lessons (API: GET/PATCH /courses/:courseId/lessons/:lessonId)
async getLesson(courseId: string, lessonId: string) {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}`);
}
async updateLesson(courseId: string, lessonId: string, data: any) {
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
// Generation
async startGeneration(prompt: string) {
return this.request<{ id: string; status: string; progress: number }>('/generation/start', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
}
async getGenerationStatus(id: string) {
return this.request<{
id: string;
status: string;
progress: number;
currentStep: string | null;
questions: any[] | null;
generatedOutline: any | null;
errorMessage: string | null;
course: { id: string; slug: string } | null;
}>(`/generation/${id}/status`);
}
async answerQuestions(id: string, answers: Record<string, string | string[]>) {
return this.request<{ success: boolean }>(`/generation/${id}/answer`, {
method: 'POST',
body: JSON.stringify({ answers }),
});
}
async cancelGeneration(id: string) {
return this.request<{ success: boolean }>(`/generation/${id}/cancel`, {
method: 'POST',
});
}
// Subscription
async getSubscription() {
return this.request<any>('/payments/subscription');
}
async createCheckoutSession(priceId: string) {
return this.request<{ url: string }>('/payments/create-checkout-session', {
method: 'POST',
body: JSON.stringify({ priceId }),
});
}
async createPortalSession() {
return this.request<{ url: string }>('/payments/create-portal-session', {
method: 'POST',
});
}
// Search
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
const searchParams = new URLSearchParams({ q: query });
if (filters?.category) searchParams.set('category', filters.category);
if (filters?.difficulty) searchParams.set('difficulty', filters.difficulty);
return this.request<{ hits: any[]; totalHits: number }>(`/search?${searchParams}`);
}
}
export const api = new ApiClient();

View File

@ -0,0 +1,32 @@
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createServerSupabaseClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options });
} catch (error) {
// Ignore errors in Server Components
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options });
} catch (error) {
// Ignore errors in Server Components
}
},
},
}
);
}

Some files were not shown because too many files have changed in this diff Show More