project init
This commit is contained in:
66
.env.example
Normal file
66
.env.example
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# ===========================================
|
||||||
|
# CourseCraft Environment Variables
|
||||||
|
# ===========================================
|
||||||
|
#
|
||||||
|
# ОБЯЗАТЕЛЬНО ДЛЯ ВХОДА И API (без них будет 401 на /api/courses):
|
||||||
|
# - NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY — один и тот же проект Supabase
|
||||||
|
# - SUPABASE_SERVICE_ROLE_KEY — из того же проекта (Settings → API → service_role secret)
|
||||||
|
# - JWT_SECRET — любая длинная строка (для подписи JWT на бэкенде)
|
||||||
|
# - NEXT_PUBLIC_API_URL — URL бэкенда (для фронта; если не задан, по умолчанию localhost:3001)
|
||||||
|
#
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/coursecraft?schema=public"
|
||||||
|
|
||||||
|
# Redis (порт 6395)
|
||||||
|
REDIS_URL="redis://localhost:6395"
|
||||||
|
REDIS_HOST="localhost"
|
||||||
|
REDIS_PORT="6395"
|
||||||
|
|
||||||
|
# Supabase Auth (все три — из одного проекта Supabase!)
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
|
||||||
|
|
||||||
|
# OpenRouter AI
|
||||||
|
OPENROUTER_API_KEY="your-openrouter-api-key"
|
||||||
|
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
# Default AI model (used for all tiers; PRO users can override in settings)
|
||||||
|
AI_MODEL_DEFAULT="openai/gpt-4o-mini"
|
||||||
|
|
||||||
|
# Testing: set to true to ignore monthly course limit (for local/dev only)
|
||||||
|
# BYPASS_COURSE_LIMIT="true"
|
||||||
|
|
||||||
|
# Stripe Payments
|
||||||
|
STRIPE_SECRET_KEY="sk_test_..."
|
||||||
|
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
|
||||||
|
|
||||||
|
# Stripe Price IDs
|
||||||
|
STRIPE_PRICE_PREMIUM="price_..."
|
||||||
|
STRIPE_PRICE_PRO="price_..."
|
||||||
|
|
||||||
|
# Meilisearch
|
||||||
|
MEILISEARCH_HOST="http://localhost:7700"
|
||||||
|
MEILISEARCH_API_KEY="your-meilisearch-master-key"
|
||||||
|
|
||||||
|
# S3 / Cloudflare R2 Storage
|
||||||
|
S3_ENDPOINT="https://your-account.r2.cloudflarestorage.com"
|
||||||
|
S3_ACCESS_KEY_ID="your-access-key"
|
||||||
|
S3_SECRET_ACCESS_KEY="your-secret-key"
|
||||||
|
S3_BUCKET_NAME="coursecraft"
|
||||||
|
S3_REGION="auto"
|
||||||
|
|
||||||
|
# App URLs
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3125"
|
||||||
|
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
||||||
|
API_URL="http://localhost:3001"
|
||||||
|
AI_SERVICE_URL="http://localhost:3002"
|
||||||
|
|
||||||
|
# JWT Secret — ОБЯЗАТЕЛЕН для API (подпись токенов после обмена с Supabase)
|
||||||
|
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
NODE_ENV="development"
|
||||||
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
es2022: true,
|
||||||
|
},
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint'],
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'prettier',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||||
|
},
|
||||||
|
ignorePatterns: ['node_modules', 'dist', '.next', '.turbo'],
|
||||||
|
};
|
||||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
.turbo
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
.start.pid
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.pem
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker/data
|
||||||
10
.prettierrc
Normal file
10
.prettierrc
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
160
README.md
Normal file
160
README.md
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
# CourseCraft - AI-Powered Course Creation Platform
|
||||||
|
|
||||||
|
CourseCraft - это платформа для создания образовательных курсов с помощью искусственного интеллекта. Просто опишите тему курса, и AI создаст полноценный курс с структурой, главами, уроками и контентом.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **AI-генерация курсов** - создание курсов по текстовому описанию
|
||||||
|
- **Уточняющие вопросы** - AI задаёт вопросы для лучшего понимания потребностей
|
||||||
|
- **WYSIWYG редактор** - редактирование курсов с помощью TipTap
|
||||||
|
- **AI-режим редактирования** - переписывание текста с помощью нейросети
|
||||||
|
- **Система тарифов** - FREE, PREMIUM, PRO с разными лимитами
|
||||||
|
- **Настраиваемые AI модели** - возможность выбора модели через OpenRouter
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Next.js 14 (App Router)
|
||||||
|
- TypeScript
|
||||||
|
- TailwindCSS + shadcn/ui
|
||||||
|
- TipTap - WYSIWYG редактор
|
||||||
|
- Framer Motion - анимации
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- NestJS
|
||||||
|
- PostgreSQL + Prisma
|
||||||
|
- Redis + BullMQ
|
||||||
|
- Supabase Auth
|
||||||
|
- Stripe - платежи
|
||||||
|
|
||||||
|
### AI
|
||||||
|
- OpenRouter API
|
||||||
|
- Настраиваемые модели (GPT-4, Claude, Llama и др.)
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
- Docker Compose
|
||||||
|
- Meilisearch - полнотекстовый поиск
|
||||||
|
- pgvector - embeddings
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- Node.js 20+
|
||||||
|
- pnpm 9+
|
||||||
|
- Docker и Docker Compose
|
||||||
|
|
||||||
|
### Установка
|
||||||
|
|
||||||
|
1. Клонируйте репозиторий:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-username/coursecraft.git
|
||||||
|
cd coursecraft
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Установите зависимости:
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Создайте файл `.env` из примера:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Заполните переменные окружения в `.env`:
|
||||||
|
- `DATABASE_URL` - URL PostgreSQL
|
||||||
|
- `REDIS_URL` - URL Redis
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL` - URL вашего Supabase проекта
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` - Anon key Supabase
|
||||||
|
- `SUPABASE_SERVICE_ROLE_KEY` - Service role key Supabase
|
||||||
|
- `OPENROUTER_API_KEY` - API ключ OpenRouter
|
||||||
|
- `STRIPE_SECRET_KEY` - Secret key Stripe
|
||||||
|
- `STRIPE_WEBHOOK_SECRET` - Webhook secret Stripe
|
||||||
|
- `MEILISEARCH_API_KEY` - Master key Meilisearch
|
||||||
|
|
||||||
|
5. Запустите Docker контейнеры:
|
||||||
|
```bash
|
||||||
|
pnpm docker:up
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Примените миграции базы данных:
|
||||||
|
```bash
|
||||||
|
pnpm db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Запустите приложение в режиме разработки:
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресам:
|
||||||
|
- Frontend: http://localhost:3000
|
||||||
|
- API: http://localhost:3001
|
||||||
|
- API Docs: http://localhost:3001/docs
|
||||||
|
- Meilisearch: http://localhost:7700
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
coursecraft/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # Next.js Frontend
|
||||||
|
│ ├── api/ # NestJS Backend
|
||||||
|
│ └── ai-service/ # AI Pipeline Service
|
||||||
|
├── packages/
|
||||||
|
│ ├── database/ # Prisma схема
|
||||||
|
│ └── shared/ # Общие типы и утилиты
|
||||||
|
├── docker/
|
||||||
|
│ └── docker-compose.yml
|
||||||
|
└── turbo.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Разработка
|
||||||
|
pnpm dev # Запуск всех сервисов
|
||||||
|
pnpm build # Сборка всех пакетов
|
||||||
|
pnpm lint # Проверка кода
|
||||||
|
|
||||||
|
# База данных
|
||||||
|
pnpm db:generate # Генерация Prisma клиента
|
||||||
|
pnpm db:push # Применение схемы
|
||||||
|
pnpm db:migrate # Создание миграции
|
||||||
|
pnpm db:studio # Открыть Prisma Studio
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
pnpm docker:up # Запуск контейнеров
|
||||||
|
pnpm docker:down # Остановка контейнеров
|
||||||
|
pnpm docker:logs # Логи контейнеров
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тарифные планы
|
||||||
|
|
||||||
|
| План | Курсов/мес | AI Модель | Цена |
|
||||||
|
|------|-----------|-----------|------|
|
||||||
|
| Free | 2 | GPT-4o Mini | $0 |
|
||||||
|
| Premium | 5 | GPT-4o Mini | $9.99 |
|
||||||
|
| Pro | 15 | GPT-4o Mini (или своя в настройках) | $29.99 |
|
||||||
|
|
||||||
|
На Pro плане можно указать любую модель OpenRouter в настройках.
|
||||||
|
|
||||||
|
## Конфигурация AI моделей
|
||||||
|
|
||||||
|
По умолчанию используется **openai/gpt-4o-mini**. На плане Pro в настройках можно указать свою модель OpenRouter, например:
|
||||||
|
```
|
||||||
|
openai/gpt-4o-mini
|
||||||
|
anthropic/claude-3.5-sonnet
|
||||||
|
openai/gpt-4-turbo
|
||||||
|
```
|
||||||
|
|
||||||
|
Формат: `provider/model-name`
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
- Email: support@coursecraft.ai
|
||||||
|
- Docs: https://docs.coursecraft.ai
|
||||||
26
apps/ai-service/package.json
Normal file
26
apps/ai-service/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
108
apps/ai-service/src/index.ts
Normal file
108
apps/ai-service/src/index.ts
Normal 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);
|
||||||
|
});
|
||||||
289
apps/ai-service/src/pipeline/course-generation.pipeline.ts
Normal file
289
apps/ai-service/src/pipeline/course-generation.pipeline.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
393
apps/ai-service/src/providers/openrouter.provider.ts
Normal file
393
apps/ai-service/src/providers/openrouter.provider.ts
Normal 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 = `Ты - эксперт по созданию образовательных курсов.
|
||||||
|
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
|
||||||
|
|
||||||
|
ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя):
|
||||||
|
- Если пользователь выбрал короткий курс / введение: 2–4 главы, в каждой 2–4 урока. estimatedTotalHours: 2–8.
|
||||||
|
- Если средний курс: 4–7 глав, в каждой 3–5 уроков. estimatedTotalHours: 8–20.
|
||||||
|
- Если длинный / полный курс: 6–12 глав, в каждой 4–8 уроков. estimatedTotalHours: 15–40.
|
||||||
|
- Если объём не указан — предложи средний (4–6 глав по 3–5 уроков).
|
||||||
|
|
||||||
|
Укажи примерное время на каждый урок (estimatedMinutes: 10–45). 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 нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения
|
||||||
|
|
||||||
|
ОБЪЁМ: зависит от темы. Короткий урок: 300–600 слов (3–5 блоков). Средний: 600–1200 слов. Длинный: 1200–2500 слов. Структура: заголовок, введение, 2–4 секции с подзаголовками, примеры/код/списки, резюме.
|
||||||
|
|
||||||
|
Уровень: ${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 || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/ai-service/tsconfig.json
Normal file
9
apps/ai-service/tsconfig.json
Normal 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
8
apps/api/nest-cli.json
Normal 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
57
apps/api/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/api/src/app.module.ts
Normal file
46
apps/api/src/app.module.ts
Normal 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 {}
|
||||||
43
apps/api/src/auth/auth.controller.ts
Normal file
43
apps/api/src/auth/auth.controller.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/api/src/auth/auth.module.ts
Normal file
40
apps/api/src/auth/auth.module.ts
Normal 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 {}
|
||||||
65
apps/api/src/auth/auth.service.ts
Normal file
65
apps/api/src/auth/auth.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
11
apps/api/src/auth/decorators/current-user.decorator.ts
Normal 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;
|
||||||
|
}
|
||||||
|
);
|
||||||
4
apps/api/src/auth/decorators/public.decorator.ts
Normal file
4
apps/api/src/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
12
apps/api/src/auth/dto/exchange-token.dto.ts
Normal file
12
apps/api/src/auth/dto/exchange-token.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
83
apps/api/src/auth/guards/jwt-auth.guard.ts
Normal file
83
apps/api/src/auth/guards/jwt-auth.guard.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/api/src/auth/guards/supabase-auth.guard.ts
Normal file
72
apps/api/src/auth/guards/supabase-auth.guard.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/api/src/auth/strategies/jwt.strategy.ts
Normal file
62
apps/api/src/auth/strategies/jwt.strategy.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/api/src/auth/supabase.service.ts
Normal file
56
apps/api/src/auth/supabase.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/common/prisma/prisma.module.ts
Normal file
9
apps/api/src/common/prisma/prisma.module.ts
Normal 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 {}
|
||||||
19
apps/api/src/common/prisma/prisma.service.ts
Normal file
19
apps/api/src/common/prisma/prisma.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/api/src/courses/chapters.controller.ts
Normal file
67
apps/api/src/courses/chapters.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
apps/api/src/courses/chapters.service.ts
Normal file
131
apps/api/src/courses/chapters.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/api/src/courses/courses.controller.ts
Normal file
78
apps/api/src/courses/courses.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/courses/courses.module.ts
Normal file
14
apps/api/src/courses/courses.module.ts
Normal 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 {}
|
||||||
231
apps/api/src/courses/courses.service.ts
Normal file
231
apps/api/src/courses/courses.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/api/src/courses/dto/create-chapter.dto.ts
Normal file
22
apps/api/src/courses/dto/create-chapter.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
25
apps/api/src/courses/dto/create-course.dto.ts
Normal file
25
apps/api/src/courses/dto/create-course.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
30
apps/api/src/courses/dto/create-lesson.dto.ts
Normal file
30
apps/api/src/courses/dto/create-lesson.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
17
apps/api/src/courses/dto/update-chapter.dto.ts
Normal file
17
apps/api/src/courses/dto/update-chapter.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
71
apps/api/src/courses/dto/update-course.dto.ts
Normal file
71
apps/api/src/courses/dto/update-course.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
34
apps/api/src/courses/dto/update-lesson.dto.ts
Normal file
34
apps/api/src/courses/dto/update-lesson.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
67
apps/api/src/courses/lessons.controller.ts
Normal file
67
apps/api/src/courses/lessons.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/api/src/courses/lessons.service.ts
Normal file
141
apps/api/src/courses/lessons.service.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/generation/dto/answer-questions.dto.ts
Normal file
15
apps/api/src/generation/dto/answer-questions.dto.ts
Normal 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[]>;
|
||||||
|
}
|
||||||
16
apps/api/src/generation/dto/start-generation.dto.ts
Normal file
16
apps/api/src/generation/dto/start-generation.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
90
apps/api/src/generation/generation.controller.ts
Normal file
90
apps/api/src/generation/generation.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/api/src/generation/generation.module.ts
Normal file
24
apps/api/src/generation/generation.module.ts
Normal 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 {}
|
||||||
226
apps/api/src/generation/generation.service.ts
Normal file
226
apps/api/src/generation/generation.service.ts
Normal 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
60
apps/api/src/main.ts
Normal 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();
|
||||||
41
apps/api/src/payments/payments.controller.ts
Normal file
41
apps/api/src/payments/payments.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/payments/payments.module.ts
Normal file
12
apps/api/src/payments/payments.module.ts
Normal 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 {}
|
||||||
231
apps/api/src/payments/payments.service.ts
Normal file
231
apps/api/src/payments/payments.service.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/api/src/payments/stripe.service.ts
Normal file
69
apps/api/src/payments/stripe.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/api/src/payments/webhooks.controller.ts
Normal file
52
apps/api/src/payments/webhooks.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
156
apps/api/src/search/meilisearch.service.ts
Normal file
156
apps/api/src/search/meilisearch.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apps/api/src/search/search.controller.ts
Normal file
58
apps/api/src/search/search.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/search/search.module.ts
Normal file
11
apps/api/src/search/search.module.ts
Normal 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 {}
|
||||||
181
apps/api/src/search/search.service.ts
Normal file
181
apps/api/src/search/search.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/users/dto/create-user.dto.ts
Normal file
17
apps/api/src/users/dto/create-user.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
35
apps/api/src/users/dto/update-settings.dto.ts
Normal file
35
apps/api/src/users/dto/update-settings.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
14
apps/api/src/users/dto/update-user.dto.ts
Normal file
14
apps/api/src/users/dto/update-user.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
60
apps/api/src/users/users.controller.ts
Normal file
60
apps/api/src/users/users.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/users/users.module.ts
Normal file
10
apps/api/src/users/users.module.ts
Normal 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 {}
|
||||||
150
apps/api/src/users/users.service.ts
Normal file
150
apps/api/src/users/users.service.ts
Normal 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
18
apps/api/tsconfig.json
Normal 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
5
apps/web/next-env.d.ts
vendored
Normal 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
19
apps/web/next.config.js
Normal 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
68
apps/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
13
apps/web/src/app/(auth)/layout.tsx
Normal file
13
apps/web/src/app/(auth)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
apps/web/src/app/(auth)/login/page.tsx
Normal file
133
apps/web/src/app/(auth)/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
apps/web/src/app/(auth)/register/page.tsx
Normal file
179
apps/web/src/app/(auth)/register/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
apps/web/src/app/(dashboard)/dashboard/billing/page.tsx
Normal file
139
apps/web/src/app/(dashboard)/dashboard/billing/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx
Normal file
235
apps/web/src/app/(dashboard)/dashboard/courses/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
512
apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx
Normal file
512
apps/web/src/app/(dashboard)/dashboard/courses/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
139
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/web/src/app/(dashboard)/dashboard/settings/page.tsx
Normal file
137
apps/web/src/app/(dashboard)/dashboard/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/src/app/(dashboard)/layout.tsx
Normal file
18
apps/web/src/app/(dashboard)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/web/src/app/globals.css
Normal file
106
apps/web/src/app/globals.css
Normal 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);
|
||||||
|
}
|
||||||
45
apps/web/src/app/layout.tsx
Normal file
45
apps/web/src/app/layout.tsx
Normal 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
23
apps/web/src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
apps/web/src/components/dashboard/course-card.tsx
Normal file
199
apps/web/src/components/dashboard/course-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/web/src/components/dashboard/header.tsx
Normal file
95
apps/web/src/components/dashboard/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/web/src/components/dashboard/sidebar.tsx
Normal file
98
apps/web/src/components/dashboard/sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
394
apps/web/src/components/editor/course-editor.tsx
Normal file
394
apps/web/src/components/editor/course-editor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/web/src/components/editor/lesson-content-viewer.tsx
Normal file
70
apps/web/src/components/editor/lesson-content-viewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
apps/web/src/components/editor/lesson-sidebar.tsx
Normal file
118
apps/web/src/components/editor/lesson-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
apps/web/src/components/landing/faq.tsx
Normal file
103
apps/web/src/components/landing/faq.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
apps/web/src/components/landing/features.tsx
Normal file
111
apps/web/src/components/landing/features.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/web/src/components/landing/footer.tsx
Normal file
99
apps/web/src/components/landing/footer.tsx
Normal 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">
|
||||||
|
© {new Date().getFullYear()} CourseCraft. Все права защищены.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/web/src/components/landing/header.tsx
Normal file
144
apps/web/src/components/landing/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/src/components/landing/hero.tsx
Normal file
105
apps/web/src/components/landing/hero.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
apps/web/src/components/landing/how-it-works.tsx
Normal file
82
apps/web/src/components/landing/how-it-works.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/web/src/components/landing/pricing.tsx
Normal file
108
apps/web/src/components/landing/pricing.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/web/src/components/providers/theme-provider.tsx
Normal file
9
apps/web/src/components/providers/theme-provider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
126
apps/web/src/components/ui/alert-dialog.tsx
Normal file
126
apps/web/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
50
apps/web/src/components/ui/avatar.tsx
Normal file
50
apps/web/src/components/ui/avatar.tsx
Normal 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 };
|
||||||
50
apps/web/src/components/ui/button.tsx
Normal file
50
apps/web/src/components/ui/button.tsx
Normal 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 };
|
||||||
56
apps/web/src/components/ui/card.tsx
Normal file
56
apps/web/src/components/ui/card.tsx
Normal 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 };
|
||||||
187
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
187
apps/web/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
};
|
||||||
24
apps/web/src/components/ui/input.tsx
Normal file
24
apps/web/src/components/ui/input.tsx
Normal 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 };
|
||||||
21
apps/web/src/components/ui/label.tsx
Normal file
21
apps/web/src/components/ui/label.tsx
Normal 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 };
|
||||||
25
apps/web/src/components/ui/progress.tsx
Normal file
25
apps/web/src/components/ui/progress.tsx
Normal 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 };
|
||||||
124
apps/web/src/components/ui/toast.tsx
Normal file
124
apps/web/src/components/ui/toast.tsx
Normal 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,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user