Старт
This commit is contained in:
36
.env.example
Normal file
36
.env.example
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# PostgreSQL Database Configuration
|
||||||
|
POSTGRES_USER=auth_user
|
||||||
|
POSTGRES_PASSWORD=auth_password
|
||||||
|
POSTGRES_DB=auth_learning
|
||||||
|
|
||||||
|
# FastAPI Backend Configuration
|
||||||
|
DATABASE_URL=postgresql://auth_user:auth_password@postgres:5432/auth_learning
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# Application URLs (for development)
|
||||||
|
FRONTEND_URL=http://localhost
|
||||||
|
BACKEND_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# CORS Origins (comma separated)
|
||||||
|
CORS_ORIGINS=http://localhost,http://localhost:80
|
||||||
|
|
||||||
|
# Logging Level
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Email configuration (for production)
|
||||||
|
# SMTP_HOST=smtp.gmail.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=your-email@gmail.com
|
||||||
|
# SMTP_PASSWORD=your-password
|
||||||
|
# EMAIL_FROM=noreply@yourapp.com
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# Require HTTPS in production
|
||||||
|
# SECURE_COOKIES=true
|
||||||
|
# SESSION_COOKIE_SECURE=true
|
||||||
|
|
||||||
|
# Development flags
|
||||||
|
DEBUG=true
|
||||||
|
RELOAD=true
|
||||||
103
.gitignore
vendored
Normal file
103
.gitignore
vendored
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv/
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Python testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.hypothesis/
|
||||||
|
*.cover
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
.pyre/
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Alembic
|
||||||
|
alembic/versions/__pycache__/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
|
.secret/
|
||||||
|
|
||||||
|
# Backups
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
docker-compose.yml.bACK
|
||||||
|
docker-compose.yml.backup
|
||||||
|
nginx.conf.backup
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.eslintcache
|
||||||
|
.cache/
|
||||||
63
FIX_INSTRUCTIONS.md
Normal file
63
FIX_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Исправление ошибки email-validator
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
При запуске бэкенда возникает ошибка:
|
||||||
|
```
|
||||||
|
ModuleNotFoundError: No module named 'email_validator'
|
||||||
|
ImportError: email-validator is not installed, run `pip install pydantic[email]`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
### Способ 1: Пересобрать Docker образы (рекомендуется)
|
||||||
|
```bash
|
||||||
|
# Остановите текущие контейнеры (если запущены)
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Пересоберите и запустите заново
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Способ 2: Если Docker не запущен
|
||||||
|
1. Убедитесь, что Docker запущен:
|
||||||
|
```bash
|
||||||
|
docker info
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Если Docker не запущен, запустите Docker Desktop или Docker daemon
|
||||||
|
|
||||||
|
3. Затем выполните Способ 1
|
||||||
|
|
||||||
|
### Способ 3: Локальная установка (для разработки)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install email-validator>=2.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что было исправлено
|
||||||
|
1. В файл `backend/requirements.txt` изменена строка:
|
||||||
|
- Было: `email-validator==2.1.0` (yanked версия)
|
||||||
|
- Стало: `email-validator>=2.0.0` (любая версия 2.0.0 и выше)
|
||||||
|
|
||||||
|
2. Теперь при сборке Docker образа будет установлена корректная версия пакета
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
После пересборки проверьте логи:
|
||||||
|
```bash
|
||||||
|
docker-compose logs backend | grep -A5 -B5 "Uvicorn running"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый вывод:
|
||||||
|
```
|
||||||
|
auth_learning_backend | INFO: Uvicorn running on http://0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительная информация
|
||||||
|
Пакет `email-validator` требуется для валидации email адресов в Pydantic схемах.
|
||||||
|
В схеме `UserBase` используется `EmailStr` для строгой валидации email.
|
||||||
|
|
||||||
|
## Контакты
|
||||||
|
Если проблема сохраняется, проверьте полные логи:
|
||||||
|
```bash
|
||||||
|
docker-compose logs backend
|
||||||
|
```
|
||||||
126
QUICKSTART.md
Normal file
126
QUICKSTART.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Быстрый старт Auth Learning App
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
- Docker 20.10+ (запущенный)
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
1. **Клонируйте проект** (если нужно):
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd auth-learning-app
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Настройте переменные окружения**:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Отредактируйте .env при необходимости
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Запустите приложение**:
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Или в фоновом режиме:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Приложение будет доступно**:
|
||||||
|
- 🌐 Frontend: http://localhost
|
||||||
|
- 📚 API Documentation: http://localhost:8000/docs
|
||||||
|
- 📖 ReDoc: http://localhost:8000/redoc
|
||||||
|
|
||||||
|
## Тестовые учетные данные
|
||||||
|
- **Email**: test@example.com
|
||||||
|
- **Password**: Test1234
|
||||||
|
|
||||||
|
## Очистка Docker (если нужно)
|
||||||
|
|
||||||
|
### Использование скрипта:
|
||||||
|
```bash
|
||||||
|
# Мягкая очистка (контейнеры и сети)
|
||||||
|
./docker-cleanup.sh --soft
|
||||||
|
|
||||||
|
# Средняя очистка (рекомендуется) + образы без тегов
|
||||||
|
./docker-cleanup.sh --medium
|
||||||
|
|
||||||
|
# Полная очистка (ВНИМАНИЕ: удалит базу данных!)
|
||||||
|
./docker-cleanup.sh --all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ручная очистка:
|
||||||
|
```bash
|
||||||
|
# Остановить и удалить контейнеры проекта
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Удалить все неиспользуемые образы
|
||||||
|
docker image prune -a
|
||||||
|
|
||||||
|
# Удалить все неиспользуемые volumes (удалит БД!)
|
||||||
|
docker volume prune
|
||||||
|
|
||||||
|
# Очистить build cache
|
||||||
|
docker builder prune
|
||||||
|
```
|
||||||
|
|
||||||
|
## Решение проблем
|
||||||
|
|
||||||
|
### 1. Docker не запущен
|
||||||
|
```bash
|
||||||
|
# Запустите Docker Desktop или Docker daemon
|
||||||
|
# Проверьте статус:
|
||||||
|
docker info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Порт уже используется
|
||||||
|
```bash
|
||||||
|
# Найдите процесс, использующий порт
|
||||||
|
sudo lsof -i :80 # Frontend
|
||||||
|
sudo lsof -i :8000 # Backend API
|
||||||
|
sudo lsof -i :5432 # PostgreSQL
|
||||||
|
|
||||||
|
# Остановите процесс или измените порты в docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Проблемы с базой данных
|
||||||
|
```bash
|
||||||
|
# Просмотрите логи PostgreSQL
|
||||||
|
docker-compose logs postgres
|
||||||
|
|
||||||
|
# Пересоздайте volume (удалит данные!)
|
||||||
|
docker-compose down -v
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Проблемы с зависимостями
|
||||||
|
```bash
|
||||||
|
# Пересоберите образы с очисткой кэша
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логи приложения
|
||||||
|
```bash
|
||||||
|
# Все сервисы
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Конкретный сервис
|
||||||
|
docker-compose logs -f backend
|
||||||
|
docker-compose logs -f frontend
|
||||||
|
docker-compose logs -f postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## Остановка приложения
|
||||||
|
```bash
|
||||||
|
# Остановить все сервисы
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Остановить с удалением volumes (удалит БД!)
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительная информация
|
||||||
|
Полная документация: [README.md](README.md)
|
||||||
338
README.md
Normal file
338
README.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# Auth Learning App
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
**Полнофункциональное SPA приложение для обучения с системой авторизации, избранными курсами и отслеживанием прогресса**
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 📋 О проекте
|
||||||
|
|
||||||
|
Auth Learning App - это современное веб-приложение для управления обучающими курсами, построенное на микросервисной архитектуре. Приложение предоставляет полный цикл работы с курсами: от регистрации пользователей до отслеживания прогресса обучения.
|
||||||
|
|
||||||
|
### Ключевые особенности:
|
||||||
|
- 🔐 **Безопасная аутентификация** с JWT токенами
|
||||||
|
- 📚 **Каталог курсов** с фильтрацией по уровням сложности
|
||||||
|
- ⭐ **Система избранных курсов** для сохранения интересных материалов
|
||||||
|
- 📊 **Отслеживание прогресса** с визуализацией выполнения
|
||||||
|
- 🐳 **Полная контейнеризация** с Docker Compose
|
||||||
|
- 📱 **Адаптивный SPA интерфейс** на чистом JavaScript
|
||||||
|
|
||||||
|
## 🏗️ Архитектура и стек технологий
|
||||||
|
|
||||||
|
### Бэкенд (FastAPI 3.11+)
|
||||||
|
- **FastAPI** - высокопроизводительный веб-фреймворк с автоматической документацией
|
||||||
|
- **SQLAlchemy ORM** + **Alembic** для работы с базой данных и миграциями
|
||||||
|
- **PostgreSQL 15** - реляционная база данных
|
||||||
|
- **JWT аутентификация** с access/refresh токенами
|
||||||
|
- **Pydantic** для валидации данных
|
||||||
|
- **Bcrypt** для безопасного хеширования паролей
|
||||||
|
|
||||||
|
### Фронтенд (Vanilla JavaScript SPA)
|
||||||
|
- **Чистый JavaScript** без фреймворков
|
||||||
|
- **SPA архитектура** с History API роутингом
|
||||||
|
- **Компонентный подход** с модулями ES6
|
||||||
|
- **LocalStorage** для сохранения сессии
|
||||||
|
- **Адаптивный дизайн** с современным CSS (Flexbox/Grid)
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
- **Docker + Docker Compose** - контейнеризация и оркестрация
|
||||||
|
- **Nginx** - веб-сервер для фронтенда и прокси к API
|
||||||
|
- **PostgreSQL** с volume для сохранения данных
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
auth-learning-app/
|
||||||
|
├── backend/ # FastAPI приложение
|
||||||
|
│ ├── app/ # Основной код приложения
|
||||||
|
│ │ ├── models.py # Модели базы данных
|
||||||
|
│ │ ├── schemas.py # Pydantic схемы
|
||||||
|
│ │ ├── crud.py # CRUD операции
|
||||||
|
│ │ ├── routes/ # API endpoints
|
||||||
|
│ │ │ ├── auth.py # Аутентификация
|
||||||
|
│ │ │ ├── courses.py # Курсы
|
||||||
|
│ │ │ ├── favorites.py # Избранные курсы
|
||||||
|
│ │ │ ├── progress.py # Прогресс обучения
|
||||||
|
│ │ │ └── stats.py # Статистика
|
||||||
|
│ │ └── main.py # Точка входа
|
||||||
|
│ ├── requirements.txt # Python зависимости
|
||||||
|
│ └── Dockerfile # Контейнеризация Python
|
||||||
|
├── frontend/ # JavaScript SPA
|
||||||
|
│ ├── public/index.html # Единственный HTML файл
|
||||||
|
│ ├── src/ # Исходный код SPA
|
||||||
|
│ │ ├── main.js # Точка входа
|
||||||
|
│ │ ├── router.js # Маршрутизация SPA
|
||||||
|
│ │ ├── api/ # API клиенты
|
||||||
|
│ │ ├── components/ # UI компоненты
|
||||||
|
│ │ ├── views/ # Страницы приложения
|
||||||
|
│ │ └── utils/ # Вспомогательные функции
|
||||||
|
│ ├── css/ # Стили
|
||||||
|
│ ├── Dockerfile # Контейнеризация Nginx
|
||||||
|
│ └── nginx.conf # Конфигурация Nginx
|
||||||
|
├── nginx/ # Конфигурация Nginx
|
||||||
|
├── docker-compose.yml # Оркестрация контейнеров
|
||||||
|
├── .env.example # Пример переменных окружения
|
||||||
|
├── docker-cleanup.sh # Скрипт очистки Docker
|
||||||
|
└── README.md # Эта документация
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
- Git (для клонирования репозитория)
|
||||||
|
|
||||||
|
### Установка и запуск
|
||||||
|
|
||||||
|
1. **Клонируйте репозиторий (если нужно)**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd auth-learning-app
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Настройте переменные окружения**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Отредактируйте .env при необходимости
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Запустите приложение**
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Приложение будет доступно по адресам:**
|
||||||
|
- 🌐 **Frontend**: http://localhost
|
||||||
|
- 📚 **API Documentation**: http://localhost:8000/docs
|
||||||
|
- 📖 **ReDoc Documentation**: http://localhost:8000/redoc
|
||||||
|
- 🗄️ **PostgreSQL**: localhost:5432
|
||||||
|
|
||||||
|
### Тестовые учетные данные
|
||||||
|
- **Email**: test@example.com
|
||||||
|
- **Password**: Test1234
|
||||||
|
|
||||||
|
## 📚 Функционал
|
||||||
|
|
||||||
|
### Аутентификация и регистрация
|
||||||
|
- ✅ Регистрация новых пользователей с валидацией email
|
||||||
|
- ✅ Вход в систему с JWT токенами
|
||||||
|
- ✅ Защищенные маршруты с проверкой авторизации
|
||||||
|
- ✅ Выход из системы с инвалидацией токенов
|
||||||
|
|
||||||
|
### Управление курсами
|
||||||
|
- ✅ Просмотр каталога курсов
|
||||||
|
- ✅ Фильтрация по уровню сложности (beginner/intermediate/advanced)
|
||||||
|
- ✅ Поиск и детальный просмотр курсов
|
||||||
|
- ✅ Карточки курсов с полной информацией
|
||||||
|
|
||||||
|
### Избранные курсы
|
||||||
|
- ✅ Добавление курсов в избранное
|
||||||
|
- ✅ Удаление курсов из избранного
|
||||||
|
- ✅ Отдельная страница "My Favorites"
|
||||||
|
- ✅ Визуальная индикация в карточках курсов
|
||||||
|
|
||||||
|
### Отслеживание прогресса
|
||||||
|
- ✅ Отслеживание прогресса по каждому курсу
|
||||||
|
- ✅ Визуализация с помощью прогресс-бара
|
||||||
|
- ✅ Автоматический расчет процента выполнения
|
||||||
|
- ✅ Страница "My Progress" с обзором всех курсов
|
||||||
|
- ✅ Возможность обновления прогресса
|
||||||
|
|
||||||
|
### Статистика
|
||||||
|
- ✅ Общая статистика системы
|
||||||
|
- ✅ Количество пользователей и курсов
|
||||||
|
- ✅ Самый популярный курс
|
||||||
|
|
||||||
|
## 🔧 API Endpoints
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
- `POST /api/auth/register` - Регистрация нового пользователя
|
||||||
|
- `POST /api/auth/login` - Вход в систему
|
||||||
|
- `POST /api/auth/logout` - Выход из системы
|
||||||
|
- `GET /api/auth/me` - Получение информации о текущем пользователе
|
||||||
|
|
||||||
|
### Курсы
|
||||||
|
- `GET /api/courses` - Получение списка курсов (с фильтрацией по level)
|
||||||
|
- `GET /api/courses/{id}` - Получение детальной информации о курсе
|
||||||
|
|
||||||
|
### Избранные курсы
|
||||||
|
- `GET /api/favorites/` - Получение списка избранных курсов
|
||||||
|
- `POST /api/favorites/` - Добавление курса в избранное
|
||||||
|
- `DELETE /api/favorites/{course_id}` - Удаление курса из избранного
|
||||||
|
|
||||||
|
### Прогресс обучения
|
||||||
|
- `GET /api/progress/` - Получение всего прогресса пользователя
|
||||||
|
- `GET /api/progress/{course_id}` - Получение прогресса по конкретному курсу
|
||||||
|
- `POST /api/progress/{course_id}` - Обновление прогресса по курсу
|
||||||
|
- `DELETE /api/progress/{course_id}` - Удаление записи прогресса
|
||||||
|
|
||||||
|
### Статистика
|
||||||
|
- `GET /api/stats` - Получение общей статистики системы
|
||||||
|
|
||||||
|
## 🐳 Docker Cleanup
|
||||||
|
|
||||||
|
Для управления Docker ресурсами проекта используйте скрипт `docker-cleanup.sh`:
|
||||||
|
|
||||||
|
### Уровни очистки:
|
||||||
|
|
||||||
|
1. **Мягкая очистка** (контейнеры и сети проекта):
|
||||||
|
```bash
|
||||||
|
./docker-cleanup.sh --soft
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Средняя очистка** (контейнеры, сети и образы без тегов) - **рекомендуется**:
|
||||||
|
```bash
|
||||||
|
./docker-cleanup.sh --medium
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Полная очистка** (контейнеры, сети, образы, volumes и build cache) - **удаляет БД!**:
|
||||||
|
```bash
|
||||||
|
./docker-cleanup.sh --all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ручная очистка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Остановить и удалить контейнеры проекта
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Удалить все неиспользуемые образы
|
||||||
|
docker image prune -a
|
||||||
|
|
||||||
|
# Удалить все неиспользуемые volumes (ВНИМАНИЕ: удалит данные БД!)
|
||||||
|
docker volume prune
|
||||||
|
|
||||||
|
# Очистить build cache
|
||||||
|
docker builder prune
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Разработка
|
||||||
|
|
||||||
|
### Запуск в режиме разработки
|
||||||
|
|
||||||
|
1. **Запустите только базу данных:**
|
||||||
|
```bash
|
||||||
|
docker-compose up postgres -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Запустите бэкенд локально:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Запустите фронтенд локально:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
# Используйте live-server или любой другой HTTP сервер
|
||||||
|
python -m http.server 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Миграции базы данных
|
||||||
|
|
||||||
|
Приложение использует автоматическое создание таблиц через SQLAlchemy. Для продвинутых сценариев миграций:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать новую миграцию
|
||||||
|
docker-compose exec backend alembic revision --autogenerate -m "description"
|
||||||
|
|
||||||
|
# Применить миграции
|
||||||
|
docker-compose exec backend alembic upgrade head
|
||||||
|
|
||||||
|
# Откатить миграцию
|
||||||
|
docker-compose exec backend alembic downgrade -1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Возможные улучшения
|
||||||
|
|
||||||
|
### Ближайшие планы:
|
||||||
|
1. **Уведомления** - система уведомлений о новых курсах и достижениях
|
||||||
|
2. **Комментарии к курсам** - возможность обсуждения материалов
|
||||||
|
3. **Рейтинги курсов** - система оценок и отзывов
|
||||||
|
4. **Административная панель** - управление курсами и пользователями
|
||||||
|
|
||||||
|
### Технические улучшения:
|
||||||
|
1. **Кэширование** - Redis для кэширования частых запросов
|
||||||
|
2. **Фоновые задачи** - Celery для обработки асинхронных задач
|
||||||
|
3. **Мониторинг** - Prometheus + Grafana для мониторинга
|
||||||
|
4. **Логирование** - централизованная система логов
|
||||||
|
|
||||||
|
### Безопасность:
|
||||||
|
1. **Rate limiting** - ограничение частоты запросов
|
||||||
|
2. **Двухфакторная аутентификация**
|
||||||
|
3. **Роли пользователей** - администратор, модератор, студент
|
||||||
|
4. **HTTPS в продакшене**
|
||||||
|
|
||||||
|
## 🐛 Поиск и устранение неисправностей
|
||||||
|
|
||||||
|
### Распространенные проблемы:
|
||||||
|
|
||||||
|
1. **Порт уже используется:**
|
||||||
|
```bash
|
||||||
|
# Найдите процесс, использующий порт
|
||||||
|
sudo lsof -i :80
|
||||||
|
sudo lsof -i :8000
|
||||||
|
sudo lsof -i :5432
|
||||||
|
|
||||||
|
# Остановите процесс или измените порты в docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Проблемы с базой данных:**
|
||||||
|
```bash
|
||||||
|
# Проверьте статус PostgreSQL
|
||||||
|
docker-compose logs postgres
|
||||||
|
|
||||||
|
# Пересоздайте volume (удалит данные!)
|
||||||
|
docker-compose down -v
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Проблемы с зависимостями:**
|
||||||
|
```bash
|
||||||
|
# Пересоберите образы
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логи:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Просмотр логов всех сервисов
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Просмотр логов конкретного сервиса
|
||||||
|
docker-compose logs -f backend
|
||||||
|
docker-compose logs -f frontend
|
||||||
|
docker-compose logs -f postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 Лицензия
|
||||||
|
|
||||||
|
Этот проект создан для образовательных целей. Используйте его как основу для своих проектов или для изучения современных веб-технологий.
|
||||||
|
|
||||||
|
## 🤝 Вклад в проект
|
||||||
|
|
||||||
|
Если вы хотите внести свой вклад:
|
||||||
|
|
||||||
|
1. Форкните репозиторий
|
||||||
|
2. Создайте ветку для своей функции (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Зафиксируйте изменения (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Запушьте ветку (`git push origin feature/amazing-feature`)
|
||||||
|
5. Откройте Pull Request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**Сделано с ❤️ для обучения современным веб-технологиям**
|
||||||
|
|
||||||
|
</div>
|
||||||
132
START_AFTER_FIX.md
Normal file
132
START_AFTER_FIX.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Запуск проекта после исправления ошибок
|
||||||
|
|
||||||
|
## Исправленные проблемы:
|
||||||
|
|
||||||
|
1. **Отсутствие email-validator** - добавлен в `backend/requirements.txt`
|
||||||
|
2. **Неправильный healthcheck PostgreSQL** - изменен на проверку конкретной базы данных
|
||||||
|
3. **Ошибка инициализации базы данных** - добавлены команды CREATE TABLE в `init.sql`
|
||||||
|
|
||||||
|
## Инструкция по запуску:
|
||||||
|
|
||||||
|
### Шаг 1: Убедитесь, что Docker запущен
|
||||||
|
```bash
|
||||||
|
docker info
|
||||||
|
```
|
||||||
|
|
||||||
|
Если Docker не запущен, запустите Docker Desktop или Docker daemon.
|
||||||
|
|
||||||
|
### Шаг 2: Остановите текущие контейнеры и удалите volumes
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Внимание:** Эта команда удалит все данные базы данных! Если нужно сохранить данные, пропустите `-v`.
|
||||||
|
|
||||||
|
### Шаг 3: Пересоберите и запустите проект
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 4: Проверьте работу
|
||||||
|
1. **Frontend:** http://localhost
|
||||||
|
2. **API документация:** http://localhost:8000/docs
|
||||||
|
3. **Тестовый пользователь:** test@example.com / Test1234
|
||||||
|
|
||||||
|
## Проверка состояния базы данных
|
||||||
|
|
||||||
|
Если возникают проблемы с базой данных, проверьте:
|
||||||
|
|
||||||
|
### 1. Логи PostgreSQL:
|
||||||
|
```bash
|
||||||
|
docker-compose logs postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Ищите сообщения:
|
||||||
|
- `database system is ready to accept connections`
|
||||||
|
- `Database initialized successfully`
|
||||||
|
|
||||||
|
### 2. Подключитесь к базе данных:
|
||||||
|
```bash
|
||||||
|
docker-compose exec postgres psql -U auth_user -d auth_learning -c "\dt"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый вывод (4 таблицы):
|
||||||
|
```
|
||||||
|
List of relations
|
||||||
|
Schema | Name | Type | Owner
|
||||||
|
--------+-----------------+-------+----------
|
||||||
|
public | courses | table | auth_user
|
||||||
|
public | favorite_courses| table | auth_user
|
||||||
|
public | progress | table | auth_user
|
||||||
|
public | users | table | auth_user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Проверьте тестовые данные:
|
||||||
|
```bash
|
||||||
|
docker-compose exec postgres psql -U auth_user -d auth_learning -c "SELECT COUNT(*) FROM users;"
|
||||||
|
docker-compose exec postgres psql -U auth_user -d auth_learning -c "SELECT COUNT(*) FROM courses;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый вывод:
|
||||||
|
```
|
||||||
|
count
|
||||||
|
-------
|
||||||
|
1
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
count
|
||||||
|
-------
|
||||||
|
10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Решение возможных проблем
|
||||||
|
|
||||||
|
### Проблема: "Port already in use"
|
||||||
|
```bash
|
||||||
|
# Найдите процесс, использующий порт
|
||||||
|
netstat -ano | findstr :80 # Frontend
|
||||||
|
netstat -ano | findstr :8000 # Backend
|
||||||
|
netstat -ano | findstr :5432 # PostgreSQL
|
||||||
|
|
||||||
|
# Остановите процесс или измените порты в docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: "Connection refused"
|
||||||
|
Убедитесь, что все сервисы запущены:
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Все сервисы должны быть в состоянии "Up".
|
||||||
|
|
||||||
|
### Проблема: "404 Not Found" на фронтенде
|
||||||
|
Подождите 1-2 минуты, пока соберется фронтенд. Проверьте логи:
|
||||||
|
```bash
|
||||||
|
docker-compose logs frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логи приложения
|
||||||
|
```bash
|
||||||
|
# Все сервисы
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Конкретный сервис
|
||||||
|
docker-compose logs -f backend
|
||||||
|
docker-compose logs -f frontend
|
||||||
|
docker-compose logs -f postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## Остановка приложения
|
||||||
|
```bash
|
||||||
|
# Без удаления данных
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# С удалением данных БД
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительная информация
|
||||||
|
- Полная документация: [README.md](README.md)
|
||||||
|
- Быстрый старт: [QUICKSTART.md](QUICKSTART.md)
|
||||||
|
- Исправление email-validator: [FIX_INSTRUCTIONS.md](FIX_INSTRUCTIONS.md)
|
||||||
654
arch-node-recommendations.md
Normal file
654
arch-node-recommendations.md
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
# Рекомендации по миграции бэкенда на Node.js (Express + TypeORM)
|
||||||
|
|
||||||
|
## Обзор текущей архитектуры
|
||||||
|
|
||||||
|
### Текущий стек технологий
|
||||||
|
- **Backend**: FastAPI 0.104.1 (Python 3.11)
|
||||||
|
- **База данных**: PostgreSQL 15 + SQLAlchemy 2.0 + Alembic
|
||||||
|
- **Аутентификация**: JWT (access/refresh токены) + bcrypt
|
||||||
|
- **Frontend**: Vanilla JavaScript SPA с динамической загрузкой модулей
|
||||||
|
- **Инфраструктура**: Docker Compose, Nginx, Alpine Linux
|
||||||
|
- **Документация API**: Автоматическая через FastAPI (Swagger/ReDoc)
|
||||||
|
|
||||||
|
### Структура бэкенда
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py # Точка входа, настройка FastAPI
|
||||||
|
│ ├── auth.py # JWT аутентификация, хеширование паролей
|
||||||
|
│ ├── database.py # Подключение к БД, сессии SQLAlchemy
|
||||||
|
│ ├── models.py # SQLAlchemy ORM модели (4 сущности)
|
||||||
|
│ ├── schemas.py # Pydantic схемы валидации (11 классов)
|
||||||
|
│ ├── crud.py # CRUD операции (135 строк бизнес-логики)
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ └── cors.py # CORS настройки
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── auth.py # Аутентификация (регистрация, вход, выход, me)
|
||||||
|
│ ├── courses.py # Курсы (список, детали)
|
||||||
|
│ ├── favorites.py # Избранные курсы (получение, добавление, удаление)
|
||||||
|
│ ├── progress.py # Прогресс обучения (CRUD)
|
||||||
|
│ └── stats.py # Статистика системы
|
||||||
|
├── alembic/ # Миграции базы данных
|
||||||
|
├── init.sql # Инициализация БД с тестовыми данными
|
||||||
|
├── requirements.txt # Python зависимости (13 пакетов)
|
||||||
|
└── Dockerfile # Docker образ Python 3.11
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые сущности данных
|
||||||
|
1. **User** (`users`): пользователи системы (email, хеш пароля, активность)
|
||||||
|
2. **Course** (`courses`): обучающие курсы (название, описание, длительность, уровень)
|
||||||
|
3. **FavoriteCourse** (`favorite_courses`): связь многие-ко-многим пользователей и курсов (избранное)
|
||||||
|
4. **Progress** (`progress`): прогресс обучения по курсам (пройдено уроков, всего уроков)
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
| Метод | Путь | Описание | Аутентификация |
|
||||||
|
|-------|------|----------|----------------|
|
||||||
|
| POST | `/api/auth/register` | Регистрация пользователя | Нет |
|
||||||
|
| POST | `/api/auth/login` | Вход, получение JWT токенов | Нет |
|
||||||
|
| POST | `/api/auth/logout` | Выход (символический) | Да |
|
||||||
|
| GET | `/api/auth/me` | Информация о текущем пользователе | Да |
|
||||||
|
| GET | `/api/courses` | Список курсов с фильтрацией по уровню | Нет |
|
||||||
|
| GET | `/api/courses/{id}` | Детали курса | Нет |
|
||||||
|
| GET | `/api/favorites` | Избранные курсы пользователя | Да |
|
||||||
|
| POST | `/api/favorites` | Добавить курс в избранное | Да |
|
||||||
|
| DELETE | `/api/favorites/{id}` | Удалить курс из избранного | Да |
|
||||||
|
| GET | `/api/progress` | Прогресс по всем курсам пользователя | Да |
|
||||||
|
| GET | `/api/progress/{id}` | Прогресс по конкретному курсу | Да |
|
||||||
|
| POST | `/api/progress/{id}` | Обновить прогресс по курсу | Да |
|
||||||
|
| DELETE | `/api/progress/{id}` | Удалить прогресс по курсу | Да |
|
||||||
|
| GET | `/api/stats` | Статистика системы (пользователи, курсы) | Нет |
|
||||||
|
|
||||||
|
### Аутентификация и авторизация
|
||||||
|
- **JWT токены**: access token (30 мин) + refresh token (7 дней)
|
||||||
|
- **Хранение токенов**: фронтенд сохраняет access token в localStorage
|
||||||
|
- **Передача токенов**: заголовок `Authorization: Bearer <token>`
|
||||||
|
- **Хеширование паролей**: bcrypt через библиотеку passlib
|
||||||
|
- **Защита эндпоинтов**: зависимость `get_current_user` в FastAPI
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
- **Docker Compose**: 3 сервиса (postgres, backend, frontend)
|
||||||
|
- **Сеть**: изолированная сеть `auth_network`
|
||||||
|
- **База данных**: персистентный volume `postgres_data`
|
||||||
|
- **Проксирование**: Nginx раздает статику и проксирует API запросы к бэкенду
|
||||||
|
- **Hot reload**: для разработки через `uvicorn --reload`
|
||||||
|
|
||||||
|
## Анализ для миграции на Node.js
|
||||||
|
|
||||||
|
### Преимущества текущей архитектуры
|
||||||
|
1. **Чистая слоистая архитектура**: модели, схемы, CRUD, маршруты
|
||||||
|
2. **Автоматическая валидация**: Pydantic схемы с детальными правилами
|
||||||
|
3. **Автоматическая документация**: Swagger UI через FastAPI
|
||||||
|
4. **Асинхронная обработка**: поддержка async/await в FastAPI
|
||||||
|
5. **Типизация**: Python type hints + Pydantic
|
||||||
|
|
||||||
|
### Сложности миграции
|
||||||
|
1. **Потеря автоматической документации** (Swagger) - потребуется Swagger UI для Express
|
||||||
|
2. **Потеря автоматической валидации** - необходимо реализовать валидацию вручную
|
||||||
|
3. **Различия в ORM**: SQLAlchemy vs TypeORM (разные подходы к отношениям, миграциям)
|
||||||
|
4. **Асинхронность**: FastAPI использует настоящую асинхронность, Express - callback/promise
|
||||||
|
5. **Система зависимостей**: FastAPI Dependency Injection vs Express middleware
|
||||||
|
|
||||||
|
### Совместимость с фронтендом
|
||||||
|
- **API интерфейс должен остаться неизменным** для минимизации изменений фронтенда
|
||||||
|
- **Структура ответов**: все эндпоинты возвращают `SuccessResponse` или массивы
|
||||||
|
- **Коды ошибок**: HTTP статусы и структура `ErrorResponse` должны сохраниться
|
||||||
|
- **Аутентификация**: тот же механизм JWT с access token в localStorage
|
||||||
|
|
||||||
|
## Рекомендуемый стек Node.js
|
||||||
|
|
||||||
|
### Основные технологии
|
||||||
|
| Компонент | Технология | Обоснование |
|
||||||
|
|-----------|------------|-------------|
|
||||||
|
| **Фреймворк** | Express 4.x | Минималистичный, близкий к текущей архитектуре, большое сообщество |
|
||||||
|
| **ORM** | TypeORM 0.3.x | TypeScript-ориентированный, поддержка PostgreSQL, миграции, ActiveRecord/DataMapper паттерны |
|
||||||
|
| **Валидация** | class-validator + class-transformer | Аналог Pydantic, декларативная валидация через декораторы |
|
||||||
|
| **Аутентификация** | jsonwebtoken + bcrypt | Стандартные библиотеки для JWT и хеширования паролей |
|
||||||
|
| **Конфигурация** | dotenv + config | Управление переменными окружения в разных средах |
|
||||||
|
| **Документация API** | swagger-jsdoc + swagger-ui-express | Автоматическая генерация Swagger документации |
|
||||||
|
| **Миграции** | TypeORM migrations | Встроенная система миграций TypeORM |
|
||||||
|
| **Логирование** | winston + morgan | Структурированное логирование + HTTP логгирование |
|
||||||
|
| **Тестирование** | Jest + Supertest | Unit и интеграционные тесты |
|
||||||
|
|
||||||
|
### Альтернативы
|
||||||
|
- **NestJS**: более структурированный, но более сложный для миграции
|
||||||
|
- **Prisma**: современный ORM, но менее зрелый для сложных отношений
|
||||||
|
- **Fastify**: более производительный, но менее распространенный
|
||||||
|
|
||||||
|
## Пошаговый план миграции
|
||||||
|
|
||||||
|
### Этап 1: Подготовка и настройка окружения
|
||||||
|
1. **Создание нового бэкенда на Node.js** в отдельной директории (например, `backend-node`)
|
||||||
|
2. **Инициализация проекта**: `npm init`, установка зависимостей
|
||||||
|
3. **Настройка TypeScript**: tsconfig, структура проекта
|
||||||
|
4. **Конфигурация Docker**: создание Dockerfile для Node.js
|
||||||
|
5. **Обновление docker-compose.yml**: замена сервиса backend
|
||||||
|
|
||||||
|
### Этап 2: Настройка базы данных и TypeORM
|
||||||
|
1. **Определение сущностей TypeORM**: преобразование SQLAlchemy моделей в TypeORM Entity
|
||||||
|
2. **Настройка подключения к БД**: использование того же PostgreSQL
|
||||||
|
3. **Создание миграций**: перенос init.sql в миграции TypeORM
|
||||||
|
4. **Тестирование подключения**: проверка работы с существующей БД
|
||||||
|
|
||||||
|
### Этап 3: Реализация базовой инфраструктуры
|
||||||
|
1. **Настройка Express**: middleware (CORS, парсинг JSON, логирование)
|
||||||
|
2. **Реализация структуры ответов**: SuccessResponse, ErrorResponse
|
||||||
|
3. **Настройка Swagger документации**: описание всех эндпоинтов
|
||||||
|
4. **Создание системы конфигурации**: переменные окружения
|
||||||
|
|
||||||
|
### Этап 4: Реализация аутентификации
|
||||||
|
1. **Создание JWT сервиса**: генерация, верификация токенов
|
||||||
|
2. **Реализация хеширования паролей**: bcrypt
|
||||||
|
3. **Создание middleware аутентификации**: аналог `get_current_user`
|
||||||
|
4. **Тестирование аутентификации**: регистрация, вход, защита эндпоинтов
|
||||||
|
|
||||||
|
### Этап 5: Реализация бизнес-логики
|
||||||
|
1. **Репозитории/сервисы**: перенос CRUD операций из `crud.py`
|
||||||
|
2. **Контроллеры**: реализация всех эндпоинтов из routes
|
||||||
|
3. **Валидация запросов**: class-validator для DTO
|
||||||
|
4. **Обработка ошибок**: централизованный обработчик ошибок
|
||||||
|
|
||||||
|
### Этап 6: Интеграция и тестирование
|
||||||
|
1. **Поэтапная замена эндпоинтов**: можно запустить оба бэкенда параллельно
|
||||||
|
2. **Тестирование фронтенда**: проверка всех сценариев
|
||||||
|
3. **Нагрузочное тестирование**: сравнение производительности
|
||||||
|
4. **Миграция данных**: перенос существующих данных (если требуется)
|
||||||
|
|
||||||
|
### Этап 7: Деплой и мониторинг
|
||||||
|
1. **Обновление Docker Compose**: замена образа бэкенда
|
||||||
|
2. **Настройка мониторинга**: логи, метрики, health checks
|
||||||
|
3. **Резервное копирование**: стратегия бэкапов PostgreSQL
|
||||||
|
4. **Документация**: обновление ARCHITECTURE.md
|
||||||
|
|
||||||
|
## Структура проекта Node.js
|
||||||
|
|
||||||
|
```
|
||||||
|
backend-node/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Точка входа
|
||||||
|
│ ├── app.ts # Настройка Express
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── index.ts # Конфигурация приложения
|
||||||
|
│ │ ├── database.ts # Конфигурация БД
|
||||||
|
│ │ └── jwt.ts # Конфигурация JWT
|
||||||
|
│ ├── entities/ # TypeORM сущности
|
||||||
|
│ │ ├── User.ts
|
||||||
|
│ │ ├── Course.ts
|
||||||
|
│ │ ├── FavoriteCourse.ts
|
||||||
|
│ │ └── Progress.ts
|
||||||
|
│ ├── migrations/ # Миграции TypeORM
|
||||||
|
│ │ ├── 0001-initial-schema.ts
|
||||||
|
│ │ └── 0002-seed-data.ts
|
||||||
|
│ ├── middlewares/
|
||||||
|
│ │ ├── auth.middleware.ts # Аутентификация
|
||||||
|
│ │ ├── error.middleware.ts # Обработка ошибок
|
||||||
|
│ │ ├── validation.middleware.ts # Валидация DTO
|
||||||
|
│ │ └── cors.middleware.ts # CORS
|
||||||
|
│ ├── controllers/ # Контроллеры (маршруты)
|
||||||
|
│ │ ├── auth.controller.ts
|
||||||
|
│ │ ├── courses.controller.ts
|
||||||
|
│ │ ├── favorites.controller.ts
|
||||||
|
│ │ ├── progress.controller.ts
|
||||||
|
│ │ └── stats.controller.ts
|
||||||
|
│ ├── services/ # Бизнес-логика
|
||||||
|
│ │ ├── auth.service.ts
|
||||||
|
│ │ ├── courses.service.ts
|
||||||
|
│ │ ├── favorites.service.ts
|
||||||
|
│ │ ├── progress.service.ts
|
||||||
|
│ │ └── stats.service.ts
|
||||||
|
│ ├── repositories/ # Работа с БД (опционально)
|
||||||
|
│ │ ├── user.repository.ts
|
||||||
|
│ │ ├── course.repository.ts
|
||||||
|
│ │ └── base.repository.ts
|
||||||
|
│ ├── dtos/ # Data Transfer Objects
|
||||||
|
│ │ ├── auth.dto.ts
|
||||||
|
│ │ ├── courses.dto.ts
|
||||||
|
│ │ ├── favorites.dto.ts
|
||||||
|
│ │ ├── progress.dto.ts
|
||||||
|
│ │ └── common.dto.ts
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── api-response.ts # SuccessResponse, ErrorResponse
|
||||||
|
│ │ ├── logger.ts # Winston логгер
|
||||||
|
│ │ └── helpers.ts # Вспомогательные функции
|
||||||
|
│ └── types/ # TypeScript типы
|
||||||
|
│ └── index.ts
|
||||||
|
├── tests/ # Тесты
|
||||||
|
│ ├── unit/
|
||||||
|
│ └── integration/
|
||||||
|
├── docker/ # Docker конфигурация
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── .env.example # Пример переменных окружения
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── ormconfig.ts # Конфигурация TypeORM
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры кода
|
||||||
|
|
||||||
|
### Сущность User (TypeORM)
|
||||||
|
```typescript
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm';
|
||||||
|
import { FavoriteCourse } from './FavoriteCourse';
|
||||||
|
import { Progress } from './Progress';
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, unique: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
hashedPassword: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@OneToMany(() => FavoriteCourse, favorite => favorite.user)
|
||||||
|
favoriteCourses: FavoriteCourse[];
|
||||||
|
|
||||||
|
@OneToMany(() => Progress, progress => progress.user)
|
||||||
|
progress: Progress[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DTO с валидацией (class-validator)
|
||||||
|
```typescript
|
||||||
|
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class UserCreateDto {
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(100)
|
||||||
|
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter, one lowercase letter and one number'
|
||||||
|
})
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserLoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Контроллер аутентификации
|
||||||
|
```typescript
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { UserCreateDto, UserLoginDto } from '../dtos/auth.dto';
|
||||||
|
import { successResponse, errorResponse } from '../utils/api-response';
|
||||||
|
import { validate } from '../middlewares/validation.middleware';
|
||||||
|
|
||||||
|
export class AuthController {
|
||||||
|
private authService: AuthService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.authService = new AuthService();
|
||||||
|
}
|
||||||
|
|
||||||
|
register = [
|
||||||
|
validate(UserCreateDto),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const userData: UserCreateDto = req.body;
|
||||||
|
await this.authService.register(userData);
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
message: 'User registered successfully',
|
||||||
|
data: { email: userData.email }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
login = [
|
||||||
|
validate(UserLoginDto),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const credentials: UserLoginDto = req.body;
|
||||||
|
const result = await this.authService.login(credentials);
|
||||||
|
|
||||||
|
return successResponse(res, {
|
||||||
|
message: 'Login successful',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
getMe = [
|
||||||
|
// authMiddleware применяется в маршрутах
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = (req as any).user; // После authMiddleware
|
||||||
|
return successResponse(res, {
|
||||||
|
data: {
|
||||||
|
email: user.email,
|
||||||
|
id: user.id,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
isActive: user.isActive
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сервис аутентификации
|
||||||
|
```typescript
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../config/database';
|
||||||
|
import { User } from '../entities/User';
|
||||||
|
import { UserCreateDto, UserLoginDto } from '../dtos/auth.dto';
|
||||||
|
import { JwtService } from './jwt.service';
|
||||||
|
import { PasswordService } from './password.service';
|
||||||
|
import { BadRequestError, UnauthorizedError } from '../utils/errors';
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
private userRepository: Repository<User>;
|
||||||
|
private jwtService: JwtService;
|
||||||
|
private passwordService: PasswordService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.userRepository = AppDataSource.getRepository(User);
|
||||||
|
this.jwtService = new JwtService();
|
||||||
|
this.passwordService = new PasswordService();
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(userData: UserCreateDto): Promise<void> {
|
||||||
|
// Проверка существования пользователя
|
||||||
|
const existingUser = await this.userRepository.findOne({
|
||||||
|
where: { email: userData.email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new BadRequestError('Email already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хеширование пароля
|
||||||
|
const hashedPassword = await this.passwordService.hashPassword(userData.password);
|
||||||
|
|
||||||
|
// Создание пользователя
|
||||||
|
const user = this.userRepository.create({
|
||||||
|
email: userData.email,
|
||||||
|
hashedPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(credentials: UserLoginDto): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||||
|
// Поиск пользователя
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { email: credentials.email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedError('Incorrect email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка пароля
|
||||||
|
const isValidPassword = await this.passwordService.verifyPassword(
|
||||||
|
credentials.password,
|
||||||
|
user.hashedPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
throw new UnauthorizedError('Incorrect email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация токенов
|
||||||
|
const accessToken = this.jwtService.generateAccessToken(user.email);
|
||||||
|
const refreshToken = this.jwtService.generateRefreshToken(user.email);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
email: user.email,
|
||||||
|
id: user.id,
|
||||||
|
createdAt: user.createdAt
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Миграция данных
|
||||||
|
|
||||||
|
### Стратегии миграции
|
||||||
|
1. **Постепенная миграция**: запуск Node.js бэкенда параллельно с Python, переключение по эндпоинтам
|
||||||
|
2. **Полная замена**: остановка Python бэкенда, запуск Node.js с переносом данных
|
||||||
|
3. **Канареечное развертывание**: направление части трафика на Node.js бэкенд
|
||||||
|
|
||||||
|
### Перенос существующих данных
|
||||||
|
1. **Схема БД остается той же**, только ORM меняется
|
||||||
|
2. **Данные пользователей**: пароли захешированы bcrypt - совместимы
|
||||||
|
3. **Курсы и связи**: прямокопирование таблиц
|
||||||
|
4. **Миграционный скрипт**: можно написать на TypeScript с использованием TypeORM
|
||||||
|
|
||||||
|
### Пример миграционного скрипта
|
||||||
|
```typescript
|
||||||
|
// migrate-data.ts
|
||||||
|
import { createConnection } from 'typeorm';
|
||||||
|
import { User, Course, FavoriteCourse, Progress } from './src/entities';
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
// Подключение к существующей БД
|
||||||
|
const connection = await createConnection({
|
||||||
|
type: 'postgres',
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5432,
|
||||||
|
username: 'auth_user',
|
||||||
|
password: 'auth_password',
|
||||||
|
database: 'auth_learning',
|
||||||
|
entities: [User, Course, FavoriteCourse, Progress],
|
||||||
|
synchronize: false // Не создавать таблицы автоматически
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка данных
|
||||||
|
const userCount = await connection.manager.count(User);
|
||||||
|
console.log(`Users in database: ${userCount}`);
|
||||||
|
|
||||||
|
// Данные уже существуют, можно закрыть соединение
|
||||||
|
await connection.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Инфраструктура
|
||||||
|
|
||||||
|
### Dockerfile для Node.js
|
||||||
|
```dockerfile
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Копирование исходного кода
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Сборка TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Создание непривилегированного пользователя
|
||||||
|
RUN addgroup -g 1000 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1000 -G nodejs
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Обновленный docker-compose.yml
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# Остается без изменений
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend-node
|
||||||
|
container_name: auth_learning_backend_node
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-auth_user}:${POSTGRES_PASSWORD:-auth_password}@postgres:5432/${POSTGRES_DB:-auth_learning}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-change-in-production}
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- auth_network
|
||||||
|
# Для разработки с hot-reload:
|
||||||
|
# command: npm run dev
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
# Остается без изменений, но нужно обновить nginx.conf для проксирования на порт 3000
|
||||||
|
|
||||||
|
networks:
|
||||||
|
auth_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Обновление nginx.conf
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:3000; # Изменен порт с 8000 на 3000
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### Стратегия тестирования
|
||||||
|
1. **Юнит-тесты**: сервисы, утилиты (Jest)
|
||||||
|
2. **Интеграционные тесты**: API эндпоинты (Supertest)
|
||||||
|
3. **E2E тесты**: полный поток с фронтендом (Cypress/Playwright)
|
||||||
|
4. **Нагрузочное тестирование**: сравнение производительности с FastAPI
|
||||||
|
|
||||||
|
### Пример теста API
|
||||||
|
```typescript
|
||||||
|
import request from 'supertest';
|
||||||
|
import { app } from '../src/app';
|
||||||
|
|
||||||
|
describe('Auth API', () => {
|
||||||
|
describe('POST /api/auth/register', () => {
|
||||||
|
it('should register a new user', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/auth/register')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'Test1234'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.data.email).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Риски и их минимизация
|
||||||
|
|
||||||
|
### Технические риски
|
||||||
|
| Риск | Вероятность | Влияние | Стратегия минимизации |
|
||||||
|
|------|-------------|---------|----------------------|
|
||||||
|
| **Несовместимость API** | Низкая | Высокое | Точное копирование структур ответов, тестирование каждого эндпоинта |
|
||||||
|
| **Производительность** | Средняя | Среднее | Нагрузочное тестирование, оптимизация запросов к БД |
|
||||||
|
| **Потеря данных** | Низкая | Критическое | Резервное копирование БД перед миграцией, поэтапное переключение |
|
||||||
|
| **Ошибки аутентификации** | Средняя | Высокое | Тщательное тестирование JWT, проверка совместимости bcrypt хешей |
|
||||||
|
|
||||||
|
### Организационные риски
|
||||||
|
1. **Время миграции**: оценка 2-4 недели для одного разработчика
|
||||||
|
2. **Обучение команды**: если команда не знает TypeScript/Node.js
|
||||||
|
3. **Поддержка двух кодовых баз**: в период параллельной работы
|
||||||
|
|
||||||
|
## Преимущества перехода на Node.js
|
||||||
|
|
||||||
|
### Технические преимущества
|
||||||
|
1. **Единый язык**: JavaScript/TypeScript на фронтенде и бэкенде
|
||||||
|
2. **Большая экосистема**: npm с огромным количеством пакетов
|
||||||
|
3. **Высокая производительность**: Node.js хорошо справляется с I/O операциями
|
||||||
|
4. **Меньшее потребление памяти**: по сравнению с Python + FastAPI
|
||||||
|
|
||||||
|
### Бизнес-преимущества
|
||||||
|
1. **Упрощение найма**: больше разработчиков знают JavaScript чем Python
|
||||||
|
2. **Более быстрое развитие**: возможность переиспользования кода между фронтендом и бэкендом
|
||||||
|
3. **Лучшая масштабируемость**: Node.js лучше подходит для микросервисной архитектуры
|
||||||
|
4. **Снижение затрат на инфраструктуру**: более эффективное использование ресурсов
|
||||||
|
|
||||||
|
## Рекомендации по внедрению
|
||||||
|
|
||||||
|
### Приоритетность задач
|
||||||
|
1. **Высокий приоритет**:
|
||||||
|
- Настройка базовой инфраструктуры (Express, TypeORM, Docker)
|
||||||
|
- Реализация аутентификации (JWT, bcrypt)
|
||||||
|
- Миграция основных сущности (User, Course)
|
||||||
|
|
||||||
|
2. **Средний приоритет**:
|
||||||
|
- Реализация оставшихся эндпоинтов
|
||||||
|
- Настройка Swagger документации
|
||||||
|
- Интеграционное тестирование
|
||||||
|
|
||||||
|
3. **Низкий приоритет**:
|
||||||
|
- Оптимизация производительности
|
||||||
|
- Расширенные функции (пагинация, сортировка)
|
||||||
|
- Мониторинг и логирование
|
||||||
|
|
||||||
|
### Контрольные точки
|
||||||
|
1. **Неделя 1**: Базовая инфраструктура, подключение к БД, простые CRUD операции
|
||||||
|
2. **Неделя 2**: Аутентификация, защита эндпоинтов, основные маршруты
|
||||||
|
3. **Неделя 3**: Тестирование, интеграция с фронтендом, документация
|
||||||
|
4. **Неделя 4**: Постепенное переключение, мониторинг, оптимизация
|
||||||
|
|
||||||
|
### Критерии успеха
|
||||||
|
1. **Все существующие API эндпоинты работают** без изменений фронтенда
|
||||||
|
2. **Производительность не хуже** FastAPI версии
|
||||||
|
3. **100% покрытие тестами** критической функциональности
|
||||||
|
4. **Полная документация** API и архитектуры
|
||||||
|
|
||||||
|
## Заключение
|
||||||
|
|
||||||
|
Миграция бэкенда с FastAPI на Express + TypeORM является технически выполнимой задачей с четкими преимуществами для долгосрочного развития проекта. Рекомендуемый подход - поэтапная миграция с сохранением обратной совместимости API.
|
||||||
|
|
||||||
|
Ключевые факторы успеха:
|
||||||
|
1. **Сохранение API интерфейса** для минимизации изменений фронтенда
|
||||||
|
2. **Тщательное тестирование** каждого эндпоинта
|
||||||
|
3. **Поэтапное развертывание** с возможностью отката
|
||||||
|
4. **Документирование** всех изменений и решений
|
||||||
|
|
||||||
|
Предложенный стек (Express + TypeORM) обеспечивает хороший баланс между производительностью, поддерживаемостью и скоростью разработки, делая его подходящим выбором для данного проекта.
|
||||||
28
backend/Dockerfile
Normal file
28
backend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
postgresql-client \
|
||||||
|
python3-dev \
|
||||||
|
libffi-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY ./app ./app
|
||||||
|
COPY alembic.ini .
|
||||||
|
COPY ./alembic ./alembic
|
||||||
|
|
||||||
|
# Create uploads directory and non-root user
|
||||||
|
RUN mkdir -p /app/uploads && useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
41
backend/alembic.ini
Normal file
41
backend/alembic.ini
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
sqlalchemy.url = postgresql://auth_user:auth_password@postgres:5432/auth_learning
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
63
backend/alembic/env.py
Normal file
63
backend/alembic/env.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from alembic import context
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add app directory to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
# Import models for autogenerate
|
||||||
|
from app.database import Base
|
||||||
|
from app import models
|
||||||
|
|
||||||
|
# Alembic Config object
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Override SQLAlchemy URL from environment variable
|
||||||
|
database_url = os.getenv('DATABASE_URL')
|
||||||
|
if database_url:
|
||||||
|
config.set_main_option('sqlalchemy.url', database_url)
|
||||||
|
|
||||||
|
# Set target_metadata for autogenerate support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode."""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
179
backend/alembic/versions/001_initial_migration.py
Normal file
179
backend/alembic/versions/001_initial_migration.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""initial migration
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-02-11 00:00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '001'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create users table
|
||||||
|
op.create_table(
|
||||||
|
'users',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default=sa.true(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('email', name='uq_users_email')
|
||||||
|
)
|
||||||
|
op.create_index('idx_users_email', 'users', ['email'], unique=True)
|
||||||
|
|
||||||
|
# Create courses table
|
||||||
|
op.create_table(
|
||||||
|
'courses',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
|
sa.Column('level', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('duration', sa.Integer(), server_default=sa.text('0'), nullable=False),
|
||||||
|
sa.Column('author_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('is_published', sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
op.create_index('idx_courses_author_id', 'courses', ['author_id'])
|
||||||
|
op.create_index('idx_courses_is_published', 'courses', ['is_published'])
|
||||||
|
|
||||||
|
# Create course_modules table
|
||||||
|
op.create_table(
|
||||||
|
'course_modules',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('course_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('duration', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('order_index', sa.Integer(), server_default=sa.text('0'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
op.create_index('idx_course_modules_course_id', 'course_modules', ['course_id'])
|
||||||
|
|
||||||
|
# Create lessons table
|
||||||
|
op.create_table(
|
||||||
|
'lessons',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('module_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('order_index', sa.Integer(), server_default=sa.text('0'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['module_id'], ['course_modules.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
op.create_index('idx_lessons_module_id', 'lessons', ['module_id'])
|
||||||
|
|
||||||
|
# Create lesson_steps table
|
||||||
|
op.create_table(
|
||||||
|
'lesson_steps',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('lesson_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('step_type', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('content', sa.Text(), nullable=True),
|
||||||
|
sa.Column('order_index', sa.Integer(), server_default=sa.text('0'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['lesson_id'], ['lessons.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
op.create_index('idx_lesson_steps_lesson_id', 'lesson_steps', ['lesson_id'])
|
||||||
|
|
||||||
|
# Create favorite_courses table
|
||||||
|
op.create_table(
|
||||||
|
'favorite_courses',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('course_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('added_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'course_id'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create progress table
|
||||||
|
op.create_table(
|
||||||
|
'progress',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('course_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('completed_lessons', sa.Integer(), server_default=sa.text('0'), nullable=False),
|
||||||
|
sa.Column('total_lessons', sa.Integer(), server_default=sa.text('0'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'course_id'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert seed data
|
||||||
|
# Test user (password: Test1234)
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO users (email, hashed_password, is_active)
|
||||||
|
VALUES ('test@example.com', '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', true)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Sample courses
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO courses (title, description, level, duration, author_id, is_published)
|
||||||
|
VALUES
|
||||||
|
('Python Fundamentals', 'Learn the basics of Python programming language including syntax, data types, and control structures.', 'beginner', 20,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true),
|
||||||
|
('Web Development with FastAPI', 'Build modern web applications using FastAPI framework with Python.', 'intermediate', 30,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true),
|
||||||
|
('Docker for Developers', 'Master containerization with Docker and Docker Compose for application deployment.', 'intermediate', 25,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true),
|
||||||
|
('Advanced Algorithms', 'Deep dive into complex algorithms and data structures for technical interviews.', 'advanced', 40,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true),
|
||||||
|
('JavaScript Essentials', 'Learn JavaScript from scratch including ES6+ features and DOM manipulation.', 'beginner', 25,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true),
|
||||||
|
('Database Design', 'Learn relational database design principles and SQL optimization techniques.', 'intermediate', 35,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true),
|
||||||
|
('Machine Learning Basics', 'Introduction to machine learning concepts and practical implementations.', 'advanced', 45,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true),
|
||||||
|
('DevOps Practices', 'Learn continuous integration, deployment, and infrastructure as code.', 'intermediate', 30,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true),
|
||||||
|
('React Frontend Development', 'Build modern user interfaces with React library and hooks.', 'intermediate', 35,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true),
|
||||||
|
('System Architecture', 'Design scalable and maintainable software system architectures.', 'advanced', 50,
|
||||||
|
(SELECT id FROM users WHERE email = 'test@example.com'), true)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Delete seed data first
|
||||||
|
op.execute("DELETE FROM favorite_courses")
|
||||||
|
op.execute("DELETE FROM progress")
|
||||||
|
op.execute("DELETE FROM lesson_steps")
|
||||||
|
op.execute("DELETE FROM lessons")
|
||||||
|
op.execute("DELETE FROM course_modules")
|
||||||
|
op.execute("DELETE FROM courses WHERE author_id = (SELECT id FROM users WHERE email = 'test@example.com')")
|
||||||
|
op.execute("DELETE FROM users WHERE email = 'test@example.com'")
|
||||||
|
|
||||||
|
# Drop tables
|
||||||
|
op.drop_table('progress')
|
||||||
|
op.drop_table('favorite_courses')
|
||||||
|
op.drop_index('idx_lesson_steps_lesson_id', table_name='lesson_steps')
|
||||||
|
op.drop_table('lesson_steps')
|
||||||
|
op.drop_index('idx_lessons_module_id', table_name='lessons')
|
||||||
|
op.drop_table('lessons')
|
||||||
|
op.drop_index('idx_course_modules_course_id', table_name='course_modules')
|
||||||
|
op.drop_table('course_modules')
|
||||||
|
op.drop_index('idx_courses_is_published', table_name='courses')
|
||||||
|
op.drop_index('idx_courses_author_id', table_name='courses')
|
||||||
|
op.drop_table('courses')
|
||||||
|
op.drop_index('idx_users_email', table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
39
backend/alembic/versions/002_add_course_drafts.py
Normal file
39
backend/alembic/versions/002_add_course_drafts.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""add_course_drafts
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2024-02-11 12:00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '002'
|
||||||
|
down_revision = '001'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create course_drafts table
|
||||||
|
op.create_table(
|
||||||
|
'course_drafts',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
|
sa.Column('level', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('structure', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
op.create_index('idx_course_drafts_user_id', 'course_drafts', ['user_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('idx_course_drafts_user_id', table_name='course_drafts')
|
||||||
|
op.drop_table('course_drafts')
|
||||||
24
backend/alembic/versions/003_add_content_to_courses.py
Normal file
24
backend/alembic/versions/003_add_content_to_courses.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""add_content_to_courses
|
||||||
|
|
||||||
|
Revision ID: 003_add_content_to_courses
|
||||||
|
Revises: 002
|
||||||
|
Create Date: 2025-02-13 09:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '003_add_content_to_courses'
|
||||||
|
down_revision = '002'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add content column to courses table
|
||||||
|
op.add_column('courses', sa.Column('content', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Remove content column from courses table
|
||||||
|
op.drop_column('courses', 'content')
|
||||||
38
backend/alembic/versions/004_add_status_to_courses.py
Normal file
38
backend/alembic/versions/004_add_status_to_courses.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""add_status_to_courses
|
||||||
|
|
||||||
|
Revision ID: 004_add_status_to_courses
|
||||||
|
Revises: 003_add_content_to_courses
|
||||||
|
Create Date: 2026-02-13 11:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '004_add_status_to_courses'
|
||||||
|
down_revision = '003_add_content_to_courses'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add status column
|
||||||
|
op.add_column('courses', sa.Column('status', sa.String(20), nullable=False, server_default='draft'))
|
||||||
|
|
||||||
|
# Migrate existing data: if is_published is True → 'published', else → 'draft'
|
||||||
|
op.execute("UPDATE courses SET status = 'published' WHERE is_published IS TRUE")
|
||||||
|
op.execute("UPDATE courses SET status = 'draft' WHERE is_published IS NOT TRUE")
|
||||||
|
|
||||||
|
# Remove old is_published column
|
||||||
|
op.drop_column('courses', 'is_published')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Add back is_published column
|
||||||
|
op.add_column('courses', sa.Column('is_published', sa.Boolean(), nullable=False, server_default='FALSE'))
|
||||||
|
|
||||||
|
# Migrate data back
|
||||||
|
op.execute("UPDATE courses SET is_published = TRUE WHERE status = 'published'")
|
||||||
|
op.execute("UPDATE courses SET is_published = FALSE WHERE status != 'published'")
|
||||||
|
|
||||||
|
# Remove status column
|
||||||
|
op.drop_column('courses', 'status')
|
||||||
22
backend/alembic/versions/005_add_is_developer_to_users.py
Normal file
22
backend/alembic/versions/005_add_is_developer_to_users.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""add_is_developer_to_users
|
||||||
|
|
||||||
|
Revision ID: 005_add_is_developer_to_users
|
||||||
|
Revises: 004_add_status_to_courses
|
||||||
|
Create Date: 2026-02-13 15:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '005_add_is_developer_to_users'
|
||||||
|
down_revision = '004_add_status_to_courses'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('users', sa.Column('is_developer', sa.Boolean(), nullable=False, server_default='FALSE'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('users', 'is_developer')
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""add content_html to lesson_steps
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005_add_is_developer_to_users
|
||||||
|
Create Date: 2024-02-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '006'
|
||||||
|
down_revision = '005_add_is_developer_to_users'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('lesson_steps', sa.Column('content_html', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('lesson_steps', 'content_html')
|
||||||
26
backend/alembic/versions/007_add_file_url_to_lesson_steps.py
Normal file
26
backend/alembic/versions/007_add_file_url_to_lesson_steps.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""add file_url to lesson_steps and update step_type
|
||||||
|
|
||||||
|
Revision ID: 007
|
||||||
|
Revises: 006_add_content_html_to_lesson_steps
|
||||||
|
Create Date: 2024-02-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '007'
|
||||||
|
down_revision = '006'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('lesson_steps', sa.Column('file_url', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE lesson_steps DROP CONSTRAINT IF EXISTS lesson_steps_step_type_check")
|
||||||
|
op.execute("ALTER TABLE lesson_steps ADD CONSTRAINT lesson_steps_step_type_check CHECK (step_type IN ('theory', 'practice', 'lab', 'test', 'presentation'))")
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('lesson_steps', 'file_url')
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE lesson_steps DROP CONSTRAINT IF EXISTS lesson_steps_step_type_check")
|
||||||
|
op.execute("ALTER TABLE lesson_steps ADD CONSTRAINT lesson_steps_step_type_check CHECK (step_type IN ('theory', 'practice', 'lab', 'test'))")
|
||||||
101
backend/alembic/versions/008_add_test_system.py
Normal file
101
backend/alembic/versions/008_add_test_system.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""add test system
|
||||||
|
|
||||||
|
Revision ID: 008_add_test_system
|
||||||
|
Revises: 007_add_file_url_to_lesson_steps
|
||||||
|
Create Date: 2026-03-06
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '008'
|
||||||
|
down_revision = '007'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add test_settings column to lesson_steps
|
||||||
|
op.add_column('lesson_steps', sa.Column('test_settings', sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
# Create test_questions table
|
||||||
|
op.create_table(
|
||||||
|
'test_questions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('step_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('question_text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('question_type', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('points', sa.Integer(), nullable=True, server_default='1'),
|
||||||
|
sa.Column('order_index', sa.Integer(), nullable=True, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['step_id'], ['lesson_steps.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_test_questions_id'), 'test_questions', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create test_answers table
|
||||||
|
op.create_table(
|
||||||
|
'test_answers',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('question_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('answer_text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('is_correct', sa.Boolean(), nullable=True, server_default='false'),
|
||||||
|
sa.Column('order_index', sa.Integer(), nullable=True, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['question_id'], ['test_questions.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_test_answers_id'), 'test_answers', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create test_match_items table
|
||||||
|
op.create_table(
|
||||||
|
'test_match_items',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('question_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('left_text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('right_text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('order_index', sa.Integer(), nullable=True, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['question_id'], ['test_questions.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_test_match_items_id'), 'test_match_items', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create test_attempts table
|
||||||
|
op.create_table(
|
||||||
|
'test_attempts',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('step_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('score', sa.Numeric(5, 2), nullable=True),
|
||||||
|
sa.Column('max_score', sa.Numeric(5, 2), nullable=True),
|
||||||
|
sa.Column('passed', sa.Boolean(), nullable=True, server_default='false'),
|
||||||
|
sa.Column('answers', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['step_id'], ['lesson_steps.id'], ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_test_attempts_id'), 'test_attempts', ['id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Drop tables in reverse order
|
||||||
|
op.drop_index(op.f('ix_test_attempts_id'), table_name='test_attempts')
|
||||||
|
op.drop_table('test_attempts')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_test_match_items_id'), table_name='test_match_items')
|
||||||
|
op.drop_table('test_match_items')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_test_answers_id'), table_name='test_answers')
|
||||||
|
op.drop_table('test_answers')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_test_questions_id'), table_name='test_questions')
|
||||||
|
op.drop_table('test_questions')
|
||||||
|
|
||||||
|
# Remove test_settings column from lesson_steps
|
||||||
|
op.drop_column('lesson_steps', 'test_settings')
|
||||||
4
backend/app/__init__.py
Normal file
4
backend/app/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .database import Base, engine, get_db
|
||||||
|
from . import models, schemas, crud, auth
|
||||||
|
|
||||||
|
__all__ = ["Base", "engine", "get_db", "models", "schemas", "crud", "auth"]
|
||||||
112
backend/app/auth.py
Normal file
112
backend/app/auth.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import os
|
||||||
|
from .schemas import TokenData, UserResponse
|
||||||
|
from .database import get_db
|
||||||
|
from . import crud
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||||
|
|
||||||
|
# Warning for default secret key
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
if SECRET_KEY == "your-secret-key-change-in-production":
|
||||||
|
logger.warning(
|
||||||
|
"WARNING: Using default SECRET_KEY! "
|
||||||
|
"Please provide a secure secret key through environment variables."
|
||||||
|
)
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
def create_refresh_token(data: dict):
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
to_encode.update({"exp": expire, "type": "refresh"})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
def create_tokens(email: str):
|
||||||
|
access_token = create_access_token(data={"sub": email})
|
||||||
|
refresh_token = create_refresh_token(data={"sub": email})
|
||||||
|
return access_token, refresh_token
|
||||||
|
|
||||||
|
def verify_token(token: str) -> Optional[TokenData]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
email: str = payload.get("sub")
|
||||||
|
token_type: str = payload.get("type")
|
||||||
|
|
||||||
|
if email is None or token_type != "access":
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TokenData(email=email)
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_current_token(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||||
|
) -> Optional[TokenData]:
|
||||||
|
if not credentials:
|
||||||
|
return None
|
||||||
|
return verify_token(credentials.credentials)
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
token: Optional[TokenData] = Depends(get_current_token),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = crud.get_user_by_email(db, email=token.email)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert SQLAlchemy model to Pydantic model
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
|
def get_optional_current_user(
|
||||||
|
token: Optional[TokenData] = Depends(get_current_token),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Returns current user if authenticated, otherwise returns None"""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = crud.get_user_by_email(db, email=token.email)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
771
backend/app/crud.py
Normal file
771
backend/app/crud.py
Normal file
@@ -0,0 +1,771 @@
|
|||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from sqlalchemy import func, desc
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from . import models, schemas, auth
|
||||||
|
import markdown
|
||||||
|
from markdown.extensions.tables import TableExtension
|
||||||
|
from markdown.extensions.fenced_code import FencedCodeExtension
|
||||||
|
from markdown.extensions.toc import TocExtension
|
||||||
|
|
||||||
|
def render_markdown_to_html(md_content: str) -> str:
|
||||||
|
if not md_content:
|
||||||
|
return ""
|
||||||
|
extensions = [
|
||||||
|
TableExtension(),
|
||||||
|
FencedCodeExtension(),
|
||||||
|
TocExtension(),
|
||||||
|
'nl2br',
|
||||||
|
'sane_lists',
|
||||||
|
'smarty'
|
||||||
|
]
|
||||||
|
return markdown.markdown(md_content, extensions=extensions)
|
||||||
|
|
||||||
|
# User operations
|
||||||
|
def get_user_by_email(db: Session, email: str):
|
||||||
|
return db.query(models.User).filter(models.User.email == email).first()
|
||||||
|
|
||||||
|
def create_user(db: Session, user: schemas.UserCreate):
|
||||||
|
hashed_password = auth.get_password_hash(user.password)
|
||||||
|
db_user = models.User(
|
||||||
|
email=user.email,
|
||||||
|
hashed_password=hashed_password
|
||||||
|
)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
def authenticate_user(db: Session, email: str, password: str):
|
||||||
|
user = get_user_by_email(db, email)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not auth.verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Course operations
|
||||||
|
def get_courses(db: Session, skip: int = 0, limit: int = 100, level: Optional[str] = None, author_id: Optional[int] = None, published_only: bool = True):
|
||||||
|
query = db.query(models.Course)
|
||||||
|
|
||||||
|
if level:
|
||||||
|
query = query.filter(models.Course.level == level)
|
||||||
|
|
||||||
|
if author_id:
|
||||||
|
query = query.filter(models.Course.author_id == author_id)
|
||||||
|
|
||||||
|
if published_only:
|
||||||
|
query = query.filter(models.Course.status == 'published')
|
||||||
|
|
||||||
|
return query.order_by(models.Course.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
def get_course_by_id(db: Session, course_id: int, include_structure: bool = False):
|
||||||
|
course = db.query(models.Course).filter(models.Course.id == course_id).first()
|
||||||
|
|
||||||
|
if course and include_structure:
|
||||||
|
# Eager load all related data
|
||||||
|
course.modules # This will trigger lazy loading
|
||||||
|
for module in course.modules:
|
||||||
|
module.lessons
|
||||||
|
for lesson in module.lessons:
|
||||||
|
lesson.steps
|
||||||
|
|
||||||
|
return course
|
||||||
|
|
||||||
|
def create_course(db: Session, course: schemas.CourseCreate, author_id: int):
|
||||||
|
db_course = models.Course(
|
||||||
|
title=course.title,
|
||||||
|
description=course.description,
|
||||||
|
level=course.level,
|
||||||
|
duration=course.duration,
|
||||||
|
author_id=author_id
|
||||||
|
)
|
||||||
|
db.add(db_course)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_course)
|
||||||
|
return db_course
|
||||||
|
|
||||||
|
def update_course(db: Session, course_id: int, course_update: schemas.CourseUpdate):
|
||||||
|
db_course = get_course_by_id(db, course_id)
|
||||||
|
if not db_course:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = course_update.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_course, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_course)
|
||||||
|
return db_course
|
||||||
|
|
||||||
|
def delete_course(db: Session, course_id: int):
|
||||||
|
db_course = get_course_by_id(db, course_id)
|
||||||
|
if not db_course:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(db_course)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def create_course_with_content(db: Session, course_data: schemas.CourseCreateRequest, author_id: int):
|
||||||
|
"""
|
||||||
|
Create a course with content (markdown) and structure (modules, lessons, steps)
|
||||||
|
Always creates draft course with real structure in DB
|
||||||
|
"""
|
||||||
|
# Create basic course with status='draft'
|
||||||
|
db_course = models.Course(
|
||||||
|
title=course_data.title,
|
||||||
|
description=course_data.description,
|
||||||
|
level=course_data.level,
|
||||||
|
duration=course_data.duration,
|
||||||
|
content=course_data.content,
|
||||||
|
author_id=author_id,
|
||||||
|
status='draft',
|
||||||
|
)
|
||||||
|
db.add(db_course)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_course)
|
||||||
|
|
||||||
|
# Extract structure from course_data if provided
|
||||||
|
structure = course_data.structure or {}
|
||||||
|
modules_data = structure.get('modules', [])
|
||||||
|
|
||||||
|
# If no structure provided, create default module, lesson and step with content
|
||||||
|
if not modules_data:
|
||||||
|
# Create default module
|
||||||
|
default_module = models.CourseModule(
|
||||||
|
course_id=db_course.id,
|
||||||
|
title="Введение",
|
||||||
|
description="Основное содержание курса",
|
||||||
|
order_index=1
|
||||||
|
)
|
||||||
|
db.add(default_module)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(default_module)
|
||||||
|
|
||||||
|
# Create default lesson
|
||||||
|
lesson = models.Lesson(
|
||||||
|
module_id=default_module.id,
|
||||||
|
title="Общее описание",
|
||||||
|
order_index=1
|
||||||
|
)
|
||||||
|
db.add(lesson)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(lesson)
|
||||||
|
|
||||||
|
# Create step with content
|
||||||
|
step = models.LessonStep(
|
||||||
|
lesson_id=lesson.id,
|
||||||
|
step_type="theory",
|
||||||
|
title=course_data.title,
|
||||||
|
content=course_data.content,
|
||||||
|
order_index=1
|
||||||
|
)
|
||||||
|
db.add(step)
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
# Create modules, lessons and steps from structure
|
||||||
|
for module_index, module_data in enumerate(modules_data):
|
||||||
|
# Create module
|
||||||
|
module = models.CourseModule(
|
||||||
|
course_id=db_course.id,
|
||||||
|
title=module_data.get('title', f"Модуль {module_index + 1}"),
|
||||||
|
description=module_data.get('description', ''),
|
||||||
|
duration=module_data.get('duration', ''),
|
||||||
|
order_index=module_index + 1
|
||||||
|
)
|
||||||
|
db.add(module)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(module)
|
||||||
|
|
||||||
|
# Create lessons in module
|
||||||
|
lessons_data = module_data.get('lessons', [])
|
||||||
|
for lesson_index, lesson_data in enumerate(lessons_data):
|
||||||
|
lesson = models.Lesson(
|
||||||
|
module_id=module.id,
|
||||||
|
title=lesson_data.get('title', f"Урок {lesson_index + 1}"),
|
||||||
|
description=lesson_data.get('description', ''),
|
||||||
|
order_index=lesson_index + 1
|
||||||
|
)
|
||||||
|
db.add(lesson)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(lesson)
|
||||||
|
|
||||||
|
# Create steps in lesson
|
||||||
|
steps_data = lesson_data.get('steps', [])
|
||||||
|
for step_index, step_data in enumerate(steps_data):
|
||||||
|
step = models.LessonStep(
|
||||||
|
lesson_id=lesson.id,
|
||||||
|
step_type=step_data.get('step_type', 'theory'),
|
||||||
|
title=step_data.get('title', f"Шаг {step_index + 1}"),
|
||||||
|
content=step_data.get('content', ''),
|
||||||
|
order_index=step_index + 1
|
||||||
|
)
|
||||||
|
db.add(step)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return db_course
|
||||||
|
|
||||||
|
# Module operations
|
||||||
|
def get_module_by_id(db: Session, module_id: int):
|
||||||
|
return db.query(models.CourseModule).filter(models.CourseModule.id == module_id).first()
|
||||||
|
|
||||||
|
def get_course_modules(db: Session, course_id: int):
|
||||||
|
return db.query(models.CourseModule).filter(
|
||||||
|
models.CourseModule.course_id == course_id
|
||||||
|
).order_by(models.CourseModule.order_index).all()
|
||||||
|
|
||||||
|
def create_module(db: Session, module: schemas.ModuleCreate):
|
||||||
|
db_module = models.CourseModule(
|
||||||
|
course_id=module.course_id,
|
||||||
|
title=module.title,
|
||||||
|
description=module.description,
|
||||||
|
duration=module.duration,
|
||||||
|
order_index=module.order_index
|
||||||
|
)
|
||||||
|
db.add(db_module)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_module)
|
||||||
|
return db_module
|
||||||
|
|
||||||
|
def update_module(db: Session, module_id: int, module_update: schemas.ModuleUpdate):
|
||||||
|
db_module = get_module_by_id(db, module_id)
|
||||||
|
if not db_module:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = module_update.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_module, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_module)
|
||||||
|
return db_module
|
||||||
|
|
||||||
|
def delete_module(db: Session, module_id: int):
|
||||||
|
db_module = get_module_by_id(db, module_id)
|
||||||
|
if not db_module:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(db_module)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Lesson operations
|
||||||
|
def get_lesson_by_id(db: Session, lesson_id: int):
|
||||||
|
return db.query(models.Lesson).filter(models.Lesson.id == lesson_id).first()
|
||||||
|
|
||||||
|
def get_module_lessons(db: Session, module_id: int):
|
||||||
|
return db.query(models.Lesson).filter(
|
||||||
|
models.Lesson.module_id == module_id
|
||||||
|
).order_by(models.Lesson.order_index).all()
|
||||||
|
|
||||||
|
def create_lesson(db: Session, lesson: schemas.LessonCreate):
|
||||||
|
db_lesson = models.Lesson(
|
||||||
|
module_id=lesson.module_id,
|
||||||
|
title=lesson.title,
|
||||||
|
description=lesson.description,
|
||||||
|
order_index=lesson.order_index
|
||||||
|
)
|
||||||
|
db.add(db_lesson)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_lesson)
|
||||||
|
return db_lesson
|
||||||
|
|
||||||
|
def update_lesson(db: Session, lesson_id: int, lesson_update: schemas.LessonUpdate):
|
||||||
|
db_lesson = get_lesson_by_id(db, lesson_id)
|
||||||
|
if not db_lesson:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = lesson_update.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_lesson, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_lesson)
|
||||||
|
return db_lesson
|
||||||
|
|
||||||
|
def delete_lesson(db: Session, lesson_id: int):
|
||||||
|
db_lesson = get_lesson_by_id(db, lesson_id)
|
||||||
|
if not db_lesson:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(db_lesson)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Step operations
|
||||||
|
def get_step_by_id(db: Session, step_id: int):
|
||||||
|
return db.query(models.LessonStep).filter(models.LessonStep.id == step_id).first()
|
||||||
|
|
||||||
|
def get_lesson_steps(db: Session, lesson_id: int):
|
||||||
|
return db.query(models.LessonStep).filter(
|
||||||
|
models.LessonStep.lesson_id == lesson_id
|
||||||
|
).order_by(models.LessonStep.order_index).all()
|
||||||
|
|
||||||
|
def create_step(db: Session, step: schemas.StepCreate):
|
||||||
|
content_html = render_markdown_to_html(step.content) if step.content else None
|
||||||
|
db_step = models.LessonStep(
|
||||||
|
lesson_id=step.lesson_id,
|
||||||
|
step_type=step.step_type,
|
||||||
|
title=step.title,
|
||||||
|
content=step.content,
|
||||||
|
content_html=content_html,
|
||||||
|
file_url=step.file_url,
|
||||||
|
order_index=step.order_index
|
||||||
|
)
|
||||||
|
db.add(db_step)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_step)
|
||||||
|
return db_step
|
||||||
|
|
||||||
|
def update_step(db: Session, step_id: int, step_update: schemas.StepUpdate):
|
||||||
|
db_step = get_step_by_id(db, step_id)
|
||||||
|
if not db_step:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = step_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if 'content' in update_data:
|
||||||
|
update_data['content_html'] = render_markdown_to_html(update_data['content'])
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_step, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_step)
|
||||||
|
return db_step
|
||||||
|
|
||||||
|
def delete_step(db: Session, step_id: int):
|
||||||
|
db_step = get_step_by_id(db, step_id)
|
||||||
|
if not db_step:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(db_step)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Stats operations
|
||||||
|
def get_stats(db: Session):
|
||||||
|
total_users = db.query(func.count(models.User.id)).scalar()
|
||||||
|
total_courses = db.query(func.count(models.Course.id)).scalar()
|
||||||
|
|
||||||
|
# For now, return the latest course as most popular
|
||||||
|
# In a real app, you would track course enrollments
|
||||||
|
most_popular = db.query(models.Course).order_by(desc(models.Course.created_at)).first()
|
||||||
|
most_popular_title = most_popular.title if most_popular else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users or 0,
|
||||||
|
"total_courses": total_courses or 0,
|
||||||
|
"most_popular_course": most_popular_title
|
||||||
|
}
|
||||||
|
|
||||||
|
# Favorite courses operations
|
||||||
|
def get_favorite_courses(db: Session, user_id: int):
|
||||||
|
return db.query(models.FavoriteCourse).filter(models.FavoriteCourse.user_id == user_id).all()
|
||||||
|
|
||||||
|
def get_favorite_course(db: Session, user_id: int, course_id: int):
|
||||||
|
return db.query(models.FavoriteCourse).filter(
|
||||||
|
models.FavoriteCourse.user_id == user_id,
|
||||||
|
models.FavoriteCourse.course_id == course_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def add_favorite_course(db: Session, user_id: int, course_id: int):
|
||||||
|
# Check if already favorited
|
||||||
|
existing = get_favorite_course(db, user_id, course_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
db_favorite = models.FavoriteCourse(user_id=user_id, course_id=course_id)
|
||||||
|
db.add(db_favorite)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_favorite)
|
||||||
|
return db_favorite
|
||||||
|
|
||||||
|
def remove_favorite_course(db: Session, user_id: int, course_id: int):
|
||||||
|
favorite = get_favorite_course(db, user_id, course_id)
|
||||||
|
if not favorite:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(favorite)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Progress operations
|
||||||
|
def get_progress(db: Session, user_id: int, course_id: int):
|
||||||
|
return db.query(models.Progress).filter(
|
||||||
|
models.Progress.user_id == user_id,
|
||||||
|
models.Progress.course_id == course_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_user_progress(db: Session, user_id: int):
|
||||||
|
return db.query(models.Progress).filter(models.Progress.user_id == user_id).all()
|
||||||
|
|
||||||
|
def create_or_update_progress(db: Session, user_id: int, course_id: int, completed_lessons: int, total_lessons: int):
|
||||||
|
progress = get_progress(db, user_id, course_id)
|
||||||
|
if progress:
|
||||||
|
progress.completed_lessons = completed_lessons
|
||||||
|
progress.total_lessons = total_lessons
|
||||||
|
else:
|
||||||
|
progress = models.Progress(
|
||||||
|
user_id=user_id,
|
||||||
|
course_id=course_id,
|
||||||
|
completed_lessons=completed_lessons,
|
||||||
|
total_lessons=total_lessons
|
||||||
|
)
|
||||||
|
db.add(progress)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(progress)
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def delete_progress(db: Session, user_id: int, course_id: int):
|
||||||
|
progress = get_progress(db, user_id, course_id)
|
||||||
|
if not progress:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(progress)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Course draft operations
|
||||||
|
def get_course_drafts(db: Session, user_id: int):
|
||||||
|
return db.query(models.CourseDraft).filter(
|
||||||
|
models.CourseDraft.user_id == user_id
|
||||||
|
).order_by(models.CourseDraft.updated_at.desc()).all()
|
||||||
|
|
||||||
|
def get_course_draft(db: Session, draft_id: int):
|
||||||
|
return db.query(models.CourseDraft).filter(models.CourseDraft.id == draft_id).first()
|
||||||
|
|
||||||
|
def save_course_draft(db: Session, draft: schemas.CourseDraftCreate, user_id: int):
|
||||||
|
# Check if draft exists
|
||||||
|
existing = db.query(models.CourseDraft).filter(
|
||||||
|
models.CourseDraft.user_id == user_id,
|
||||||
|
models.CourseDraft.title == draft.title
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing draft
|
||||||
|
existing.description = draft.description
|
||||||
|
existing.level = draft.level
|
||||||
|
existing.content = draft.content
|
||||||
|
existing.structure = draft.structure or existing.structure
|
||||||
|
existing.updated_at = func.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
else:
|
||||||
|
# Create new draft
|
||||||
|
db_draft = models.CourseDraft(
|
||||||
|
user_id=user_id,
|
||||||
|
title=draft.title,
|
||||||
|
description=draft.description,
|
||||||
|
level=draft.level,
|
||||||
|
content=draft.content,
|
||||||
|
structure=draft.structure
|
||||||
|
)
|
||||||
|
db.add(db_draft)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_draft)
|
||||||
|
return db_draft
|
||||||
|
|
||||||
|
def delete_course_draft(db: Session, draft_id: int, user_id: int):
|
||||||
|
draft = db.query(models.CourseDraft).filter(
|
||||||
|
models.CourseDraft.id == draft_id,
|
||||||
|
models.CourseDraft.user_id == user_id
|
||||||
|
).first()
|
||||||
|
if not draft:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(draft)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Draft-specific queries using Course.status='draft'
|
||||||
|
def get_user_drafts(db: Session, user_id: int):
|
||||||
|
"""Get all draft courses for a user (status='draft')"""
|
||||||
|
return db.query(models.Course).filter(
|
||||||
|
models.Course.author_id == user_id,
|
||||||
|
models.Course.status == 'draft'
|
||||||
|
).order_by(models.Course.updated_at.desc()).all()
|
||||||
|
|
||||||
|
def get_user_draft(db: Session, user_id: int, draft_id: int, include_structure: bool = False):
|
||||||
|
"""Get a specific draft for a user"""
|
||||||
|
query = db.query(models.Course).filter(
|
||||||
|
models.Course.id == draft_id,
|
||||||
|
models.Course.author_id == user_id,
|
||||||
|
models.Course.status == 'draft'
|
||||||
|
)
|
||||||
|
|
||||||
|
if include_structure:
|
||||||
|
# Предзагрузка связей для полной структуры
|
||||||
|
query = query.options(
|
||||||
|
joinedload(models.Course.modules).joinedload(models.CourseModule.lessons).joinedload(models.Lesson.steps)
|
||||||
|
)
|
||||||
|
|
||||||
|
return query.first()
|
||||||
|
|
||||||
|
def publish_course(db: Session, course_id: int, user_id: int):
|
||||||
|
"""Publish a draft by changing status to 'published'"""
|
||||||
|
draft = get_user_draft(db, user_id, course_id)
|
||||||
|
if not draft:
|
||||||
|
return None
|
||||||
|
|
||||||
|
draft.status = 'published'
|
||||||
|
db.commit()
|
||||||
|
db.refresh(draft)
|
||||||
|
return draft
|
||||||
|
|
||||||
|
def delete_user_draft(db: Session, course_id: int, user_id: int):
|
||||||
|
"""Delete a user's draft course"""
|
||||||
|
draft = get_user_draft(db, user_id, course_id)
|
||||||
|
if not draft:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(draft)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Test operations
|
||||||
|
def get_test_questions(db: Session, step_id: int):
|
||||||
|
"""Get all questions for a test step"""
|
||||||
|
return db.query(models.TestQuestion).filter(
|
||||||
|
models.TestQuestion.step_id == step_id
|
||||||
|
).order_by(models.TestQuestion.order_index).all()
|
||||||
|
|
||||||
|
def get_test_question(db: Session, question_id: int):
|
||||||
|
"""Get a specific test question"""
|
||||||
|
return db.query(models.TestQuestion).options(
|
||||||
|
joinedload(models.TestQuestion.answers),
|
||||||
|
joinedload(models.TestQuestion.match_items)
|
||||||
|
).filter(
|
||||||
|
models.TestQuestion.id == question_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def create_test_question(db: Session, step_id: int, question: schemas.TestQuestionCreate):
|
||||||
|
"""Create a new test question with answers/match items"""
|
||||||
|
db_question = models.TestQuestion(
|
||||||
|
step_id=step_id,
|
||||||
|
question_text=question.question_text,
|
||||||
|
question_type=question.question_type,
|
||||||
|
points=question.points,
|
||||||
|
order_index=question.order_index
|
||||||
|
)
|
||||||
|
db.add(db_question)
|
||||||
|
db.flush() # Flush to get the question ID
|
||||||
|
|
||||||
|
# Create answers for choice questions
|
||||||
|
if question.question_type in ['single_choice', 'multiple_choice']:
|
||||||
|
for answer in question.answers:
|
||||||
|
db_answer = models.TestAnswer(
|
||||||
|
question_id=db_question.id,
|
||||||
|
answer_text=answer.answer_text,
|
||||||
|
is_correct=answer.is_correct,
|
||||||
|
order_index=answer.order_index
|
||||||
|
)
|
||||||
|
db.add(db_answer)
|
||||||
|
|
||||||
|
# Create match items for match questions
|
||||||
|
if question.question_type == 'match':
|
||||||
|
for item in question.match_items:
|
||||||
|
db_item = models.TestMatchItem(
|
||||||
|
question_id=db_question.id,
|
||||||
|
left_text=item.left_text,
|
||||||
|
right_text=item.right_text,
|
||||||
|
order_index=item.order_index
|
||||||
|
)
|
||||||
|
db.add(db_item)
|
||||||
|
|
||||||
|
# Store correct answers for text_answer questions in content field
|
||||||
|
if question.question_type == 'text_answer' and question.correct_answers:
|
||||||
|
# Store as JSON in question_text temporarily (we'll need a separate field)
|
||||||
|
# For now, we'll store it in the database and handle it properly
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_question)
|
||||||
|
return db_question
|
||||||
|
|
||||||
|
def update_test_question(db: Session, question_id: int, question_update: schemas.TestQuestionUpdate):
|
||||||
|
"""Update a test question"""
|
||||||
|
db_question = get_test_question(db, question_id)
|
||||||
|
if not db_question:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = question_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Update question fields first
|
||||||
|
if 'question_text' in update_data:
|
||||||
|
db_question.question_text = update_data['question_text']
|
||||||
|
if 'question_type' in update_data:
|
||||||
|
db_question.question_type = update_data['question_type']
|
||||||
|
if 'points' in update_data:
|
||||||
|
db_question.points = update_data['points']
|
||||||
|
if 'order_index' in update_data:
|
||||||
|
db_question.order_index = update_data['order_index']
|
||||||
|
|
||||||
|
# Flush to save question type changes before updating answers
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Update answers if provided
|
||||||
|
if 'answers' in update_data and db_question.question_type in ['single_choice', 'multiple_choice']:
|
||||||
|
# Delete existing answers
|
||||||
|
db.query(models.TestAnswer).filter(
|
||||||
|
models.TestAnswer.question_id == question_id
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
# Create new answers
|
||||||
|
for answer_data in update_data['answers']:
|
||||||
|
db_answer = models.TestAnswer(
|
||||||
|
question_id=question_id,
|
||||||
|
answer_text=answer_data.get('answer_text', '') if isinstance(answer_data, dict) else answer_data.answer_text,
|
||||||
|
is_correct=answer_data.get('is_correct', False) if isinstance(answer_data, dict) else answer_data.is_correct,
|
||||||
|
order_index=answer_data.get('order_index', 0) if isinstance(answer_data, dict) else answer_data.order_index
|
||||||
|
)
|
||||||
|
db.add(db_answer)
|
||||||
|
|
||||||
|
# Update match items if provided
|
||||||
|
if 'match_items' in update_data and db_question.question_type == 'match':
|
||||||
|
# Delete existing match items
|
||||||
|
db.query(models.TestMatchItem).filter(
|
||||||
|
models.TestMatchItem.question_id == question_id
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
# Create new match items
|
||||||
|
for item_data in update_data['match_items']:
|
||||||
|
db_item = models.TestMatchItem(
|
||||||
|
question_id=question_id,
|
||||||
|
left_text=item_data.get('left_text', '') if isinstance(item_data, dict) else item_data.left_text,
|
||||||
|
right_text=item_data.get('right_text', '') if isinstance(item_data, dict) else item_data.right_text,
|
||||||
|
order_index=item_data.get('order_index', 0) if isinstance(item_data, dict) else item_data.order_index
|
||||||
|
)
|
||||||
|
db.add(db_item)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
# Re-query to get fresh data with relationships
|
||||||
|
db_question = get_test_question(db, question_id)
|
||||||
|
return db_question
|
||||||
|
|
||||||
|
def delete_test_question(db: Session, question_id: int):
|
||||||
|
"""Delete a test question"""
|
||||||
|
db_question = get_test_question(db, question_id)
|
||||||
|
if not db_question:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(db_question)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_test_settings(db: Session, step_id: int, settings: schemas.TestSettings):
|
||||||
|
"""Update test settings for a step"""
|
||||||
|
db_step = get_step_by_id(db, step_id)
|
||||||
|
if not db_step:
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_step.test_settings = settings.model_dump()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_step)
|
||||||
|
return db_step
|
||||||
|
|
||||||
|
def create_test_attempt(db: Session, user_id: int, step_id: int, answers: dict):
|
||||||
|
"""Create a test attempt and calculate score"""
|
||||||
|
# Get test settings
|
||||||
|
db_step = get_step_by_id(db, step_id)
|
||||||
|
if not db_step or not db_step.test_settings:
|
||||||
|
return None
|
||||||
|
|
||||||
|
settings = db_step.test_settings
|
||||||
|
|
||||||
|
# Get all questions
|
||||||
|
questions = get_test_questions(db, step_id)
|
||||||
|
if not questions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate score
|
||||||
|
total_points = 0
|
||||||
|
earned_points = 0
|
||||||
|
|
||||||
|
for question in questions:
|
||||||
|
total_points += question.points
|
||||||
|
question_answer = answers.get(str(question.id))
|
||||||
|
|
||||||
|
if not question_answer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if question.question_type == 'single_choice':
|
||||||
|
# Single choice: check if selected answer is correct
|
||||||
|
correct_answer = db.query(models.TestAnswer).filter(
|
||||||
|
models.TestAnswer.question_id == question.id,
|
||||||
|
models.TestAnswer.is_correct == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if correct_answer and question_answer == correct_answer.id:
|
||||||
|
earned_points += question.points
|
||||||
|
|
||||||
|
elif question.question_type == 'multiple_choice':
|
||||||
|
# Multiple choice: check if all correct answers are selected
|
||||||
|
correct_answers = db.query(models.TestAnswer).filter(
|
||||||
|
models.TestAnswer.question_id == question.id,
|
||||||
|
models.TestAnswer.is_correct == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
correct_ids = set([a.id for a in correct_answers])
|
||||||
|
selected_ids = set(question_answer) if isinstance(question_answer, list) else set()
|
||||||
|
|
||||||
|
if correct_ids == selected_ids:
|
||||||
|
earned_points += question.points
|
||||||
|
|
||||||
|
elif question.question_type == 'text_answer':
|
||||||
|
# Text answer: simple keyword matching (case-insensitive)
|
||||||
|
# For better implementation, use fuzzy matching or AI
|
||||||
|
# Store correct answers in question content for now
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif question.question_type == 'match':
|
||||||
|
# Match: check if all pairs are correct
|
||||||
|
match_items = db.query(models.TestMatchItem).filter(
|
||||||
|
models.TestMatchItem.question_id == question.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
all_correct = True
|
||||||
|
for item in match_items:
|
||||||
|
user_match = question_answer.get(str(item.id))
|
||||||
|
if user_match != item.right_text:
|
||||||
|
all_correct = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if all_correct:
|
||||||
|
earned_points += question.points
|
||||||
|
|
||||||
|
# Calculate percentage and determine if passed
|
||||||
|
score_percentage = (earned_points / total_points * 100) if total_points > 0 else 0
|
||||||
|
passed = score_percentage >= settings.get('passing_score', 70)
|
||||||
|
|
||||||
|
# Create attempt record
|
||||||
|
db_attempt = models.TestAttempt(
|
||||||
|
user_id=user_id,
|
||||||
|
step_id=step_id,
|
||||||
|
score=earned_points,
|
||||||
|
max_score=total_points,
|
||||||
|
passed=passed,
|
||||||
|
answers=answers,
|
||||||
|
completed_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(db_attempt)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_attempt)
|
||||||
|
|
||||||
|
return db_attempt
|
||||||
|
|
||||||
|
def get_test_attempts(db: Session, user_id: int, step_id: int):
|
||||||
|
"""Get all test attempts for a user and step"""
|
||||||
|
return db.query(models.TestAttempt).filter(
|
||||||
|
models.TestAttempt.user_id == user_id,
|
||||||
|
models.TestAttempt.step_id == step_id
|
||||||
|
).order_by(models.TestAttempt.started_at.desc()).all()
|
||||||
|
|
||||||
|
def get_test_attempt(db: Session, attempt_id: int):
|
||||||
|
"""Get a specific test attempt"""
|
||||||
|
return db.query(models.TestAttempt).filter(
|
||||||
|
models.TestAttempt.id == attempt_id
|
||||||
|
).first()
|
||||||
18
backend/app/database.py
Normal file
18
backend/app/database.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://auth_user:auth_password@localhost:5432/auth_learning")
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
51
backend/app/main.py
Normal file
51
backend/app/main.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from .database import engine, Base
|
||||||
|
from .routes import auth_router, courses_router, stats_router, favorites_router, progress_router, course_builder_router, admin_router, learn_router
|
||||||
|
from .middleware.cors import setup_cors
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title="Auth Learning API",
|
||||||
|
description="API for authentication and course management system",
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup CORS
|
||||||
|
setup_cors(app)
|
||||||
|
|
||||||
|
# Serve static files (uploads)
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|
||||||
|
# Create database tables
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
logger.info("Creating database tables...")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("Database tables created")
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(auth_router, prefix="/api")
|
||||||
|
app.include_router(courses_router, prefix="/api")
|
||||||
|
app.include_router(stats_router, prefix="/api")
|
||||||
|
app.include_router(favorites_router, prefix="/api")
|
||||||
|
app.include_router(progress_router, prefix="/api")
|
||||||
|
app.include_router(course_builder_router, prefix="/api")
|
||||||
|
app.include_router(admin_router, prefix="/api")
|
||||||
|
app.include_router(learn_router, prefix="/api")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return RedirectResponse(url="/docs")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy", "service": "auth-learning-api"}
|
||||||
24
backend/app/middleware/cors.py
Normal file
24
backend/app/middleware/cors.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import os
|
||||||
|
|
||||||
|
def setup_cors(app):
|
||||||
|
origins = [
|
||||||
|
"http://localhost",
|
||||||
|
"http://localhost:80",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://frontend:80",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add environment variable if set
|
||||||
|
allowed_origins = os.getenv("ALLOWED_ORIGINS", "")
|
||||||
|
if allowed_origins:
|
||||||
|
origins.extend([origin.strip() for origin in allowed_origins.split(",")])
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=["*"],
|
||||||
|
)
|
||||||
187
backend/app/models.py
Normal file
187
backend/app/models.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, Numeric, JSON
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_developer = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
favorite_courses = relationship("FavoriteCourse", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
progress = relationship("Progress", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class Course(Base):
|
||||||
|
__tablename__ = "courses"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
description = Column(Text, nullable=False)
|
||||||
|
level = Column(String(50), nullable=False)
|
||||||
|
duration = Column(Integer, default=0, nullable=False)
|
||||||
|
content = Column(Text)
|
||||||
|
author_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
status = Column(String(20), default='draft', nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
author = relationship("User")
|
||||||
|
modules = relationship("CourseModule", back_populates="course", cascade="all, delete-orphan")
|
||||||
|
favorites = relationship("FavoriteCourse", back_populates="course", cascade="all, delete-orphan")
|
||||||
|
progress_records = relationship("Progress", back_populates="course", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class CourseModule(Base):
|
||||||
|
__tablename__ = "course_modules"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
duration = Column(String(100)) # e.g., "2 weeks", "10 hours"
|
||||||
|
order_index = Column(Integer, default=0, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
course = relationship("Course", back_populates="modules")
|
||||||
|
lessons = relationship("Lesson", back_populates="module", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Lesson(Base):
|
||||||
|
__tablename__ = "lessons"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
module_id = Column(Integer, ForeignKey("course_modules.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
order_index = Column(Integer, default=0, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
module = relationship("CourseModule", back_populates="lessons")
|
||||||
|
steps = relationship("LessonStep", back_populates="lesson", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class LessonStep(Base):
|
||||||
|
__tablename__ = "lesson_steps"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
lesson_id = Column(Integer, ForeignKey("lessons.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
step_type = Column(String(50), nullable=False)
|
||||||
|
title = Column(String(255))
|
||||||
|
content = Column(Text)
|
||||||
|
content_html = Column(Text)
|
||||||
|
file_url = Column(String(500))
|
||||||
|
test_settings = Column(JSON) # {passing_score, max_attempts, time_limit, show_answers}
|
||||||
|
order_index = Column(Integer, default=0, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
lesson = relationship("Lesson", back_populates="steps")
|
||||||
|
test_questions = relationship("TestQuestion", back_populates="step", cascade="all, delete-orphan")
|
||||||
|
test_attempts = relationship("TestAttempt", back_populates="step", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteCourse(Base):
|
||||||
|
__tablename__ = "favorite_courses"
|
||||||
|
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
added_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="favorite_courses")
|
||||||
|
course = relationship("Course", back_populates="favorites")
|
||||||
|
|
||||||
|
|
||||||
|
class Progress(Base):
|
||||||
|
__tablename__ = "progress"
|
||||||
|
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
completed_lessons = Column(Integer, default=0, nullable=False)
|
||||||
|
total_lessons = Column(Integer, default=0, nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="progress")
|
||||||
|
course = relationship("Course")
|
||||||
|
|
||||||
|
|
||||||
|
class CourseDraft(Base):
|
||||||
|
__tablename__ = "course_drafts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
level = Column(String(50), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
structure = Column(Text) # JSON строка для структуры модулей
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuestion(Base):
|
||||||
|
__tablename__ = "test_questions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
step_id = Column(Integer, ForeignKey("lesson_steps.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
question_text = Column(Text, nullable=False)
|
||||||
|
question_type = Column(String(50), nullable=False) # single_choice, multiple_choice, text_answer, match
|
||||||
|
points = Column(Integer, default=1)
|
||||||
|
order_index = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
step = relationship("LessonStep", back_populates="test_questions")
|
||||||
|
answers = relationship("TestAnswer", back_populates="question", cascade="all, delete-orphan")
|
||||||
|
match_items = relationship("TestMatchItem", back_populates="question", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnswer(Base):
|
||||||
|
__tablename__ = "test_answers"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
question_id = Column(Integer, ForeignKey("test_questions.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
answer_text = Column(Text, nullable=False)
|
||||||
|
is_correct = Column(Boolean, default=False)
|
||||||
|
order_index = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
question = relationship("TestQuestion", back_populates="answers")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchItem(Base):
|
||||||
|
__tablename__ = "test_match_items"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
question_id = Column(Integer, ForeignKey("test_questions.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
left_text = Column(Text, nullable=False)
|
||||||
|
right_text = Column(Text, nullable=False)
|
||||||
|
order_index = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
question = relationship("TestQuestion", back_populates="match_items")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAttempt(Base):
|
||||||
|
__tablename__ = "test_attempts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
step_id = Column(Integer, ForeignKey("lesson_steps.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
score = Column(Numeric(5, 2))
|
||||||
|
max_score = Column(Numeric(5, 2))
|
||||||
|
passed = Column(Boolean, default=False)
|
||||||
|
answers = Column(JSON) # {question_id: answer_id или [answer_ids] или text или {left_id: right_id}}
|
||||||
|
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
completed_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
step = relationship("LessonStep", back_populates="test_attempts")
|
||||||
10
backend/app/routes/__init__.py
Normal file
10
backend/app/routes/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from .auth import router as auth_router
|
||||||
|
from .courses import router as courses_router
|
||||||
|
from .stats import router as stats_router
|
||||||
|
from .favorites import router as favorites_router
|
||||||
|
from .progress import router as progress_router
|
||||||
|
from .course_builder import router as course_builder_router
|
||||||
|
from .admin import router as admin_router
|
||||||
|
from .learn import router as learn_router
|
||||||
|
|
||||||
|
__all__ = ["auth_router", "courses_router", "stats_router", "favorites_router", "progress_router", "course_builder_router", "admin_router", "learn_router"]
|
||||||
104
backend/app/routes/admin.py
Normal file
104
backend/app/routes/admin.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Dict
|
||||||
|
from .. import crud, schemas
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import User
|
||||||
|
import os
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
# Проверка админ-секрета через переменную окружения
|
||||||
|
def verify_admin_secret(admin_secret: str) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка admin-секрета
|
||||||
|
Разрешенные источники:
|
||||||
|
1. DEVELOPER_SECRET из переменной окружения
|
||||||
|
2. Можно добавить дополнительные проверки (IP, токен и т.д.)
|
||||||
|
"""
|
||||||
|
valid_secret = os.getenv("DEVELOPER_SECRET")
|
||||||
|
if not valid_secret:
|
||||||
|
raise HTTPException(status_code=500, detail="DEVELOPER_SECRET not configured")
|
||||||
|
|
||||||
|
return admin_secret == valid_secret
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/make-developer")
|
||||||
|
def make_user_developer(
|
||||||
|
user_id: int,
|
||||||
|
admin_secret: str = Body(..., embed=True),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Назначить статус разработчика для пользователя
|
||||||
|
Требует admin_secret в запросе
|
||||||
|
"""
|
||||||
|
if not verify_admin_secret(admin_secret):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid admin secret")
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
user.is_developer = True
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Developer status enabled for user {user.email}",
|
||||||
|
"user_id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"is_developer": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/remove-developer")
|
||||||
|
def remove_user_developer(
|
||||||
|
user_id: int,
|
||||||
|
admin_secret: str = Body(..., embed=True),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Убрать статус разработчика у пользователя
|
||||||
|
Требует admin_secret в запросе
|
||||||
|
"""
|
||||||
|
if not verify_admin_secret(admin_secret):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid admin secret")
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
user.is_developer = False
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Developer status removed for user {user.email}",
|
||||||
|
"user_id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"is_developer": False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
def list_users(
|
||||||
|
admin_secret: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
if not verify_admin_secret(admin_secret):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid admin secret")
|
||||||
|
|
||||||
|
users = db.query(User).all()
|
||||||
|
return {
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"is_developer": user.is_developer,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"created_at": user.created_at.isoformat() if user.created_at else None
|
||||||
|
}
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
|
}
|
||||||
105
backend/app/routes/auth.py
Normal file
105
backend/app/routes/auth.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Dict
|
||||||
|
from .. import crud, schemas, auth
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
@router.post("/register", response_model=schemas.SuccessResponse)
|
||||||
|
def register(user: schemas.UserCreate, db: Session = Depends(get_db)):
|
||||||
|
# Check if user already exists
|
||||||
|
db_user = crud.get_user_by_email(db, email=user.email)
|
||||||
|
if db_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
crud.create_user(db=db, user=user)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
message="User registered successfully",
|
||||||
|
data={"email": user.email}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/login", response_model=schemas.SuccessResponse)
|
||||||
|
def login(credentials: schemas.UserLogin, db: Session = Depends(get_db)):
|
||||||
|
# Authenticate user
|
||||||
|
user = crud.authenticate_user(db, credentials.email, credentials.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect email or password"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tokens
|
||||||
|
access_token, refresh_token = auth.create_tokens(user.email)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
message="Login successful",
|
||||||
|
data={
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": {
|
||||||
|
"email": user.email,
|
||||||
|
"id": user.id,
|
||||||
|
"created_at": user.created_at.isoformat() if user.created_at else None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=schemas.SuccessResponse)
|
||||||
|
def logout():
|
||||||
|
# In a real app, you might want to blacklist the token
|
||||||
|
return schemas.SuccessResponse(message="Logout successful")
|
||||||
|
|
||||||
|
@router.get("/me", response_model=schemas.SuccessResponse)
|
||||||
|
def get_current_user(
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={
|
||||||
|
"email": current_user.email,
|
||||||
|
"id": current_user.id,
|
||||||
|
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
|
||||||
|
"is_active": current_user.is_active,
|
||||||
|
"is_developer": current_user.is_developer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/me/developer-status", response_model=schemas.SuccessResponse)
|
||||||
|
def update_developer_status(
|
||||||
|
is_developer: bool = Body(..., embed=True),
|
||||||
|
current_user: User = Depends(auth.get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update developer status for current user
|
||||||
|
"""
|
||||||
|
current_user.is_developer = is_developer
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
message=f"Developer status updated to: {is_developer}",
|
||||||
|
data={"is_developer": is_developer}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=schemas.Token)
|
||||||
|
def refresh_token(
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Refresh access token using refresh token
|
||||||
|
"""
|
||||||
|
# Create new access token for the current user
|
||||||
|
access_token, refresh_token = auth.create_tokens(current_user.email)
|
||||||
|
|
||||||
|
return schemas.Token(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
token_type="bearer"
|
||||||
|
)
|
||||||
646
backend/app/routes/course_builder.py
Normal file
646
backend/app/routes/course_builder.py
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from .. import crud, schemas, auth
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import User, CourseDraft
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/course-builder", tags=["course-builder"])
|
||||||
|
|
||||||
|
# Configure upload directory - same as in main.py StaticFiles
|
||||||
|
UPLOAD_DIR = Path("uploads")
|
||||||
|
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
@router.get("/steps/{step_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def get_step_content(
|
||||||
|
step_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get step content by ID
|
||||||
|
"""
|
||||||
|
step = crud.get_step_by_id(db, step_id=step_id)
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
# Check if user has access to the course
|
||||||
|
# TODO: Implement proper access check
|
||||||
|
# For now, allow access if user is authenticated
|
||||||
|
|
||||||
|
step_response = schemas.StepResponse.from_orm(step)
|
||||||
|
return schemas.SuccessResponse(data={"step": step_response})
|
||||||
|
|
||||||
|
@router.put("/steps/{step_id}/content", response_model=schemas.SuccessResponse)
|
||||||
|
def update_step_content(
|
||||||
|
step_id: int,
|
||||||
|
content_update: schemas.StepUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update step content (markdown)
|
||||||
|
"""
|
||||||
|
step = crud.get_step_by_id(db, step_id=step_id)
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
# Check if user is the author of the course
|
||||||
|
# TODO: Implement proper authorization
|
||||||
|
# For now, allow if user is authenticated
|
||||||
|
|
||||||
|
updated_step = crud.update_step(db, step_id=step_id, step_update=content_update)
|
||||||
|
if not updated_step:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update step")
|
||||||
|
|
||||||
|
step_response = schemas.StepResponse.from_orm(updated_step)
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"step": step_response},
|
||||||
|
message="Step content updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=schemas.SuccessResponse)
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload file (image, document, etc.) for use in course content
|
||||||
|
"""
|
||||||
|
# Validate file type
|
||||||
|
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx', '.txt', '.md'}
|
||||||
|
file_extension = Path(file.filename).suffix.lower()
|
||||||
|
|
||||||
|
if file_extension not in allowed_extensions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File type not allowed. Allowed types: {', '.join(allowed_extensions)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
unique_filename = f"{current_user.id}_{int(time.time())}_{file.filename}"
|
||||||
|
file_path = UPLOAD_DIR / unique_filename
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
try:
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
|
||||||
|
|
||||||
|
# Return file URL
|
||||||
|
file_url = f"/uploads/{unique_filename}"
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={
|
||||||
|
"filename": file.filename,
|
||||||
|
"url": file_url,
|
||||||
|
"size": file_path.stat().st_size
|
||||||
|
},
|
||||||
|
message="File uploaded successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/render/markdown", response_model=schemas.SuccessResponse)
|
||||||
|
def render_markdown(
|
||||||
|
markdown: str = Form(...),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render markdown to HTML (server-side rendering)
|
||||||
|
This can be used for preview functionality
|
||||||
|
"""
|
||||||
|
# Simple markdown rendering (basic conversion)
|
||||||
|
# In production, use a proper markdown library like markdown2 or mistune
|
||||||
|
html_content = markdown.replace("\n", "<br>")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"html": html_content},
|
||||||
|
message="Markdown rendered (basic conversion)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/courses/{course_id}/structure", response_model=schemas.SuccessResponse)
|
||||||
|
def get_course_structure(
|
||||||
|
course_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get complete course structure for editing
|
||||||
|
"""
|
||||||
|
course = crud.get_course_by_id(db, course_id=course_id, include_structure=True)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(status_code=404, detail="Course not found")
|
||||||
|
|
||||||
|
# Check if user is the author
|
||||||
|
# Note: Add is_admin field to User model for admin access
|
||||||
|
if course.author_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to edit this course")
|
||||||
|
|
||||||
|
# Convert to response schema
|
||||||
|
course_structure = schemas.CourseStructureResponse.from_orm(course)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={"course": course_structure})
|
||||||
|
|
||||||
|
@router.post("/steps/{step_id}/autosave", response_model=schemas.SuccessResponse)
|
||||||
|
def autosave_step_content(
|
||||||
|
step_id: int,
|
||||||
|
content: str = Form(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Autosave step content (frequent saves during editing)
|
||||||
|
"""
|
||||||
|
step = crud.get_step_by_id(db, step_id=step_id)
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
# Update content only
|
||||||
|
step_update = schemas.StepUpdate(content=content)
|
||||||
|
updated_step = crud.update_step(db, step_id=step_id, step_update=step_update)
|
||||||
|
|
||||||
|
if not updated_step:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to autosave")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"step_id": step_id, "saved_at": updated_step.updated_at},
|
||||||
|
message="Content autosaved"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/create", response_model=schemas.SuccessResponse)
|
||||||
|
async def create_course(
|
||||||
|
course_data: schemas.CourseCreateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new course with content and structure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_course = crud.create_course_with_content(db, course_data, current_user.id)
|
||||||
|
course_response = schemas.CourseResponse.from_orm(db_course)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"course": course_response},
|
||||||
|
message="Course created successfully"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to create course: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draft endpoints - now using Course.status='draft'
|
||||||
|
@router.get("/drafts", response_model=schemas.SuccessResponse)
|
||||||
|
def get_drafts(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all draft courses for current user (status='draft')
|
||||||
|
"""
|
||||||
|
drafts = crud.get_user_drafts(db, current_user.id)
|
||||||
|
draft_responses = [schemas.CourseResponse.from_orm(d) for d in drafts]
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"drafts": draft_responses, "total": len(draft_responses)},
|
||||||
|
message="Drafts retrieved successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/drafts/{draft_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def get_draft(
|
||||||
|
draft_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a specific draft course with full structure
|
||||||
|
"""
|
||||||
|
draft = crud.get_user_draft(db, current_user.id, draft_id, include_structure=True)
|
||||||
|
if not draft:
|
||||||
|
raise HTTPException(status_code=404, detail="Draft not found")
|
||||||
|
|
||||||
|
draft_data = {
|
||||||
|
"id": draft.id,
|
||||||
|
"title": draft.title,
|
||||||
|
"description": draft.description,
|
||||||
|
"level": draft.level,
|
||||||
|
"duration": draft.duration,
|
||||||
|
"content": draft.content,
|
||||||
|
"status": draft.status,
|
||||||
|
"author_id": draft.author_id,
|
||||||
|
"created_at": draft.created_at.isoformat() if draft.created_at else None,
|
||||||
|
"updated_at": draft.updated_at.isoformat() if draft.updated_at else None,
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": module.id,
|
||||||
|
"title": module.title,
|
||||||
|
"description": module.description,
|
||||||
|
"duration": module.duration,
|
||||||
|
"order_index": module.order_index,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": lesson.id,
|
||||||
|
"title": lesson.title,
|
||||||
|
"description": lesson.description,
|
||||||
|
"order_index": lesson.order_index,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": step.id,
|
||||||
|
"step_type": step.step_type,
|
||||||
|
"title": step.title,
|
||||||
|
"content": step.content,
|
||||||
|
"order_index": step.order_index
|
||||||
|
} for step in lesson.steps
|
||||||
|
] if lesson.steps else []
|
||||||
|
}
|
||||||
|
for lesson in module.lessons
|
||||||
|
] if module.lessons else []
|
||||||
|
}
|
||||||
|
for module in draft.modules
|
||||||
|
] if draft.modules else []
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={"draft": draft_data})
|
||||||
|
|
||||||
|
@router.put("/drafts/{draft_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def update_draft(
|
||||||
|
draft_id: int,
|
||||||
|
course_update: schemas.CourseUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a draft course - replaces old /drafts/save endpoint
|
||||||
|
"""
|
||||||
|
draft = crud.get_user_draft(db, current_user.id, draft_id)
|
||||||
|
if not draft:
|
||||||
|
raise HTTPException(status_code=404, detail="Draft not found")
|
||||||
|
|
||||||
|
updated_draft = crud.update_course(db, draft_id, course_update)
|
||||||
|
draft_response = schemas.CourseResponse.from_orm(updated_draft)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"draft": draft_response, "saved_at": updated_draft.updated_at},
|
||||||
|
message="Draft updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/drafts/{draft_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def delete_draft(
|
||||||
|
draft_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a draft course
|
||||||
|
"""
|
||||||
|
deleted = crud.delete_user_draft(db, draft_id, current_user.id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Draft not found")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(message="Draft deleted successfully")
|
||||||
|
|
||||||
|
@router.post("/drafts/{draft_id}/publish", response_model=schemas.SuccessResponse)
|
||||||
|
def publish_draft(
|
||||||
|
draft_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Publish a draft by changing status to 'published'
|
||||||
|
"""
|
||||||
|
published_course = crud.publish_course(db, draft_id, current_user.id)
|
||||||
|
if not published_course:
|
||||||
|
raise HTTPException(status_code=404, detail="Draft not found")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"course": schemas.CourseResponse.from_orm(published_course)},
|
||||||
|
message="Course published successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Module endpoints
|
||||||
|
@router.post("/modules", response_model=schemas.SuccessResponse)
|
||||||
|
def create_module(
|
||||||
|
module: schemas.ModuleCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new module for a course
|
||||||
|
"""
|
||||||
|
# Check if user has access to the course (optional: add course_id validation)
|
||||||
|
created_module = crud.create_module(db, module)
|
||||||
|
module_response = schemas.ModuleResponse.from_orm(created_module)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"module": module_response},
|
||||||
|
message="Module created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/modules/{module_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def get_module(
|
||||||
|
module_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a module by ID
|
||||||
|
"""
|
||||||
|
module = crud.get_module_by_id(db, module_id)
|
||||||
|
if not module:
|
||||||
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
|
|
||||||
|
# TODO: Check if user has access to this module
|
||||||
|
|
||||||
|
module_response = schemas.ModuleResponse.from_orm(module)
|
||||||
|
return schemas.SuccessResponse(data={"module": module_response})
|
||||||
|
|
||||||
|
# Lesson endpoints
|
||||||
|
@router.post("/lessons", response_model=schemas.SuccessResponse)
|
||||||
|
def create_lesson(
|
||||||
|
lesson: schemas.LessonCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new lesson for a module
|
||||||
|
"""
|
||||||
|
# Validate that module exists
|
||||||
|
crud.get_module_by_id(db, lesson.module_id)
|
||||||
|
|
||||||
|
created_lesson = crud.create_lesson(db, lesson)
|
||||||
|
lesson_response = schemas.LessonResponse.from_orm(created_lesson)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"lesson": lesson_response},
|
||||||
|
message="Lesson created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/lessons/{lesson_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def get_lesson(
|
||||||
|
lesson_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a lesson by ID with steps
|
||||||
|
"""
|
||||||
|
lesson = crud.get_lesson_by_id(db, lesson_id)
|
||||||
|
if not lesson:
|
||||||
|
raise HTTPException(status_code=404, detail="Lesson not found")
|
||||||
|
|
||||||
|
lesson_data = {
|
||||||
|
"id": lesson.id,
|
||||||
|
"module_id": lesson.module_id,
|
||||||
|
"title": lesson.title,
|
||||||
|
"description": lesson.description,
|
||||||
|
"order_index": lesson.order_index,
|
||||||
|
"created_at": lesson.created_at,
|
||||||
|
"updated_at": lesson.updated_at,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": step.id,
|
||||||
|
"step_type": step.step_type,
|
||||||
|
"title": step.title,
|
||||||
|
"content": step.content,
|
||||||
|
"content_html": step.content_html,
|
||||||
|
"file_url": step.file_url,
|
||||||
|
"order_index": step.order_index
|
||||||
|
}
|
||||||
|
for step in sorted(lesson.steps, key=lambda s: s.order_index)
|
||||||
|
] if lesson.steps else []
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={"lesson": lesson_data})
|
||||||
|
|
||||||
|
# Update endpoints
|
||||||
|
@router.put("/modules/{module_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def update_module(
|
||||||
|
module_id: int,
|
||||||
|
module_update: schemas.ModuleUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a module
|
||||||
|
"""
|
||||||
|
updated_module = crud.update_module(db, module_id, module_update)
|
||||||
|
if not updated_module:
|
||||||
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
|
|
||||||
|
module_response = schemas.ModuleResponse.from_orm(updated_module)
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"module": module_response},
|
||||||
|
message="Module updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/modules/{module_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def delete_module(
|
||||||
|
module_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a module
|
||||||
|
"""
|
||||||
|
deleted = crud.delete_module(db, module_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(message="Module deleted successfully")
|
||||||
|
|
||||||
|
@router.put("/lessons/{lesson_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def update_lesson(
|
||||||
|
lesson_id: int,
|
||||||
|
lesson_update: schemas.LessonUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a lesson
|
||||||
|
"""
|
||||||
|
updated_lesson = crud.update_lesson(db, lesson_id, lesson_update)
|
||||||
|
if not updated_lesson:
|
||||||
|
raise HTTPException(status_code=404, detail="Lesson not found")
|
||||||
|
|
||||||
|
lesson_response = schemas.LessonResponse.from_orm(updated_lesson)
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"lesson": lesson_response},
|
||||||
|
message="Lesson updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/lessons/{lesson_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def delete_lesson(
|
||||||
|
lesson_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a lesson
|
||||||
|
"""
|
||||||
|
deleted = crud.delete_lesson(db, lesson_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Lesson not found")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(message="Lesson deleted successfully")
|
||||||
|
|
||||||
|
# Step endpoints
|
||||||
|
@router.post("/steps", response_model=schemas.SuccessResponse)
|
||||||
|
def create_step(
|
||||||
|
step: schemas.StepCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new step for a lesson
|
||||||
|
"""
|
||||||
|
lesson = crud.get_lesson_by_id(db, step.lesson_id)
|
||||||
|
if not lesson:
|
||||||
|
raise HTTPException(status_code=404, detail="Lesson not found")
|
||||||
|
|
||||||
|
created_step = crud.create_step(db, step)
|
||||||
|
step_response = schemas.StepResponse.from_orm(created_step)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"step": step_response},
|
||||||
|
message="Step created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/steps/{step_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def update_step(
|
||||||
|
step_id: int,
|
||||||
|
step_update: schemas.StepUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a step (full update including type, title, content, order)
|
||||||
|
"""
|
||||||
|
updated_step = crud.update_step(db, step_id, step_update)
|
||||||
|
if not updated_step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
step_response = schemas.StepResponse.from_orm(updated_step)
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"step": step_response},
|
||||||
|
message="Step updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/steps/{step_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def delete_step(
|
||||||
|
step_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a step
|
||||||
|
"""
|
||||||
|
deleted = crud.delete_step(db, step_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(message="Step deleted successfully")
|
||||||
|
|
||||||
|
# Test endpoints
|
||||||
|
@router.post("/steps/{step_id}/test/questions", response_model=schemas.SuccessResponse)
|
||||||
|
def create_test_question(
|
||||||
|
step_id: int,
|
||||||
|
question: schemas.TestQuestionCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new test question
|
||||||
|
"""
|
||||||
|
step = crud.get_step_by_id(db, step_id)
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
if step.step_type != 'test':
|
||||||
|
raise HTTPException(status_code=400, detail="Step is not a test")
|
||||||
|
|
||||||
|
created_question = crud.create_test_question(db, step_id, question)
|
||||||
|
question_response = schemas.TestQuestionResponse.from_orm(created_question)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"question": question_response},
|
||||||
|
message="Question created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/steps/{step_id}/test/questions", response_model=schemas.SuccessResponse)
|
||||||
|
def get_test_questions(
|
||||||
|
step_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all questions for a test
|
||||||
|
"""
|
||||||
|
step = crud.get_step_by_id(db, step_id)
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
questions = crud.get_test_questions(db, step_id)
|
||||||
|
questions_response = [schemas.TestQuestionResponse.from_orm(q) for q in questions]
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"questions": questions_response, "settings": step.test_settings}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/steps/{step_id}/test/questions/{question_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def update_test_question(
|
||||||
|
step_id: int,
|
||||||
|
question_id: int,
|
||||||
|
question_update: schemas.TestQuestionUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a test question
|
||||||
|
"""
|
||||||
|
updated_question = crud.update_test_question(db, question_id, question_update)
|
||||||
|
if not updated_question:
|
||||||
|
raise HTTPException(status_code=404, detail="Question not found")
|
||||||
|
|
||||||
|
question_response = schemas.TestQuestionResponse.from_orm(updated_question)
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"question": question_response},
|
||||||
|
message="Question updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/steps/{step_id}/test/questions/{question_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def delete_test_question(
|
||||||
|
step_id: int,
|
||||||
|
question_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a test question
|
||||||
|
"""
|
||||||
|
deleted = crud.delete_test_question(db, question_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Question not found")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(message="Question deleted successfully")
|
||||||
|
|
||||||
|
@router.put("/steps/{step_id}/test/settings", response_model=schemas.SuccessResponse)
|
||||||
|
def update_test_settings(
|
||||||
|
step_id: int,
|
||||||
|
settings: schemas.TestSettings,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update test settings (passing score, max attempts, time limit, show answers)
|
||||||
|
"""
|
||||||
|
updated_step = crud.update_test_settings(db, step_id, settings)
|
||||||
|
if not updated_step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={"settings": updated_step.test_settings},
|
||||||
|
message="Test settings updated successfully"
|
||||||
|
)
|
||||||
141
backend/app/routes/courses.py
Normal file
141
backend/app/routes/courses.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from .. import crud, schemas, auth
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/courses", tags=["courses"])
|
||||||
|
|
||||||
|
@router.get("/", response_model=schemas.SuccessResponse)
|
||||||
|
def get_courses(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=100),
|
||||||
|
level: Optional[str] = Query(None, pattern="^(beginner|intermediate|advanced)$"),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# Get published courses for all users (including guests)
|
||||||
|
courses = crud.get_courses(db, skip=skip, limit=limit, level=level, published_only=True)
|
||||||
|
|
||||||
|
# Convert SQLAlchemy models to Pydantic models
|
||||||
|
course_responses = [schemas.CourseResponse.from_orm(course) for course in courses]
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(
|
||||||
|
data={
|
||||||
|
"courses": course_responses,
|
||||||
|
"total": len(courses),
|
||||||
|
"skip": skip,
|
||||||
|
"limit": limit,
|
||||||
|
"level": level
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{course_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def get_course(
|
||||||
|
course_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Optional[User] = Depends(auth.get_optional_current_user)
|
||||||
|
):
|
||||||
|
course = crud.get_course_by_id(db, course_id=course_id)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Course not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check access based on status
|
||||||
|
course_status = getattr(course, 'status', 'draft')
|
||||||
|
author_id = getattr(course, 'author_id', None)
|
||||||
|
|
||||||
|
if course_status == 'published':
|
||||||
|
pass # Anyone can access published course
|
||||||
|
elif course_status == 'draft':
|
||||||
|
# Only author can access drafts
|
||||||
|
if current_user is None or author_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Not authorized to access this draft"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Archived - author-only access
|
||||||
|
if current_user is None or author_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Not authorized to access this course"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert SQLAlchemy model to Pydantic model
|
||||||
|
course_response = schemas.CourseResponse.from_orm(course)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={"course": course_response})
|
||||||
|
|
||||||
|
@router.put("/{course_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def update_course(
|
||||||
|
course_id: int,
|
||||||
|
course_update: schemas.CourseUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
course = crud.get_course_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(status_code=404, detail="Course not found")
|
||||||
|
|
||||||
|
if course.author_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to update this course")
|
||||||
|
|
||||||
|
updated_course = crud.update_course(db, course_id, course_update)
|
||||||
|
course_response = schemas.CourseResponse.from_orm(updated_course)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={"course": course_response})
|
||||||
|
|
||||||
|
@router.get("/{course_id}/structure", response_model=schemas.SuccessResponse)
|
||||||
|
def get_course_structure(
|
||||||
|
course_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Optional[User] = Depends(auth.get_optional_current_user)
|
||||||
|
):
|
||||||
|
course = crud.get_course_by_id(db, course_id=course_id, include_structure=True)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(status_code=404, detail="Course not found")
|
||||||
|
|
||||||
|
if course.status != 'published':
|
||||||
|
if current_user is None or course.author_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
course_data = {
|
||||||
|
"id": course.id,
|
||||||
|
"title": course.title,
|
||||||
|
"description": course.description,
|
||||||
|
"level": course.level,
|
||||||
|
"duration": course.duration,
|
||||||
|
"status": course.status,
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": module.id,
|
||||||
|
"title": module.title,
|
||||||
|
"description": module.description,
|
||||||
|
"duration": module.duration,
|
||||||
|
"order_index": module.order_index,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": lesson.id,
|
||||||
|
"title": lesson.title,
|
||||||
|
"description": lesson.description,
|
||||||
|
"order_index": lesson.order_index,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": step.id,
|
||||||
|
"step_type": step.step_type,
|
||||||
|
"title": step.title,
|
||||||
|
"order_index": step.order_index
|
||||||
|
} for step in sorted(lesson.steps, key=lambda s: s.order_index)
|
||||||
|
] if lesson.steps else []
|
||||||
|
}
|
||||||
|
for lesson in sorted(module.lessons, key=lambda l: l.order_index)
|
||||||
|
] if module.lessons else []
|
||||||
|
}
|
||||||
|
for module in sorted(course.modules, key=lambda m: m.order_index)
|
||||||
|
] if course.modules else []
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={"course": course_data})
|
||||||
58
backend/app/routes/favorites.py
Normal file
58
backend/app/routes/favorites.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from .. import crud, schemas, auth
|
||||||
|
from ..database import get_db
|
||||||
|
from ..auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/favorites", tags=["favorites"])
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[schemas.FavoriteResponse])
|
||||||
|
def get_my_favorites(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: schemas.UserResponse = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
favorites = crud.get_favorite_courses(db, user_id=current_user.id)
|
||||||
|
# Convert SQLAlchemy models to Pydantic models
|
||||||
|
return [schemas.FavoriteResponse.model_validate(favorite) for favorite in favorites]
|
||||||
|
|
||||||
|
@router.post("/", response_model=schemas.FavoriteResponse)
|
||||||
|
def add_to_favorites(
|
||||||
|
favorite: schemas.FavoriteCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: schemas.UserResponse = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
# Check if course exists
|
||||||
|
course = crud.get_course_by_id(db, favorite.course_id)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Course not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to favorites
|
||||||
|
db_favorite = crud.add_favorite_course(db, user_id=current_user.id, course_id=favorite.course_id)
|
||||||
|
return schemas.FavoriteResponse.model_validate(db_favorite)
|
||||||
|
|
||||||
|
@router.delete("/{course_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def remove_from_favorites(
|
||||||
|
course_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: schemas.UserResponse = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
# Check if course exists
|
||||||
|
course = crud.get_course_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Course not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
success = crud.remove_favorite_course(db, user_id=current_user.id, course_id=course_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Course not in favorites"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True, "message": "Course removed from favorites"}
|
||||||
179
backend/app/routes/learn.py
Normal file
179
backend/app/routes/learn.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from .. import crud, schemas, auth
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/learn", tags=["learning"])
|
||||||
|
|
||||||
|
@router.get("/steps/{step_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def get_step_content(
|
||||||
|
step_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Optional[User] = Depends(auth.get_optional_current_user)
|
||||||
|
):
|
||||||
|
step = crud.get_step_by_id(db, step_id=step_id)
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
lesson = crud.get_lesson_by_id(db, step.lesson_id)
|
||||||
|
if not lesson:
|
||||||
|
raise HTTPException(status_code=404, detail="Lesson not found")
|
||||||
|
|
||||||
|
module = crud.get_module_by_id(db, lesson.module_id)
|
||||||
|
if not module:
|
||||||
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
|
|
||||||
|
from ..models import Course
|
||||||
|
course = db.query(Course).filter(Course.id == module.course_id).first()
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(status_code=404, detail="Course not found")
|
||||||
|
|
||||||
|
if course.status != 'published':
|
||||||
|
if current_user is None or course.author_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={
|
||||||
|
"step": {
|
||||||
|
"id": step.id,
|
||||||
|
"step_type": step.step_type,
|
||||||
|
"title": step.title,
|
||||||
|
"content": step.content,
|
||||||
|
"content_html": step.content_html,
|
||||||
|
"file_url": step.file_url,
|
||||||
|
"test_settings": step.test_settings,
|
||||||
|
"order_index": step.order_index
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
@router.get("/steps/{step_id}/test", response_model=schemas.SuccessResponse)
|
||||||
|
def get_test_for_viewing(
|
||||||
|
step_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get test questions for viewing (without correct answers)
|
||||||
|
"""
|
||||||
|
step = crud.get_step_by_id(db, step_id)
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
if step.step_type != 'test':
|
||||||
|
raise HTTPException(status_code=400, detail="Step is not a test")
|
||||||
|
|
||||||
|
questions = crud.get_test_questions(db, step_id)
|
||||||
|
|
||||||
|
# Prepare questions without correct answers
|
||||||
|
questions_for_viewer = []
|
||||||
|
for q in questions:
|
||||||
|
question_data = {
|
||||||
|
"id": q.id,
|
||||||
|
"question_text": q.question_text,
|
||||||
|
"question_type": q.question_type,
|
||||||
|
"points": q.points,
|
||||||
|
"order_index": q.order_index
|
||||||
|
}
|
||||||
|
|
||||||
|
if q.question_type in ['single_choice', 'multiple_choice']:
|
||||||
|
# Return answers without is_correct flag
|
||||||
|
question_data["answers"] = [
|
||||||
|
{"id": a.id, "answer_text": a.answer_text, "order_index": a.order_index}
|
||||||
|
for a in q.answers
|
||||||
|
]
|
||||||
|
elif q.question_type == 'match':
|
||||||
|
# Return match items shuffled
|
||||||
|
question_data["match_items"] = [
|
||||||
|
{"id": m.id, "left_text": m.left_text, "right_text": m.right_text, "order_index": m.order_index}
|
||||||
|
for m in q.match_items
|
||||||
|
]
|
||||||
|
|
||||||
|
questions_for_viewer.append(question_data)
|
||||||
|
|
||||||
|
# Get user's previous attempts
|
||||||
|
attempts = crud.get_test_attempts(db, current_user.id, step_id)
|
||||||
|
attempts_data = [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"score": float(a.score),
|
||||||
|
"max_score": float(a.max_score),
|
||||||
|
"passed": a.passed,
|
||||||
|
"completed_at": a.completed_at.isoformat() if a.completed_at else None
|
||||||
|
}
|
||||||
|
for a in attempts
|
||||||
|
]
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={
|
||||||
|
"questions": questions_for_viewer,
|
||||||
|
"settings": step.test_settings,
|
||||||
|
"attempts": attempts_data
|
||||||
|
})
|
||||||
|
|
||||||
|
@router.post("/steps/{step_id}/test/submit", response_model=schemas.SuccessResponse)
|
||||||
|
def submit_test(
|
||||||
|
step_id: int,
|
||||||
|
attempt: schemas.TestAttemptCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Submit test answers and get results
|
||||||
|
"""
|
||||||
|
step = crud.get_step_by_id(db, step_id)
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
if step.step_type != 'test':
|
||||||
|
raise HTTPException(status_code=400, detail="Step is not a test")
|
||||||
|
|
||||||
|
# Check max attempts limit
|
||||||
|
settings = step.test_settings or {}
|
||||||
|
max_attempts = settings.get('max_attempts', 0)
|
||||||
|
|
||||||
|
if max_attempts > 0:
|
||||||
|
attempts = crud.get_test_attempts(db, current_user.id, step_id)
|
||||||
|
if len(attempts) >= max_attempts:
|
||||||
|
raise HTTPException(status_code=400, detail="Maximum attempts reached")
|
||||||
|
|
||||||
|
# Create attempt and calculate score
|
||||||
|
test_attempt = crud.create_test_attempt(db, current_user.id, step_id, attempt.answers)
|
||||||
|
if not test_attempt:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create test attempt")
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={
|
||||||
|
"attempt": {
|
||||||
|
"id": test_attempt.id,
|
||||||
|
"score": float(test_attempt.score),
|
||||||
|
"max_score": float(test_attempt.max_score),
|
||||||
|
"passed": test_attempt.passed,
|
||||||
|
"completed_at": test_attempt.completed_at.isoformat() if test_attempt.completed_at else None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
@router.get("/steps/{step_id}/test/attempts", response_model=schemas.SuccessResponse)
|
||||||
|
def get_test_attempts(
|
||||||
|
step_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(auth.get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get user's test attempts history
|
||||||
|
"""
|
||||||
|
step = crud.get_step_by_id(db, step_id)
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(status_code=404, detail="Step not found")
|
||||||
|
|
||||||
|
attempts = crud.get_test_attempts(db, current_user.id, step_id)
|
||||||
|
attempts_data = [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"score": float(a.score),
|
||||||
|
"max_score": float(a.max_score),
|
||||||
|
"passed": a.passed,
|
||||||
|
"completed_at": a.completed_at.isoformat() if a.completed_at else None
|
||||||
|
}
|
||||||
|
for a in attempts
|
||||||
|
]
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data={"attempts": attempts_data})
|
||||||
106
backend/app/routes/progress.py
Normal file
106
backend/app/routes/progress.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from .. import crud, schemas, auth
|
||||||
|
from ..database import get_db
|
||||||
|
from ..auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/progress", tags=["progress"])
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[schemas.ProgressResponse])
|
||||||
|
def get_my_progress(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: schemas.UserResponse = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
progress_list = crud.get_user_progress(db, user_id=current_user.id)
|
||||||
|
# Calculate percentage for each progress
|
||||||
|
result = []
|
||||||
|
for progress in progress_list:
|
||||||
|
percentage = (progress.completed_lessons / progress.total_lessons * 100) if progress.total_lessons > 0 else 0
|
||||||
|
result.append({
|
||||||
|
"user_id": progress.user_id,
|
||||||
|
"course_id": progress.course_id,
|
||||||
|
"completed_lessons": progress.completed_lessons,
|
||||||
|
"total_lessons": progress.total_lessons,
|
||||||
|
"percentage": percentage,
|
||||||
|
"updated_at": progress.updated_at
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.get("/{course_id}", response_model=schemas.ProgressResponse)
|
||||||
|
def get_course_progress(
|
||||||
|
course_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: schemas.UserResponse = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
progress = crud.get_progress(db, user_id=current_user.id, course_id=course_id)
|
||||||
|
if not progress:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Progress not found for this course"
|
||||||
|
)
|
||||||
|
|
||||||
|
percentage = (progress.completed_lessons / progress.total_lessons * 100) if progress.total_lessons > 0 else 0
|
||||||
|
return {
|
||||||
|
"user_id": progress.user_id,
|
||||||
|
"course_id": progress.course_id,
|
||||||
|
"completed_lessons": progress.completed_lessons,
|
||||||
|
"total_lessons": progress.total_lessons,
|
||||||
|
"percentage": percentage,
|
||||||
|
"updated_at": progress.updated_at
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/{course_id}", response_model=schemas.ProgressResponse)
|
||||||
|
def update_progress(
|
||||||
|
course_id: int,
|
||||||
|
progress_update: schemas.ProgressUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: schemas.UserResponse = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
# Check if course exists
|
||||||
|
course = crud.get_course_by_id(db, course_id)
|
||||||
|
if not course:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Course not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate completed_lessons <= total_lessons
|
||||||
|
if progress_update.completed_lessons > progress_update.total_lessons:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Completed lessons cannot exceed total lessons"
|
||||||
|
)
|
||||||
|
|
||||||
|
progress = crud.create_or_update_progress(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
course_id=course_id,
|
||||||
|
completed_lessons=progress_update.completed_lessons,
|
||||||
|
total_lessons=progress_update.total_lessons
|
||||||
|
)
|
||||||
|
|
||||||
|
percentage = (progress.completed_lessons / progress.total_lessons * 100) if progress.total_lessons > 0 else 0
|
||||||
|
return {
|
||||||
|
"user_id": progress.user_id,
|
||||||
|
"course_id": progress.course_id,
|
||||||
|
"completed_lessons": progress.completed_lessons,
|
||||||
|
"total_lessons": progress.total_lessons,
|
||||||
|
"percentage": percentage,
|
||||||
|
"updated_at": progress.updated_at
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/{course_id}", response_model=schemas.SuccessResponse)
|
||||||
|
def delete_progress(
|
||||||
|
course_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: schemas.UserResponse = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
success = crud.delete_progress(db, user_id=current_user.id, course_id=course_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Progress not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True, "message": "Progress deleted"}
|
||||||
12
backend/app/routes/stats.py
Normal file
12
backend/app/routes/stats.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .. import crud, schemas
|
||||||
|
from ..database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/stats", tags=["stats"])
|
||||||
|
|
||||||
|
@router.get("/", response_model=schemas.SuccessResponse)
|
||||||
|
def get_stats(db: Session = Depends(get_db)):
|
||||||
|
stats = crud.get_stats(db)
|
||||||
|
|
||||||
|
return schemas.SuccessResponse(data=stats)
|
||||||
354
backend/app/schemas.py
Normal file
354
backend/app/schemas.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# User schemas
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str = Field(..., min_length=8, max_length=100)
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class UserResponse(UserBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
is_developer: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Token schemas
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
email: Optional[str] = None
|
||||||
|
|
||||||
|
# Course schemas
|
||||||
|
class CourseBase(BaseModel):
|
||||||
|
title: str = Field(..., max_length=255)
|
||||||
|
description: str
|
||||||
|
level: str = Field(..., pattern="^(beginner|intermediate|advanced)$")
|
||||||
|
duration: int = Field(0, ge=0)
|
||||||
|
status: str = Field('draft', pattern="^(draft|published|archived)$")
|
||||||
|
|
||||||
|
class CourseCreate(CourseBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Course builder schemas
|
||||||
|
class CourseCreateRequest(BaseModel):
|
||||||
|
title: str = Field(..., max_length=255)
|
||||||
|
description: str
|
||||||
|
level: str = Field(..., pattern="^(beginner|intermediate|advanced)$")
|
||||||
|
duration: int = Field(..., ge=0)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
structure: Optional[dict] = None
|
||||||
|
|
||||||
|
class CourseUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
level: Optional[str] = Field(None, pattern="^(beginner|intermediate|advanced)$")
|
||||||
|
duration: Optional[int] = Field(None, ge=0)
|
||||||
|
content: Optional[str] = None
|
||||||
|
status: Optional[str] = Field(None, pattern="^(draft|published|archived)$")
|
||||||
|
|
||||||
|
class CourseResponse(CourseBase):
|
||||||
|
id: int
|
||||||
|
author_id: int
|
||||||
|
content: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Course response with full structure
|
||||||
|
class CourseStructureResponse(CourseBase):
|
||||||
|
id: int
|
||||||
|
author_id: int
|
||||||
|
content: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
modules: list['ModuleResponse'] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Module schemas
|
||||||
|
class ModuleBase(BaseModel):
|
||||||
|
title: str = Field(..., max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
duration: Optional[str] = Field(None, max_length=100)
|
||||||
|
order_index: int = 0
|
||||||
|
|
||||||
|
class ModuleCreate(ModuleBase):
|
||||||
|
course_id: int
|
||||||
|
|
||||||
|
class ModuleUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
duration: Optional[str] = Field(None, max_length=100)
|
||||||
|
order_index: Optional[int] = None
|
||||||
|
|
||||||
|
class ModuleResponse(ModuleBase):
|
||||||
|
id: int
|
||||||
|
course_id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Lesson schemas
|
||||||
|
class LessonBase(BaseModel):
|
||||||
|
title: str = Field(..., max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
order_index: int = 0
|
||||||
|
|
||||||
|
class LessonCreate(LessonBase):
|
||||||
|
module_id: int
|
||||||
|
|
||||||
|
class LessonUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
order_index: Optional[int] = None
|
||||||
|
|
||||||
|
class LessonResponse(LessonBase):
|
||||||
|
id: int
|
||||||
|
module_id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Lesson Step schemas
|
||||||
|
class StepBase(BaseModel):
|
||||||
|
step_type: str = Field(..., pattern="^(theory|practice|lab|test|presentation)$")
|
||||||
|
title: Optional[str] = Field(None, max_length=255)
|
||||||
|
content: Optional[str] = None
|
||||||
|
content_html: Optional[str] = None
|
||||||
|
file_url: Optional[str] = Field(None, max_length=500)
|
||||||
|
order_index: int = 0
|
||||||
|
|
||||||
|
class StepCreate(StepBase):
|
||||||
|
lesson_id: int
|
||||||
|
|
||||||
|
class StepUpdate(BaseModel):
|
||||||
|
step_type: Optional[str] = Field(None, pattern="^(theory|practice|lab|test|presentation)$")
|
||||||
|
title: Optional[str] = Field(None, max_length=255)
|
||||||
|
content: Optional[str] = None
|
||||||
|
content_html: Optional[str] = None
|
||||||
|
file_url: Optional[str] = Field(None, max_length=500)
|
||||||
|
order_index: Optional[int] = None
|
||||||
|
|
||||||
|
class StepResponse(StepBase):
|
||||||
|
id: int
|
||||||
|
lesson_id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Test schemas
|
||||||
|
class TestSettings(BaseModel):
|
||||||
|
passing_score: int = Field(70, ge=0, le=100)
|
||||||
|
max_attempts: int = Field(0, ge=0)
|
||||||
|
time_limit: int = Field(0, ge=0)
|
||||||
|
show_answers: bool = False
|
||||||
|
|
||||||
|
class TestAnswerBase(BaseModel):
|
||||||
|
answer_text: str
|
||||||
|
is_correct: bool = False
|
||||||
|
order_index: int = 0
|
||||||
|
|
||||||
|
class TestAnswerCreate(TestAnswerBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TestAnswerResponse(TestAnswerBase):
|
||||||
|
id: int
|
||||||
|
question_id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TestMatchItemBase(BaseModel):
|
||||||
|
left_text: str
|
||||||
|
right_text: str
|
||||||
|
order_index: int = 0
|
||||||
|
|
||||||
|
class TestMatchItemCreate(TestMatchItemBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TestMatchItemResponse(TestMatchItemBase):
|
||||||
|
id: int
|
||||||
|
question_id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TestQuestionBase(BaseModel):
|
||||||
|
question_text: str
|
||||||
|
question_type: str = Field(..., pattern="^(single_choice|multiple_choice|text_answer|match)$")
|
||||||
|
points: int = Field(1, ge=1)
|
||||||
|
order_index: int = 0
|
||||||
|
|
||||||
|
class TestQuestionCreate(TestQuestionBase):
|
||||||
|
answers: List[TestAnswerCreate] = []
|
||||||
|
match_items: List[TestMatchItemCreate] = []
|
||||||
|
correct_answers: List[str] = [] # Для text_answer
|
||||||
|
|
||||||
|
class TestQuestionUpdate(BaseModel):
|
||||||
|
question_text: Optional[str] = None
|
||||||
|
question_type: Optional[str] = Field(None, pattern="^(single_choice|multiple_choice|text_answer|match)$")
|
||||||
|
points: Optional[int] = Field(None, ge=1)
|
||||||
|
order_index: Optional[int] = None
|
||||||
|
answers: Optional[List[TestAnswerCreate]] = None
|
||||||
|
match_items: Optional[List[TestMatchItemCreate]] = None
|
||||||
|
correct_answers: Optional[List[str]] = None
|
||||||
|
|
||||||
|
class TestQuestionResponse(TestQuestionBase):
|
||||||
|
id: int
|
||||||
|
step_id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
answers: List[TestAnswerResponse] = []
|
||||||
|
match_items: List[TestMatchItemResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TestQuestionForViewer(TestQuestionResponse):
|
||||||
|
"""Вопрос без правильных ответов для отображения студенту"""
|
||||||
|
answers: List[dict] = [] # {id, answer_text, order_index} без is_correct
|
||||||
|
match_items: List[TestMatchItemResponse] = []
|
||||||
|
|
||||||
|
class TestAttemptCreate(BaseModel):
|
||||||
|
step_id: int
|
||||||
|
answers: Dict[str, Any] # {question_id: answer_id или [answer_ids] или text или {left_id: right_id}}
|
||||||
|
|
||||||
|
class TestAttemptResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
score: float
|
||||||
|
max_score: float
|
||||||
|
passed: bool
|
||||||
|
completed_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TestAttemptWithDetailsResponse(TestAttemptResponse):
|
||||||
|
"""Попытка с деталями для просмотра результатов"""
|
||||||
|
answers: Dict[str, Any]
|
||||||
|
|
||||||
|
# Full course structure schemas
|
||||||
|
class CourseStructureResponse(CourseResponse):
|
||||||
|
modules: list["ModuleWithLessonsResponse"] = []
|
||||||
|
|
||||||
|
class ModuleWithLessonsResponse(ModuleResponse):
|
||||||
|
lessons: list["LessonWithStepsResponse"] = []
|
||||||
|
|
||||||
|
class LessonWithStepsResponse(LessonResponse):
|
||||||
|
steps: list[StepResponse] = []
|
||||||
|
|
||||||
|
# Update forward references
|
||||||
|
CourseStructureResponse.update_forward_refs()
|
||||||
|
ModuleWithLessonsResponse.update_forward_refs()
|
||||||
|
LessonWithStepsResponse.update_forward_refs()
|
||||||
|
|
||||||
|
# Favorite schemas
|
||||||
|
class FavoriteBase(BaseModel):
|
||||||
|
course_id: int
|
||||||
|
|
||||||
|
class FavoriteCreate(FavoriteBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FavoriteResponse(FavoriteBase):
|
||||||
|
user_id: int
|
||||||
|
added_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Progress schemas
|
||||||
|
class ProgressBase(BaseModel):
|
||||||
|
course_id: int
|
||||||
|
completed_lessons: int = Field(..., ge=0)
|
||||||
|
total_lessons: int = Field(..., gt=0)
|
||||||
|
|
||||||
|
class ProgressCreate(ProgressBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ProgressUpdate(BaseModel):
|
||||||
|
completed_lessons: int = Field(..., ge=0)
|
||||||
|
total_lessons: int = Field(..., gt=0)
|
||||||
|
|
||||||
|
class ProgressResponse(ProgressBase):
|
||||||
|
user_id: int
|
||||||
|
percentage: float
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Stats schemas
|
||||||
|
class StatsResponse(BaseModel):
|
||||||
|
total_users: int
|
||||||
|
total_courses: int
|
||||||
|
most_popular_course: Optional[str] = None
|
||||||
|
|
||||||
|
# Response schemas
|
||||||
|
class SuccessResponse(BaseModel):
|
||||||
|
success: bool = True
|
||||||
|
data: Optional[dict] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
# ErrorResponse
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
success: bool = False
|
||||||
|
error: str
|
||||||
|
details: Optional[dict] = None
|
||||||
|
|
||||||
|
# Course builder schemas
|
||||||
|
class CourseCreateRequest(BaseModel):
|
||||||
|
title: str = Field(..., max_length=255)
|
||||||
|
description: str
|
||||||
|
level: str = Field(..., pattern="^(beginner|intermediate|advanced)$")
|
||||||
|
duration: int = Field(..., ge=0)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
structure: Optional[dict] = None
|
||||||
|
|
||||||
|
# Course draft schemas
|
||||||
|
class CourseDraftBase(BaseModel):
|
||||||
|
title: str = Field(..., max_length=255)
|
||||||
|
description: str
|
||||||
|
level: str = Field(..., pattern="^(beginner|intermediate|advanced)$")
|
||||||
|
content: str
|
||||||
|
|
||||||
|
class CourseDraftCreate(CourseDraftBase):
|
||||||
|
structure: Optional[str] = None # JSON string
|
||||||
|
|
||||||
|
class CourseDraftUpdate(CourseDraftCreate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CourseDraftResponse(CourseDraftBase):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
102
backend/init.sql
Normal file
102
backend/init.sql
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
-- Seed Data for Auth Learning Database
|
||||||
|
-- Tables are created by Alembic migrations (001_initial_migration.py)
|
||||||
|
|
||||||
|
-- Seed default test user if not exists (password: Test1234)
|
||||||
|
INSERT INTO users (email, hashed_password, is_active)
|
||||||
|
VALUES ('test@example.com', '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', true)
|
||||||
|
ON CONFLICT (email) DO NOTHING;
|
||||||
|
|
||||||
|
-- Seed sample courses with Russian content covering various topics
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
test_user_id INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Get test user ID
|
||||||
|
SELECT id INTO test_user_id FROM users WHERE email = 'test@example.com';
|
||||||
|
|
||||||
|
-- Check if courses already exist
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM courses LIMIT 1) THEN
|
||||||
|
-- Insert sample courses
|
||||||
|
INSERT INTO courses (title, description, level, duration, author_id, is_published) VALUES
|
||||||
|
(
|
||||||
|
'Python Fundamentals',
|
||||||
|
'Learn the basics of Python programming language including syntax, data types, and control structures.',
|
||||||
|
'beginner',
|
||||||
|
20,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Web Development with FastAPI',
|
||||||
|
'Build modern web applications using FastAPI framework with Python.',
|
||||||
|
'intermediate',
|
||||||
|
30,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Docker for Developers',
|
||||||
|
'Master containerization with Docker and Docker Compose for application deployment.',
|
||||||
|
'intermediate',
|
||||||
|
25,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Advanced Algorithms',
|
||||||
|
'Deep dive into complex algorithms and data structures for technical interviews.',
|
||||||
|
'advanced',
|
||||||
|
40,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'JavaScript Essentials',
|
||||||
|
'Learn JavaScript from scratch including ES6+ features and DOM manipulation.',
|
||||||
|
'beginner',
|
||||||
|
25,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Database Design',
|
||||||
|
'Learn relational database design principles and SQL optimization techniques.',
|
||||||
|
'intermediate',
|
||||||
|
35,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Machine Learning Basics',
|
||||||
|
'Introduction to machine learning concepts and practical implementations.',
|
||||||
|
'advanced',
|
||||||
|
45,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'DevOps Practices',
|
||||||
|
'Learn continuous integration, deployment, and infrastructure as code.',
|
||||||
|
'intermediate',
|
||||||
|
30,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'React Frontend Development',
|
||||||
|
'Build modern user interfaces with React library and hooks.',
|
||||||
|
'intermediate',
|
||||||
|
35,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'System Architecture',
|
||||||
|
'Design scalable and maintainable software system architectures.',
|
||||||
|
'advanced',
|
||||||
|
50,
|
||||||
|
test_user_id,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
alembic==1.13.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.6
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
email-validator>=2.0.0
|
||||||
|
markdown==3.5.1
|
||||||
196
docker-cleanup.sh
Normal file
196
docker-cleanup.sh
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Docker Cleanup Script for Auth Learning App
|
||||||
|
# Usage: ./docker-cleanup.sh [--soft|--medium|--all]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
echo -e "\n${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE} Docker Cleanup Script${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_step() {
|
||||||
|
echo -e "\n${YELLOW}[STEP]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_usage() {
|
||||||
|
echo "Usage: $0 [OPTION]"
|
||||||
|
echo "Options:"
|
||||||
|
echo " --soft Remove containers and networks (safe)"
|
||||||
|
echo " --medium Remove containers, networks, and dangling images (recommended)"
|
||||||
|
echo " --all Remove everything including volumes and build cache (DANGER: deletes database!)"
|
||||||
|
echo " --help Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Default: --medium"
|
||||||
|
}
|
||||||
|
|
||||||
|
soft_cleanup() {
|
||||||
|
print_step "Performing SOFT cleanup (containers and networks)..."
|
||||||
|
|
||||||
|
# Stop and remove containers
|
||||||
|
if docker-compose ps -q > /dev/null 2>&1; then
|
||||||
|
print_step "Stopping and removing containers..."
|
||||||
|
docker-compose down
|
||||||
|
print_success "Containers stopped and removed"
|
||||||
|
else
|
||||||
|
print_warning "No running containers found for this project"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove project networks
|
||||||
|
print_step "Removing project networks..."
|
||||||
|
local networks=$(docker network ls --filter name=auth_learning -q)
|
||||||
|
if [ -n "$networks" ]; then
|
||||||
|
echo "$networks" | xargs -r docker network rm
|
||||||
|
print_success "Project networks removed"
|
||||||
|
else
|
||||||
|
print_warning "No project networks found"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
medium_cleanup() {
|
||||||
|
soft_cleanup
|
||||||
|
|
||||||
|
print_step "Performing MEDIUM cleanup (dangling images)..."
|
||||||
|
|
||||||
|
# Remove dangling images
|
||||||
|
print_step "Removing dangling images..."
|
||||||
|
local dangling_images=$(docker images -f "dangling=true" -q)
|
||||||
|
if [ -n "$dangling_images" ]; then
|
||||||
|
echo "$dangling_images" | xargs -r docker rmi
|
||||||
|
print_success "Dangling images removed"
|
||||||
|
else
|
||||||
|
print_warning "No dangling images found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove unused images (without tags)
|
||||||
|
print_step "Removing unused images..."
|
||||||
|
local untagged_images=$(docker images --filter "dangling=false" --format "{{.Repository}}:{{.Tag}}" | grep "<none>" | cut -d' ' -f1)
|
||||||
|
if [ -n "$untagged_images" ]; then
|
||||||
|
echo "$untagged_images" | xargs -r docker rmi
|
||||||
|
print_success "Untagged images removed"
|
||||||
|
else
|
||||||
|
print_warning "No untagged images found"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
all_cleanup() {
|
||||||
|
medium_cleanup
|
||||||
|
|
||||||
|
print_step "Performing ALL cleanup (volumes and build cache)..."
|
||||||
|
|
||||||
|
# Remove volumes (WARNING: This will delete database data!)
|
||||||
|
print_warning "Removing volumes (this will DELETE database data!)"
|
||||||
|
read -p "Are you sure you want to delete ALL volumes? [y/N]: " confirm
|
||||||
|
if [[ $confirm =~ ^[Yy]$ ]]; then
|
||||||
|
print_step "Removing volumes..."
|
||||||
|
docker volume prune -f
|
||||||
|
print_success "Volumes removed"
|
||||||
|
else
|
||||||
|
print_warning "Skipping volume removal"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean build cache
|
||||||
|
print_step "Cleaning build cache..."
|
||||||
|
docker builder prune -f
|
||||||
|
print_success "Build cache cleaned"
|
||||||
|
|
||||||
|
# Remove all unused images (not just dangling)
|
||||||
|
print_step "Removing all unused images..."
|
||||||
|
docker image prune -a -f
|
||||||
|
print_success "All unused images removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main execution
|
||||||
|
main() {
|
||||||
|
print_header
|
||||||
|
|
||||||
|
local mode="--medium"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
print_warning "No mode specified, using default: --medium"
|
||||||
|
else
|
||||||
|
case "$1" in
|
||||||
|
--soft)
|
||||||
|
mode="--soft"
|
||||||
|
;;
|
||||||
|
--medium)
|
||||||
|
mode="--medium"
|
||||||
|
;;
|
||||||
|
--all)
|
||||||
|
mode="--all"
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
print_usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_error "Unknown option: $1"
|
||||||
|
print_usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "Mode: ${GREEN}$mode${NC}"
|
||||||
|
echo -e "Project: ${YELLOW}auth-learning-app${NC}"
|
||||||
|
|
||||||
|
# Check if docker-compose.yml exists
|
||||||
|
if [ ! -f "docker-compose.yml" ]; then
|
||||||
|
print_error "docker-compose.yml not found in current directory"
|
||||||
|
echo "Please run this script from the project root directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Docker is running
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
print_error "Docker is not running. Please start Docker daemon."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Execute cleanup based on mode
|
||||||
|
case "$mode" in
|
||||||
|
--soft)
|
||||||
|
soft_cleanup
|
||||||
|
;;
|
||||||
|
--medium)
|
||||||
|
medium_cleanup
|
||||||
|
;;
|
||||||
|
--all)
|
||||||
|
all_cleanup
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
print_step "Cleanup completed!"
|
||||||
|
|
||||||
|
# Show remaining resources
|
||||||
|
echo -e "\n${BLUE}Remaining Docker resources:${NC}"
|
||||||
|
echo "Containers: $(docker ps -a -q --filter name=auth_learning 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
echo "Images: $(docker images -q --filter reference='*auth-learning*' 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
echo "Networks: $(docker network ls -q --filter name=auth_learning 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
echo "Volumes: $(docker volume ls -q --filter name=auth_learning 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function
|
||||||
|
main "$@"
|
||||||
79
docker-compose.yml
Normal file
79
docker-compose.yml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: auth_learning_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-auth_user}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-auth_password}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-auth_learning}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
# - ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql # Disabled - using Alembic migrations
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
networks:
|
||||||
|
- auth_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-auth_user} -d ${POSTGRES_DB:-auth_learning}"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
backend:
|
||||||
|
volumes:
|
||||||
|
- ./backend/app:/app/app
|
||||||
|
- ./backend/alembic:/app/alembic
|
||||||
|
- ./backend/alembic.ini:/app/alembic.ini
|
||||||
|
- uploads:/app/uploads
|
||||||
|
build: ./backend
|
||||||
|
container_name: auth_learning_backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-auth_user}:${POSTGRES_PASSWORD:-auth_password}@postgres:5432/${POSTGRES_DB:-auth_learning}
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-your-secret-key-change-in-production}
|
||||||
|
DEVELOPER_SECRET: ${DEVELOPER_SECRET:-1982}
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7}
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- auth_network
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
cd /app || true &&
|
||||||
|
echo 'Current directory:' &&
|
||||||
|
pwd &&
|
||||||
|
echo 'Files in /app:' &&
|
||||||
|
ls -la /app &&
|
||||||
|
echo 'Files in /app/alembic:' &&
|
||||||
|
ls -la /app/alembic/ &&
|
||||||
|
echo 'Running migrations...' &&
|
||||||
|
python -m alembic upgrade head &&
|
||||||
|
echo 'Starting server...' &&
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/usr/share/nginx/html
|
||||||
|
build: ./frontend
|
||||||
|
container_name: auth_learning_frontend
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- auth_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
auth_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
uploads:
|
||||||
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Remove default nginx website
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Copy static files
|
||||||
|
COPY ./public /usr/share/nginx/html
|
||||||
|
COPY ./src /usr/share/nginx/html/src
|
||||||
|
COPY ./css /usr/share/nginx/html/css
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
40
frontend/css/auth.css
Normal file
40
frontend/css/auth.css
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/* Auth-specific styles */
|
||||||
|
.auth-required {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-required h2 {
|
||||||
|
color: #4b5563;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-required a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-required a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer a {
|
||||||
|
color: #6b7280;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer a:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
222
frontend/css/courses.css
Normal file
222
frontend/css/courses.css
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/* Courses-specific styles */
|
||||||
|
.courses-page h1 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-detail-page {
|
||||||
|
background-color: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-description h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-description p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-actions-panel {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-display {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt p {
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt a {
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-prompt a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress page */
|
||||||
|
.progress-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-item {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-percentage {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-details span {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-update-progress {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #4b5563;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-update-progress:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Available courses */
|
||||||
|
.available-courses {
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-courses h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-courses .courses-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-card.small {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-card.small h4 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start-course {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
background-color: #2563eb;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start-course:hover {
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Favorites page */
|
||||||
|
.btn-remove-favorite {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-favorite:hover {
|
||||||
|
background-color: #fef2f2;
|
||||||
|
}
|
||||||
1201
frontend/css/style.css
Normal file
1201
frontend/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
67
frontend/nginx.conf
Normal file
67
frontend/nginx.conf
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html/public;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 60M;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# Static files - CSS
|
||||||
|
location /css/ {
|
||||||
|
alias /usr/share/nginx/html/css/;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files - JS
|
||||||
|
location /src/ {
|
||||||
|
alias /usr/share/nginx/html/src/;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA routing - all routes go to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uploaded files proxy to backend
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Cache uploaded files
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Other static files cache (excluding .css and .js which are handled above)
|
||||||
|
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/public/index.html
Normal file
28
frontend/public/index.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LearnMD — Умное корпоративное обучение</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/css/auth.css">
|
||||||
|
<link rel="stylesheet" href="/css/courses.css">
|
||||||
|
<link rel="stylesheet" href="/src/styles/course-viewer.css">
|
||||||
|
<link rel="stylesheet" href="/src/styles/lesson-editor.css">
|
||||||
|
<link rel="stylesheet" href="/src/styles/pdf-viewer.css">
|
||||||
|
<link rel="stylesheet" href="/src/styles/test-editor.css">
|
||||||
|
<link rel="stylesheet" href="/src/styles/test-viewer.css">
|
||||||
|
<link rel="stylesheet" href="/src/styles/landing.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap" as="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/vditor@3.10.4/dist/index.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
341
frontend/src/api/courses.js
Normal file
341
frontend/src/api/courses.js
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { apiRequest, getAuthToken } from '../utils/api.js';
|
||||||
|
|
||||||
|
export const coursesAPI = {
|
||||||
|
async getAll(level = null) {
|
||||||
|
const url = level ? `/api/courses?level=${level}` : '/api/courses';
|
||||||
|
const response = await apiRequest(url);
|
||||||
|
return response.data?.courses || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const response = await apiRequest(`/api/courses/${id}`);
|
||||||
|
return response.data?.course || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFavorites() {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) return [];
|
||||||
|
|
||||||
|
const response = await apiRequest('/api/favorites', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
return response || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async addFavorite(courseId) {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
const response = await apiRequest('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ course_id: courseId })
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFavorite(courseId) {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
const response = await apiRequest(`/api/favorites/${courseId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProgress(courseId) {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiRequest(`/api/progress/${courseId}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
return response.data || null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateProgress(courseId, completedLessons, totalLessons) {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
const response = await apiRequest(`/api/progress/${courseId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
completed_lessons: completedLessons,
|
||||||
|
total_lessons: totalLessons
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return response.data || response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCourse(courseData) {
|
||||||
|
const response = await apiRequest('/api/course-builder/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(courseData)
|
||||||
|
});
|
||||||
|
return response.data.course;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveDraft(draftData) {
|
||||||
|
const response = await apiRequest('/api/course-builder/drafts/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(draftData)
|
||||||
|
});
|
||||||
|
return response.data.draft;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDrafts() {
|
||||||
|
const response = await apiRequest('/api/course-builder/drafts');
|
||||||
|
return response.data?.drafts || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDraft(draftId) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/drafts/${draftId}`);
|
||||||
|
return response.data?.draft || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDraft(draftId, draftData) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/drafts/${draftId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(draftData)
|
||||||
|
});
|
||||||
|
return response.data?.draft || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async publishDraft(draftId) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/drafts/${draftId}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
return response.data?.course || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDraft(draftId) {
|
||||||
|
await apiRequest(`/api/course-builder/drafts/${draftId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createModule(moduleData) {
|
||||||
|
const response = await apiRequest('/api/course-builder/modules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(moduleData)
|
||||||
|
});
|
||||||
|
return response.data.module;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createLesson(lessonData) {
|
||||||
|
const response = await apiRequest('/api/course-builder/lessons', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(lessonData)
|
||||||
|
});
|
||||||
|
return response.data.lesson;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createStep(stepData) {
|
||||||
|
const response = await apiRequest('/api/course-builder/steps', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
lesson_id: stepData.lesson_id,
|
||||||
|
step_type: stepData.type,
|
||||||
|
title: stepData.title,
|
||||||
|
content: stepData.content,
|
||||||
|
file_url: stepData.file_url,
|
||||||
|
order_index: stepData.order
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return response.data?.step || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStep(stepId, stepData) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/steps/${stepId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
step_type: stepData.type,
|
||||||
|
title: stepData.title,
|
||||||
|
content: stepData.content,
|
||||||
|
file_url: stepData.file_url,
|
||||||
|
order_index: stepData.order
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return response.data?.step || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteStep(stepId) {
|
||||||
|
await apiRequest(`/api/course-builder/steps/${stepId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateModule(moduleId, moduleData) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/modules/${moduleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(moduleData)
|
||||||
|
});
|
||||||
|
return response.data.module;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteModule(moduleId) {
|
||||||
|
await apiRequest(`/api/course-builder/modules/${moduleId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateLesson(lessonId, lessonData) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/lessons/${lessonId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(lessonData)
|
||||||
|
});
|
||||||
|
return response.data.lesson;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteLesson(lessonId) {
|
||||||
|
await apiRequest(`/api/course-builder/lessons/${lessonId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCourseStructure(courseId) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/courses/${courseId}/structure`);
|
||||||
|
return response.data.course || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateCourse(courseId, courseData) {
|
||||||
|
const response = await apiRequest(`/api/courses/${courseId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(courseData)
|
||||||
|
});
|
||||||
|
return response.data.course;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCourseForLearning(courseId) {
|
||||||
|
const response = await apiRequest(`/api/courses/${courseId}/learn`);
|
||||||
|
return response.data?.course || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCourseStructure(courseId) {
|
||||||
|
const response = await apiRequest(`/api/courses/${courseId}/structure`);
|
||||||
|
return response.data?.course || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStepContent(stepId) {
|
||||||
|
const response = await apiRequest(`/api/learn/steps/${stepId}`);
|
||||||
|
return response.data?.step || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadFile(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${window.location.origin}/api/course-builder/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
let errorMsg = 'Upload failed';
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(text);
|
||||||
|
errorMsg = error.detail || errorMsg;
|
||||||
|
} catch {
|
||||||
|
errorMsg = text.substring(0, 200) || `HTTP ${response.status}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLesson(lessonId) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/lessons/${lessonId}`);
|
||||||
|
return response.data?.lesson || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test API methods
|
||||||
|
async getTestQuestions(stepId) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/steps/${stepId}/test/questions`);
|
||||||
|
return response.data || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTestQuestion(stepId, questionData) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/steps/${stepId}/test/questions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(questionData)
|
||||||
|
});
|
||||||
|
return response.data?.question || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTestQuestion(stepId, questionId, questionData) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/steps/${stepId}/test/questions/${questionId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(questionData)
|
||||||
|
});
|
||||||
|
return response.data?.question || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTestQuestion(stepId, questionId) {
|
||||||
|
await apiRequest(`/api/course-builder/steps/${stepId}/test/questions/${questionId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTestSettings(stepId, settings) {
|
||||||
|
const response = await apiRequest(`/api/course-builder/steps/${stepId}/test/settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(settings)
|
||||||
|
});
|
||||||
|
return response.data?.settings || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTestForViewing(stepId) {
|
||||||
|
const response = await apiRequest(`/api/learn/steps/${stepId}/test`);
|
||||||
|
return response.data || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitTest(stepId, answers) {
|
||||||
|
const response = await apiRequest(`/api/learn/steps/${stepId}/test/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ step_id: stepId, answers })
|
||||||
|
});
|
||||||
|
return response.data || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTestAttempts(stepId) {
|
||||||
|
const response = await apiRequest(`/api/learn/steps/${stepId}/test/attempts`);
|
||||||
|
return response.data?.attempts || [];
|
||||||
|
}
|
||||||
|
};
|
||||||
327
frontend/src/components/PdfViewer.js
Normal file
327
frontend/src/components/PdfViewer.js
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
export class PdfViewer {
|
||||||
|
constructor(container, pdfUrl, options = {}) {
|
||||||
|
this.container = container;
|
||||||
|
this.pdfUrl = pdfUrl;
|
||||||
|
this.options = {
|
||||||
|
showControls: true,
|
||||||
|
fullscreenButton: true,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
this.pdfDoc = null;
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.totalPages = 0;
|
||||||
|
this.scale = 1.5;
|
||||||
|
this.isFullscreen = false;
|
||||||
|
this.canvas = null;
|
||||||
|
this.ctx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (typeof pdfjsLib === 'undefined') {
|
||||||
|
console.error('PDF.js not loaded');
|
||||||
|
this.showError('PDF.js library not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadingTask = pdfjsLib.getDocument(this.pdfUrl);
|
||||||
|
|
||||||
|
loadingTask.onProgress = (progress) => {
|
||||||
|
if (progress.total) {
|
||||||
|
const percent = Math.round((progress.loaded / progress.total) * 100);
|
||||||
|
this.updateLoadingProgress(percent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pdfDoc = await loadingTask.promise;
|
||||||
|
this.totalPages = this.pdfDoc.numPages;
|
||||||
|
|
||||||
|
this.renderControls();
|
||||||
|
await this.renderPage(this.currentPage);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading PDF:', error);
|
||||||
|
this.showError(`Ошибка загрузки PDF: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="pdf-viewer">
|
||||||
|
<div class="pdf-loading" id="pdf-loading">
|
||||||
|
<div class="pdf-spinner"></div>
|
||||||
|
<p>Загрузка PDF... <span id="pdf-progress">0%</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-content" style="display: none;">
|
||||||
|
<div class="pdf-controls" id="pdf-controls"></div>
|
||||||
|
<div class="pdf-progress-bar">
|
||||||
|
<div class="pdf-progress-fill" id="pdf-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-canvas-container">
|
||||||
|
<canvas id="pdf-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-error" id="pdf-error" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.canvas = this.container.querySelector('#pdf-canvas');
|
||||||
|
this.ctx = this.canvas.getContext('2d');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderControls() {
|
||||||
|
const controls = this.container.querySelector('#pdf-controls');
|
||||||
|
if (!controls || !this.options.showControls) return;
|
||||||
|
|
||||||
|
controls.innerHTML = `
|
||||||
|
<div class="pdf-nav">
|
||||||
|
<button class="pdf-btn" id="pdf-prev" title="Предыдущая страница (←)">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M15 18l-6-6 6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="pdf-page-info">
|
||||||
|
<span id="pdf-current-page">${this.currentPage}</span> / <span id="pdf-total-pages">${this.totalPages}</span>
|
||||||
|
</span>
|
||||||
|
<button class="pdf-btn" id="pdf-next" title="Следующая страница (→)">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M9 18l6-6-6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-zoom">
|
||||||
|
<button class="pdf-btn" id="pdf-zoom-out" title="Уменьшить">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<circle cx="11" cy="11" r="8" stroke-width="2"/>
|
||||||
|
<path d="M21 21l-4.35-4.35M8 11h6" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="pdf-zoom-level" id="pdf-zoom-level">${Math.round(this.scale * 100)}%</span>
|
||||||
|
<button class="pdf-btn" id="pdf-zoom-in" title="Увеличить">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<circle cx="11" cy="11" r="8" stroke-width="2"/>
|
||||||
|
<path d="M21 21l-4.35-4.35M8 11h6M11 8v6" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${this.options.fullscreenButton ? `
|
||||||
|
<button class="pdf-btn pdf-fullscreen-btn" id="pdf-fullscreen" title="Полноэкранный режим">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
controls.style.display = 'flex';
|
||||||
|
controls.style.flexDirection = 'row';
|
||||||
|
controls.style.alignItems = 'center';
|
||||||
|
controls.style.justifyContent = 'center';
|
||||||
|
controls.style.gap = '24px';
|
||||||
|
controls.style.flexWrap = 'nowrap';
|
||||||
|
|
||||||
|
this.attachControlListeners();
|
||||||
|
this.hideLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
attachControlListeners() {
|
||||||
|
const prevBtn = this.container.querySelector('#pdf-prev');
|
||||||
|
const nextBtn = this.container.querySelector('#pdf-next');
|
||||||
|
const zoomInBtn = this.container.querySelector('#pdf-zoom-in');
|
||||||
|
const zoomOutBtn = this.container.querySelector('#pdf-zoom-out');
|
||||||
|
const fullscreenBtn = this.container.querySelector('#pdf-fullscreen');
|
||||||
|
|
||||||
|
prevBtn?.addEventListener('click', () => this.prevPage());
|
||||||
|
nextBtn?.addEventListener('click', () => this.nextPage());
|
||||||
|
zoomInBtn?.addEventListener('click', () => this.zoomIn());
|
||||||
|
zoomOutBtn?.addEventListener('click', () => this.zoomOut());
|
||||||
|
fullscreenBtn?.addEventListener('click', () => this.toggleFullscreen());
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderPage(pageNum) {
|
||||||
|
if (!this.pdfDoc) return;
|
||||||
|
|
||||||
|
const page = await this.pdfDoc.getPage(pageNum);
|
||||||
|
const viewport = page.getViewport({ scale: this.scale });
|
||||||
|
|
||||||
|
this.canvas.height = viewport.height;
|
||||||
|
this.canvas.width = viewport.width;
|
||||||
|
|
||||||
|
const renderContext = {
|
||||||
|
canvasContext: this.ctx,
|
||||||
|
viewport: viewport
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.render(renderContext).promise;
|
||||||
|
|
||||||
|
this.updatePageDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
async prevPage() {
|
||||||
|
if (this.currentPage <= 1) return;
|
||||||
|
this.currentPage--;
|
||||||
|
await this.renderPage(this.currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async nextPage() {
|
||||||
|
if (this.currentPage >= this.totalPages) return;
|
||||||
|
this.currentPage++;
|
||||||
|
await this.renderPage(this.currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async goToPage(pageNum) {
|
||||||
|
if (pageNum < 1 || pageNum > this.totalPages) return;
|
||||||
|
this.currentPage = pageNum;
|
||||||
|
await this.renderPage(this.currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async zoomIn() {
|
||||||
|
if (this.scale >= 3) return;
|
||||||
|
this.scale += 0.25;
|
||||||
|
await this.renderPage(this.currentPage);
|
||||||
|
this.updateZoomDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
async zoomOut() {
|
||||||
|
if (this.scale <= 0.5) return;
|
||||||
|
this.scale -= 0.25;
|
||||||
|
await this.renderPage(this.currentPage);
|
||||||
|
this.updateZoomDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFullscreen() {
|
||||||
|
const viewer = this.container.querySelector('.pdf-viewer');
|
||||||
|
|
||||||
|
if (!this.isFullscreen) {
|
||||||
|
if (viewer.requestFullscreen) {
|
||||||
|
viewer.requestFullscreen();
|
||||||
|
} else if (viewer.webkitRequestFullscreen) {
|
||||||
|
viewer.webkitRequestFullscreen();
|
||||||
|
} else if (viewer.msRequestFullscreen) {
|
||||||
|
viewer.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
viewer.classList.add('fullscreen');
|
||||||
|
this.isFullscreen = true;
|
||||||
|
} else {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else if (document.webkitExitFullscreen) {
|
||||||
|
document.webkitExitFullscreen();
|
||||||
|
} else if (document.msExitFullscreen) {
|
||||||
|
document.msExitFullscreen();
|
||||||
|
}
|
||||||
|
viewer.classList.remove('fullscreen');
|
||||||
|
this.isFullscreen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeydown(e) {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'PageUp':
|
||||||
|
e.preventDefault();
|
||||||
|
this.prevPage();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'PageDown':
|
||||||
|
case ' ': // Space
|
||||||
|
e.preventDefault();
|
||||||
|
this.nextPage();
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
e.preventDefault();
|
||||||
|
this.goToPage(1);
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
e.preventDefault();
|
||||||
|
this.goToPage(this.totalPages);
|
||||||
|
break;
|
||||||
|
case '+':
|
||||||
|
case '=':
|
||||||
|
e.preventDefault();
|
||||||
|
this.zoomIn();
|
||||||
|
break;
|
||||||
|
case '-':
|
||||||
|
e.preventDefault();
|
||||||
|
this.zoomOut();
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
e.preventDefault();
|
||||||
|
this.toggleFullscreen();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
if (this.isFullscreen) {
|
||||||
|
this.toggleFullscreen();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePageDisplay() {
|
||||||
|
const currentPageEl = this.container.querySelector('#pdf-current-page');
|
||||||
|
if (currentPageEl) {
|
||||||
|
currentPageEl.textContent = this.currentPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressFill = this.container.querySelector('#pdf-progress-fill');
|
||||||
|
if (progressFill && this.totalPages > 0) {
|
||||||
|
const progress = (this.currentPage / this.totalPages) * 100;
|
||||||
|
progressFill.style.width = `${progress}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateZoomDisplay() {
|
||||||
|
const zoomLevelEl = this.container.querySelector('#pdf-zoom-level');
|
||||||
|
if (zoomLevelEl) {
|
||||||
|
zoomLevelEl.textContent = `${Math.round(this.scale * 100)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLoadingProgress(percent) {
|
||||||
|
const progressEl = this.container.querySelector('#pdf-progress');
|
||||||
|
if (progressEl) {
|
||||||
|
progressEl.textContent = `${percent}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideLoading() {
|
||||||
|
const loading = this.container.querySelector('#pdf-loading');
|
||||||
|
const content = this.container.querySelector('.pdf-content');
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
if (content) content.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const loading = this.container.querySelector('#pdf-loading');
|
||||||
|
const error = this.container.querySelector('#pdf-error');
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
if (error) {
|
||||||
|
error.style.display = 'block';
|
||||||
|
error.innerHTML = `
|
||||||
|
<div class="pdf-error-content">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#ef4444">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke-width="2"/>
|
||||||
|
<path d="M15 9l-6 6M9 9l6 6" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<p>${message}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
document.removeEventListener('keydown', this.handleKeydown);
|
||||||
|
if (this.pdfDoc) {
|
||||||
|
this.pdfDoc.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
368
frontend/src/components/TestViewer.js
Normal file
368
frontend/src/components/TestViewer.js
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
|
||||||
|
export class TestViewer {
|
||||||
|
constructor(container, stepId) {
|
||||||
|
this.container = container;
|
||||||
|
this.stepId = stepId;
|
||||||
|
this.questions = [];
|
||||||
|
this.settings = {};
|
||||||
|
this.attempts = [];
|
||||||
|
this.currentQuestionIndex = 0;
|
||||||
|
this.answers = {};
|
||||||
|
this.timeRemaining = 0;
|
||||||
|
this.timerInterval = null;
|
||||||
|
this.isSubmitted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const response = await coursesAPI.getTestForViewing(this.stepId);
|
||||||
|
if (response) {
|
||||||
|
this.questions = response.questions || [];
|
||||||
|
this.settings = response.settings || {};
|
||||||
|
this.attempts = response.attempts || [];
|
||||||
|
|
||||||
|
// Initialize answers object
|
||||||
|
this.questions.forEach(q => {
|
||||||
|
if (q.question_type === 'single_choice') {
|
||||||
|
this.answers[q.id] = null;
|
||||||
|
} else if (q.question_type === 'multiple_choice') {
|
||||||
|
this.answers[q.id] = [];
|
||||||
|
} else if (q.question_type === 'text_answer') {
|
||||||
|
this.answers[q.id] = '';
|
||||||
|
} else if (q.question_type === 'match') {
|
||||||
|
this.answers[q.id] = {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start timer if time limit is set
|
||||||
|
if (this.settings.time_limit > 0) {
|
||||||
|
this.timeRemaining = this.settings.time_limit * 60;
|
||||||
|
this.startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load test:', error);
|
||||||
|
this.showError('Ошибка загрузки теста: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer() {
|
||||||
|
this.updateTimerDisplay();
|
||||||
|
this.timerInterval = setInterval(() => {
|
||||||
|
this.timeRemaining--;
|
||||||
|
this.updateTimerDisplay();
|
||||||
|
|
||||||
|
if (this.timeRemaining <= 0) {
|
||||||
|
clearInterval(this.timerInterval);
|
||||||
|
this.submitTest();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimerDisplay() {
|
||||||
|
const timerEl = document.getElementById('test-timer');
|
||||||
|
if (timerEl) {
|
||||||
|
const minutes = Math.floor(this.timeRemaining / 60);
|
||||||
|
const seconds = this.timeRemaining % 60;
|
||||||
|
timerEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
if (this.timeRemaining < 300) { // Less than 5 minutes
|
||||||
|
timerEl.classList.add('warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.isSubmitted) {
|
||||||
|
return this.renderResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = this.questions[this.currentQuestionIndex];
|
||||||
|
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="test-viewer">
|
||||||
|
<div class="test-header">
|
||||||
|
<div class="test-info">
|
||||||
|
<span class="question-counter">Вопрос ${this.currentQuestionIndex + 1} из ${this.questions.length}</span>
|
||||||
|
${this.settings.time_limit > 0 ? `
|
||||||
|
<span class="test-timer" id="test-timer">
|
||||||
|
${Math.floor(this.timeRemaining / 60)}:${(this.timeRemaining % 60).toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="test-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: ${((this.currentQuestionIndex + 1) / this.questions.length * 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question-container">
|
||||||
|
${this.renderQuestion(question)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-navigation">
|
||||||
|
<button class="btn btn-outline" onclick="window.prevQuestion()" ${this.currentQuestionIndex === 0 ? 'disabled' : ''}>
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="question-dots">
|
||||||
|
${this.questions.map((q, idx) => `
|
||||||
|
<button class="dot ${idx === this.currentQuestionIndex ? 'active' : ''} ${this.answers[q.id] ? 'answered' : ''}"
|
||||||
|
onclick="window.goToQuestion(${idx})">
|
||||||
|
${idx + 1}
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.currentQuestionIndex < this.questions.length - 1 ? `
|
||||||
|
<button class="btn btn-primary" onclick="window.nextQuestion()">
|
||||||
|
Далее →
|
||||||
|
</button>
|
||||||
|
` : `
|
||||||
|
<button class="btn btn-success" onclick="window.submitTest()">
|
||||||
|
Завершить тест
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderQuestion(question) {
|
||||||
|
if (!question) return '<p>Вопрос не найден</p>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="question" data-question-id="${question.id}">
|
||||||
|
<div class="question-header">
|
||||||
|
<span class="question-points">${question.points} балл(ов)</span>
|
||||||
|
</div>
|
||||||
|
<div class="question-text">${question.question_text}</div>
|
||||||
|
|
||||||
|
<div class="question-answers">
|
||||||
|
${this.renderAnswers(question)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAnswers(question) {
|
||||||
|
switch (question.question_type) {
|
||||||
|
case 'single_choice':
|
||||||
|
return this.renderSingleChoice(question);
|
||||||
|
case 'multiple_choice':
|
||||||
|
return this.renderMultipleChoice(question);
|
||||||
|
case 'text_answer':
|
||||||
|
return this.renderTextAnswer(question);
|
||||||
|
case 'match':
|
||||||
|
return this.renderMatch(question);
|
||||||
|
default:
|
||||||
|
return '<p>Неизвестный тип вопроса</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSingleChoice(question) {
|
||||||
|
return `
|
||||||
|
<div class="answers-list single-choice">
|
||||||
|
${question.answers.map(answer => `
|
||||||
|
<label class="answer-option ${this.answers[question.id] === answer.id ? 'selected' : ''}">
|
||||||
|
<input type="radio" name="question-${question.id}" value="${answer.id}"
|
||||||
|
${this.answers[question.id] === answer.id ? 'checked' : ''}
|
||||||
|
onchange="window.selectAnswer(${question.id}, ${answer.id})">
|
||||||
|
<span class="answer-text">${answer.answer_text}</span>
|
||||||
|
</label>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMultipleChoice(question) {
|
||||||
|
return `
|
||||||
|
<div class="answers-list multiple-choice">
|
||||||
|
${question.answers.map(answer => `
|
||||||
|
<label class="answer-option ${this.answers[question.id]?.includes(answer.id) ? 'selected' : ''}">
|
||||||
|
<input type="checkbox" value="${answer.id}"
|
||||||
|
${this.answers[question.id]?.includes(answer.id) ? 'checked' : ''}
|
||||||
|
onchange="window.toggleAnswer(${question.id}, ${answer.id})">
|
||||||
|
<span class="answer-text">${answer.answer_text}</span>
|
||||||
|
</label>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTextAnswer(question) {
|
||||||
|
return `
|
||||||
|
<div class="text-answer">
|
||||||
|
<textarea class="form-control" rows="4"
|
||||||
|
placeholder="Введите ваш ответ..."
|
||||||
|
onchange="window.setTextAnswer(${question.id}, this.value)">${this.answers[question.id] || ''}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMatch(question) {
|
||||||
|
// Shuffle right items for display
|
||||||
|
const rightItems = [...question.match_items].sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="match-question">
|
||||||
|
<div class="match-columns">
|
||||||
|
<div class="match-column left-column">
|
||||||
|
${question.match_items.map(item => `
|
||||||
|
<div class="match-item" data-id="${item.id}">
|
||||||
|
<span>${item.left_text}</span>
|
||||||
|
<select onchange="window.selectMatch(${question.id}, ${item.id}, this.value)">
|
||||||
|
<option value="">Выберите...</option>
|
||||||
|
${rightItems.map(r => `
|
||||||
|
<option value="${r.right_text}" ${this.answers[question.id]?.[item.id] === r.right_text ? 'selected' : ''}>
|
||||||
|
${r.right_text}
|
||||||
|
</option>
|
||||||
|
`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderResults() {
|
||||||
|
const lastAttempt = this.attempts[0];
|
||||||
|
if (!lastAttempt) return '<p>Результаты не найдены</p>';
|
||||||
|
|
||||||
|
const percentage = Math.round((lastAttempt.score / lastAttempt.max_score) * 100);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="test-results">
|
||||||
|
<div class="results-header ${lastAttempt.passed ? 'passed' : 'failed'}">
|
||||||
|
<h2>${lastAttempt.passed ? '✓ Тест пройден!' : '✗ Тест не пройден'}</h2>
|
||||||
|
<div class="score-circle">
|
||||||
|
<span class="score-value">${percentage}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-details">
|
||||||
|
<p>Набрано баллов: ${lastAttempt.score} из ${lastAttempt.max_score}</p>
|
||||||
|
<p>Проходной балл: ${this.settings.passing_score}%</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.settings.max_attempts > 0 ? `
|
||||||
|
<p class="attempts-info">Попыток использовано: ${this.attempts.length} из ${this.settings.max_attempts}</p>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${!lastAttempt.passed && (this.settings.max_attempts === 0 || this.attempts.length < this.settings.max_attempts) ? `
|
||||||
|
<button class="btn btn-primary" onclick="window.location.reload()">
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="test-error">
|
||||||
|
<p>${message}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitTest() {
|
||||||
|
if (this.timerInterval) {
|
||||||
|
clearInterval(this.timerInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all questions are answered
|
||||||
|
const unanswered = this.questions.filter(q => {
|
||||||
|
const answer = this.answers[q.id];
|
||||||
|
if (q.question_type === 'single_choice') return answer === null;
|
||||||
|
if (q.question_type === 'multiple_choice') return answer.length === 0;
|
||||||
|
if (q.question_type === 'text_answer') return !answer.trim();
|
||||||
|
if (q.question_type === 'match') return Object.keys(answer).length === 0;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unanswered.length > 0 && !confirm(`Осталось ${unanswered.length} без ответа. Продолжить?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await coursesAPI.submitTest(this.stepId, this.answers);
|
||||||
|
if (response && response.attempt) {
|
||||||
|
this.attempts.unshift(response.attempt);
|
||||||
|
this.isSubmitted = true;
|
||||||
|
this.container.innerHTML = this.renderResults();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка отправки теста: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.timerInterval) {
|
||||||
|
clearInterval(this.timerInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global functions
|
||||||
|
window.prevQuestion = function() {
|
||||||
|
if (!window.currentTestViewer) return;
|
||||||
|
if (window.currentTestViewer.currentQuestionIndex > 0) {
|
||||||
|
window.currentTestViewer.currentQuestionIndex--;
|
||||||
|
window.currentTestViewer.render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.nextQuestion = function() {
|
||||||
|
if (!window.currentTestViewer) return;
|
||||||
|
if (window.currentTestViewer.currentQuestionIndex < window.currentTestViewer.questions.length - 1) {
|
||||||
|
window.currentTestViewer.currentQuestionIndex++;
|
||||||
|
window.currentTestViewer.render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.goToQuestion = function(index) {
|
||||||
|
if (!window.currentTestViewer) return;
|
||||||
|
window.currentTestViewer.currentQuestionIndex = index;
|
||||||
|
window.currentTestViewer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.selectAnswer = function(questionId, answerId) {
|
||||||
|
if (!window.currentTestViewer) return;
|
||||||
|
window.currentTestViewer.answers[questionId] = answerId;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.toggleAnswer = function(questionId, answerId) {
|
||||||
|
if (!window.currentTestViewer) return;
|
||||||
|
const answers = window.currentTestViewer.answers[questionId];
|
||||||
|
const index = answers.indexOf(answerId);
|
||||||
|
if (index > -1) {
|
||||||
|
answers.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
answers.push(answerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.setTextAnswer = function(questionId, text) {
|
||||||
|
if (!window.currentTestViewer) return;
|
||||||
|
window.currentTestViewer.answers[questionId] = text;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.selectMatch = function(questionId, leftId, rightValue) {
|
||||||
|
if (!window.currentTestViewer) return;
|
||||||
|
if (!window.currentTestViewer.answers[questionId]) {
|
||||||
|
window.currentTestViewer.answers[questionId] = {};
|
||||||
|
}
|
||||||
|
window.currentTestViewer.answers[questionId][leftId] = rightValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.submitTest = function() {
|
||||||
|
if (!window.currentTestViewer) return;
|
||||||
|
window.currentTestViewer.submitTest();
|
||||||
|
};
|
||||||
141
frontend/src/components/auth.js
Normal file
141
frontend/src/components/auth.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { apiRequest, setAuthToken, removeAuthToken, getAuthToken } from '../utils/api.js';
|
||||||
|
import { router } from '../router.js';
|
||||||
|
|
||||||
|
export let currentUser = null;
|
||||||
|
|
||||||
|
export async function initAuth() {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/auth/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
currentUser = response.data || null;
|
||||||
|
console.log('User authenticated:', currentUser?.email, 'is_developer:', currentUser?.is_developer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
removeAuthToken();
|
||||||
|
currentUser = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateAuthUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email, password) {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
setAuthToken(response.data?.access_token);
|
||||||
|
localStorage.setItem('refresh_token', response.data?.refresh_token);
|
||||||
|
await initAuth();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
return { success: false, error: error.data?.detail || 'Ошибка входа' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(email, password) {
|
||||||
|
try {
|
||||||
|
await apiRequest('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration failed:', error);
|
||||||
|
return { success: false, error: error.data?.detail || 'Ошибка регистрации' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await apiRequest('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAuthToken();
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
currentUser = null;
|
||||||
|
updateAuthUI();
|
||||||
|
router.navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken() {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiRequest('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
_isRetry: true
|
||||||
|
});
|
||||||
|
|
||||||
|
setAuthToken(response.data?.access_token);
|
||||||
|
localStorage.setItem('refresh_token', response.data?.refresh_token);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Refresh failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAuthUI() {
|
||||||
|
console.log('updateAuthUI called, currentUser:', currentUser);
|
||||||
|
|
||||||
|
// Update navigation links
|
||||||
|
const navLinks = document.querySelector('.nav-links');
|
||||||
|
if (navLinks) {
|
||||||
|
console.log('Updating navigation links');
|
||||||
|
navLinks.innerHTML = `
|
||||||
|
<a href="/" data-link>Главная</a>
|
||||||
|
<a href="/courses" data-link>Курсы</a>
|
||||||
|
${currentUser ? `
|
||||||
|
${currentUser.is_developer ? `
|
||||||
|
<div class="nav-dropdown">
|
||||||
|
<button class="nav-dropdown-btn">Инструменты ▼</button>
|
||||||
|
<div class="nav-dropdown-content">
|
||||||
|
<a href="/dev-tools" data-link>Разработка</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<a href="/favorites" data-link>Избранное</a>
|
||||||
|
<a href="/progress" data-link>Мой прогресс</a>
|
||||||
|
<a href="/course-builder" data-link>Конструктор курса</a>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
console.log('nav-links element not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update auth button
|
||||||
|
const authBtn = document.getElementById('auth-btn');
|
||||||
|
console.log('Auth button:', authBtn);
|
||||||
|
if (!authBtn) return;
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
authBtn.textContent = currentUser.email;
|
||||||
|
authBtn.onclick = () => {
|
||||||
|
console.log('Logout clicked');
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
authBtn.textContent = 'Войти';
|
||||||
|
authBtn.onclick = () => {
|
||||||
|
console.log('Login clicked, navigating to /login');
|
||||||
|
router.navigate('/login');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
48
frontend/src/components/nav.js
Normal file
48
frontend/src/components/nav.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { currentUser } from './auth.js';
|
||||||
|
|
||||||
|
function removeExistingNav() {
|
||||||
|
const existingNav = document.querySelector('.main-nav');
|
||||||
|
if (existingNav) {
|
||||||
|
existingNav.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderNav() {
|
||||||
|
removeExistingNav();
|
||||||
|
|
||||||
|
const nav = document.createElement('nav');
|
||||||
|
nav.className = 'main-nav';
|
||||||
|
nav.innerHTML = `
|
||||||
|
<div class="nav-container">
|
||||||
|
<div class="nav-brand">
|
||||||
|
<a href="/" data-link>Изучение аутентификации</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/" data-link>Главная</a>
|
||||||
|
<a href="/courses" data-link>Курсы</a>
|
||||||
|
${currentUser ? `
|
||||||
|
${currentUser.is_developer ? `
|
||||||
|
<div class="nav-dropdown">
|
||||||
|
<button class="nav-dropdown-btn">Инструменты ▼</button>
|
||||||
|
<div class="nav-dropdown-content">
|
||||||
|
<a href="/dev-tools" data-link>Разработка</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<a href="/favorites" data-link>Избранное</a>
|
||||||
|
<a href="/progress" data-link>Мой прогресс</a>
|
||||||
|
<a href="/course-builder" data-link>Конструктор курса</a>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="nav-auth">
|
||||||
|
<button id="auth-btn" class="auth-btn">Войти</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.insertBefore(nav, document.body.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNav() {
|
||||||
|
renderNav();
|
||||||
|
}
|
||||||
16
frontend/src/course-builder/Constants.js
Normal file
16
frontend/src/course-builder/Constants.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const stepTypeLabels = {
|
||||||
|
'theory': 'Теоретический материал',
|
||||||
|
'practice': 'Практическое задание',
|
||||||
|
'lab': 'Лабораторная работа',
|
||||||
|
'test': 'Тест'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStepTypeLabel(type) {
|
||||||
|
return stepTypeLabels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validationRules = {
|
||||||
|
courseTitle: { minLength: 3, maxLength: 255 },
|
||||||
|
moduleTitle: { maxLength: 255 },
|
||||||
|
maxModules: 50
|
||||||
|
};
|
||||||
25
frontend/src/course-builder/CourseUI.js
Normal file
25
frontend/src/course-builder/CourseUI.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export function showMessage(text, type) {
|
||||||
|
const messageEl = document.getElementById('builder-message');
|
||||||
|
if (!messageEl) {
|
||||||
|
console.warn('Message element not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messageEl.textContent = text;
|
||||||
|
messageEl.className = `message ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBuilderTitle(isEditing) {
|
||||||
|
const h2 = document.querySelector('.course-builder-container h2');
|
||||||
|
const subtitle = document.querySelector('.course-builder-container .subtitle');
|
||||||
|
const publishBtn = document.getElementById('publish-course');
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
if (h2) h2.textContent = 'Редактирование курса';
|
||||||
|
if (subtitle) subtitle.textContent = 'Редактируйте содержимое курса с помощью редактора Markdown';
|
||||||
|
if (publishBtn) publishBtn.textContent = 'Сохранить изменения';
|
||||||
|
} else {
|
||||||
|
if (h2) h2.textContent = 'Конструктор курса';
|
||||||
|
if (subtitle) subtitle.textContent = 'Создайте новый курс с помощью редактора Markdown с поддержкой LaTeX';
|
||||||
|
if (publishBtn) publishBtn.textContent = 'Опубликовать курс';
|
||||||
|
}
|
||||||
|
}
|
||||||
263
frontend/src/course-builder/DraftManager.js
Normal file
263
frontend/src/course-builder/DraftManager.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { validationRules } from './Constants.js';
|
||||||
|
import { getVditorInstance, currentCourseId, isEditMode, isDraftMode, courseStructure, setCurrentCourseId, setIsEditMode, setIsDraftMode, setPendingDraftContent } from './State.js';
|
||||||
|
import { showMessage } from './CourseUI.js';
|
||||||
|
import { router } from '../router.js';
|
||||||
|
|
||||||
|
export function saveDraft() {
|
||||||
|
const titleEl = document.getElementById('course-title');
|
||||||
|
const descriptionEl = document.getElementById('course-description');
|
||||||
|
const levelEl = document.getElementById('course-level');
|
||||||
|
|
||||||
|
if (!titleEl || !descriptionEl || !levelEl) return;
|
||||||
|
|
||||||
|
const title = titleEl.value;
|
||||||
|
const description = descriptionEl.value;
|
||||||
|
const level = levelEl.value;
|
||||||
|
const vditor = getVditorInstance();
|
||||||
|
const content = vditor ? vditor.getValue() : '';
|
||||||
|
|
||||||
|
if (!title.trim()) { showMessage('❌ Введите название курса', 'error'); return; }
|
||||||
|
|
||||||
|
const localDraft = { title, description, level, content, structure: courseStructure, savedAt: new Date().toISOString() };
|
||||||
|
localStorage.setItem('course_draft', JSON.stringify(localDraft));
|
||||||
|
showMessage('💾 Автосохранение...', 'info');
|
||||||
|
|
||||||
|
if (currentCourseId) {
|
||||||
|
const savePromise = isDraftMode
|
||||||
|
? coursesAPI.updateDraft(currentCourseId, { title, description, level, content })
|
||||||
|
: coursesAPI.updateCourse(currentCourseId, { title, description, level, content });
|
||||||
|
savePromise
|
||||||
|
.then(() => showMessage(`✅ Сохранено (${new Date().toLocaleTimeString()})`, 'success'))
|
||||||
|
.catch(error => { console.error('Failed to save:', error); showMessage('⚠️ Сохранено только локально', 'warning'); });
|
||||||
|
} else {
|
||||||
|
coursesAPI.createCourse({ title, description, level, content, structure: courseStructure })
|
||||||
|
.then(course => { setCurrentCourseId(course.id); showMessage(`✅ Черновик создан (${new Date().toLocaleTimeString()})`, 'success'); })
|
||||||
|
.catch(error => { console.error('Failed to create draft:', error); showMessage('❌ Ошибка при создании черновика', 'error'); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewCourse() {
|
||||||
|
const vditor = getVditorInstance();
|
||||||
|
if (vditor) vditor.setPreviewMode(vditor.vditor.preview.element.style.display === 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishCourse() {
|
||||||
|
const title = document.getElementById('course-title').value;
|
||||||
|
const vditor = getVditorInstance();
|
||||||
|
const content = vditor ? vditor.getValue() : '';
|
||||||
|
|
||||||
|
if (!title.trim()) { showMessage('❌ Введите название курса', 'error'); return; }
|
||||||
|
if (!content.trim()) { showMessage('❌ Добавьте содержание курса', 'error'); return; }
|
||||||
|
if (title.length < validationRules.courseTitle.minLength) { showMessage(`❌ Название слишком короткое (минимум ${validationRules.courseTitle.minLength} символа)`, 'error'); return; }
|
||||||
|
if (title.length > validationRules.courseTitle.maxLength) { showMessage(`❌ Название слишком длинное (максимум ${validationRules.courseTitle.maxLength} символов)`, 'error'); return; }
|
||||||
|
|
||||||
|
const validation = validateCourseStructure(courseStructure);
|
||||||
|
if (!validation.valid) { showMessage('❌ ' + validation.error, 'error'); return; }
|
||||||
|
|
||||||
|
showMessage('📤 Публикация курса...', 'info');
|
||||||
|
try {
|
||||||
|
let course;
|
||||||
|
if (currentCourseId) {
|
||||||
|
if (isDraftMode) {
|
||||||
|
course = await coursesAPI.publishDraft(currentCourseId);
|
||||||
|
showMessage('✅ Курс успешно опубликован!', 'success');
|
||||||
|
} else {
|
||||||
|
await coursesAPI.updateCourse(currentCourseId, { title, description: document.getElementById('course-description').value, level: document.getElementById('course-level').value, content });
|
||||||
|
course = { id: currentCourseId };
|
||||||
|
showMessage('✅ Курс успешно обновлен!', 'success');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
course = await coursesAPI.createCourse({ title, description: document.getElementById('course-description').value, level: document.getElementById('course-level').value, content, structure: courseStructure });
|
||||||
|
course = await coursesAPI.publishDraft(course.id);
|
||||||
|
showMessage('✅ Курс успешно создан и опубликован!', 'success');
|
||||||
|
}
|
||||||
|
router.navigate(`/courses/${course.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to publish course:', error);
|
||||||
|
showMessage('❌ Ошибка при публикации: ' + (error.data?.detail || 'Неизвестная ошибка'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCourseStructure(structure) {
|
||||||
|
if (!structure.modules || structure.modules.length === 0) return { valid: false, error: 'Добавьте хотя бы один модуль в курс' };
|
||||||
|
|
||||||
|
let totalLessons = 0;
|
||||||
|
for (const module of structure.modules) {
|
||||||
|
if (!module.title || module.title.trim().length === 0) return { valid: false, error: 'Все модули должны иметь название' };
|
||||||
|
if (module.title.length > validationRules.moduleTitle.maxLength) return { valid: false, error: `Название модуля слишком длинное (максимум ${validationRules.moduleTitle.maxLength} символов)` };
|
||||||
|
if (module.lessons && module.lessons.length > 0) {
|
||||||
|
totalLessons += module.lessons.length;
|
||||||
|
for (const lesson of module.lessons) {
|
||||||
|
if (!lesson.title || lesson.title.trim().length === 0) return { valid: false, error: 'Все уроки должны иметь название' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalLessons === 0) return { valid: false, error: 'Добавьте хотя бы один урок в любой модуль' };
|
||||||
|
if (structure.modules.length > validationRules.maxModules) return { valid: false, error: `Максимум ${validationRules.maxModules} модулей в курсе` };
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDraftsCount() {
|
||||||
|
try {
|
||||||
|
const drafts = await coursesAPI.getDrafts();
|
||||||
|
const countEl = document.getElementById('drafts-count');
|
||||||
|
if (countEl) countEl.textContent = drafts.length;
|
||||||
|
} catch (error) { console.error('Failed to load drafts count:', error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showDraftsModal() {
|
||||||
|
try {
|
||||||
|
const drafts = await coursesAPI.getDrafts();
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal-overlay" id="drafts-modal">
|
||||||
|
<div class="modal" style="max-width: 700px; max-height: 80vh; overflow-y: auto;">
|
||||||
|
<div class="modal-header"><h3>Мои черновики</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
${drafts.length === 0 ? '<p class="no-drafts">У вас пока нет черновиков</p>' : `
|
||||||
|
<div class="drafts-list">
|
||||||
|
${drafts.map(draft => `
|
||||||
|
<div class="draft-item" data-draft-id="${draft.id}">
|
||||||
|
<div class="draft-info">
|
||||||
|
<h4 class="draft-title">${draft.title}</h4>
|
||||||
|
<p class="draft-meta">Уровень: ${draft.level || 'Не указан'} | Обновлено: ${new Date(draft.updated_at).toLocaleDateString('ru-RU')}</p>
|
||||||
|
${draft.description ? `<p class="draft-desc">${draft.description}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="draft-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary load-draft" data-draft-id="${draft.id}">Загрузить</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline edit-draft" data-draft-id="${draft.id}">Редактировать</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary delete-draft" data-draft-id="${draft.id}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer"><button type="button" class="btn btn-secondary" id="close-drafts-modal">Закрыть</button></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = document.getElementById('drafts-modal');
|
||||||
|
|
||||||
|
document.getElementById('close-drafts-modal').addEventListener('click', () => modal.remove());
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||||
|
modal.querySelectorAll('.load-draft').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => loadDraftIntoEditor(parseInt(btn.dataset.draftId)).then(() => modal.remove()));
|
||||||
|
});
|
||||||
|
modal.querySelectorAll('.edit-draft').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const draftId = parseInt(btn.dataset.draftId);
|
||||||
|
modal.remove();
|
||||||
|
const newTitle = prompt('Введите новое название черновика:');
|
||||||
|
if (newTitle !== null) updateDraftTitle(draftId, newTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
modal.querySelectorAll('.delete-draft').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const draftId = parseInt(btn.dataset.draftId);
|
||||||
|
if (confirm('Удалить этот черновик?')) {
|
||||||
|
try {
|
||||||
|
await coursesAPI.deleteDraft(draftId);
|
||||||
|
modal.querySelector(`[data-draft-id="${draftId}"]`)?.remove();
|
||||||
|
updateDraftsCount();
|
||||||
|
showMessage('✅ Черновик удален', 'success');
|
||||||
|
if (modal.querySelectorAll('.draft-item').length === 0) modal.querySelector('.modal-body').innerHTML = '<p class="no-drafts">У вас пока нет черновиков</p>';
|
||||||
|
} catch (error) { console.error('Failed to delete draft:', error); showMessage('❌ Ошибка при удалении черновика', 'error'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) { console.error('Failed to load drafts:', error); showMessage('❌ Не удалось загрузить черновики', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDraftTitle(draftId, newTitle) {
|
||||||
|
try {
|
||||||
|
showMessage('⏳ Обновление названия...', 'info');
|
||||||
|
const draft = await coursesAPI.getDraft(draftId);
|
||||||
|
if (!draft) { showMessage('❌ Черновик не найден', 'error'); return; }
|
||||||
|
await coursesAPI.updateDraft(draftId, { title: newTitle, description: draft.description, level: draft.level, content: draft.content });
|
||||||
|
showMessage('✅ Название обновлено', 'success');
|
||||||
|
showDraftsModal();
|
||||||
|
} catch (error) { console.error('Failed to update draft:', error); showMessage('❌ Ошибка при обновлении', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDraftIntoEditor(draftId) {
|
||||||
|
try {
|
||||||
|
const draft = await coursesAPI.getDraft(draftId);
|
||||||
|
if (!draft) { showMessage('❌ Черновик не найден', 'error'); return; }
|
||||||
|
|
||||||
|
setCurrentCourseId(draft.id);
|
||||||
|
setIsEditMode(true);
|
||||||
|
setIsDraftMode(true);
|
||||||
|
|
||||||
|
document.getElementById('course-title').value = draft.title || '';
|
||||||
|
document.getElementById('course-description').value = draft.description || '';
|
||||||
|
document.getElementById('course-level').value = draft.level || 'beginner';
|
||||||
|
|
||||||
|
setPendingDraftContent(draft.content || '');
|
||||||
|
|
||||||
|
if (draft.modules) {
|
||||||
|
courseStructure.modules = draft.modules.map(module => ({
|
||||||
|
id: module.id,
|
||||||
|
title: module.title,
|
||||||
|
duration: module.duration,
|
||||||
|
lessons: module.lessons ? module.lessons.map(lesson => ({
|
||||||
|
id: lesson.id,
|
||||||
|
title: lesson.title,
|
||||||
|
steps: lesson.steps ? lesson.steps.map(step => {
|
||||||
|
console.log('Loading step:', step.id, 'title:', step.title, 'type:', step.step_type);
|
||||||
|
return {
|
||||||
|
id: step.id,
|
||||||
|
type: step.step_type,
|
||||||
|
title: step.title,
|
||||||
|
content: step.content,
|
||||||
|
order: step.order_index
|
||||||
|
};
|
||||||
|
}) : []
|
||||||
|
})) : []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { renderCourseStructure } = await import('./StructureManager.js');
|
||||||
|
renderCourseStructure();
|
||||||
|
|
||||||
|
showMessage(`✅ Черновик загружен: ${draft.title}`, 'success');
|
||||||
|
return draft;
|
||||||
|
} catch (error) { console.error('Failed to load draft:', error); showMessage('❌ Не удалось загрузить черновик', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStructureFromDraft() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const draftId = urlParams.get('draft');
|
||||||
|
if (draftId) {
|
||||||
|
loadDraftsFromServer().then(() => loadDraftIntoEditor(draftId));
|
||||||
|
} else {
|
||||||
|
const draft = localStorage.getItem('course_draft');
|
||||||
|
if (draft) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(draft);
|
||||||
|
if (data.structure && data.structure.modules) courseStructure.modules = data.structure.modules;
|
||||||
|
} catch (e) { console.error('Error loading structure from draft:', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDraftsFromServer() {
|
||||||
|
try {
|
||||||
|
const drafts = await coursesAPI.getDrafts();
|
||||||
|
localStorage.setItem('drafts', JSON.stringify(drafts));
|
||||||
|
} catch (error) { console.error('Failed to load drafts from server:', error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLastDraft() {
|
||||||
|
try {
|
||||||
|
const drafts = await coursesAPI.getDrafts();
|
||||||
|
if (drafts && drafts.length > 0) {
|
||||||
|
const lastDraft = drafts.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))[0];
|
||||||
|
return await loadDraftIntoEditor(lastDraft.id);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load last draft:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
frontend/src/course-builder/LessonManager.js
Normal file
158
frontend/src/course-builder/LessonManager.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { courseStructure } from './State.js';
|
||||||
|
import { saveDraft } from './DraftManager.js';
|
||||||
|
import { renderCourseStructure } from './StructureManager.js';
|
||||||
|
import { showMessage } from './CourseUI.js';
|
||||||
|
import { getStepTypeLabel } from './Constants.js';
|
||||||
|
|
||||||
|
export function showAddLessonModal(moduleIndex) {
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal-overlay" id="add-lesson-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header"><h3>Добавить урок</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lesson-title">Название урока *</label>
|
||||||
|
<input type="text" id="lesson-title" placeholder="Введите название урока" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancel-lesson">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-lesson">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = document.getElementById('add-lesson-modal');
|
||||||
|
|
||||||
|
document.getElementById('cancel-lesson').addEventListener('click', () => modal.remove());
|
||||||
|
document.getElementById('save-lesson').addEventListener('click', () => {
|
||||||
|
const title = document.getElementById('lesson-title').value.trim();
|
||||||
|
if (!title) { alert('Введите название урока'); return; }
|
||||||
|
addLesson(moduleIndex, title);
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showEditLessonModal(moduleIndex, lessonIndex) {
|
||||||
|
const lesson = courseStructure.modules[moduleIndex].lessons[lessonIndex];
|
||||||
|
console.log('showEditLessonModal - lesson:', lesson);
|
||||||
|
console.log('showEditLessonModal - steps:', lesson?.steps);
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal-overlay" id="edit-lesson-modal">
|
||||||
|
<div class="modal" style="max-width: 800px;">
|
||||||
|
<div class="modal-header"><h3>Редактировать урок: ${lesson.title || 'Без названия'}</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-lesson-title">Название урока *</label>
|
||||||
|
<input type="text" id="edit-lesson-title" value="${lesson.title || ''}" required>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-steps-section">
|
||||||
|
<h4>Шаги урока</h4>
|
||||||
|
<p class="section-description">Добавьте шаги с учебным контентом для этого урока</p>
|
||||||
|
<div class="steps-actions">
|
||||||
|
<button type="button" class="btn btn-outline" id="add-step-btn"><span class="btn-icon">+</span> Добавить шаг</button>
|
||||||
|
</div>
|
||||||
|
<div id="lesson-steps" class="lesson-steps">
|
||||||
|
${lesson.steps && lesson.steps.length > 0 ?
|
||||||
|
lesson.steps.map((step, stepIndex) => `
|
||||||
|
<div class="step-item" data-step-index="${stepIndex}">
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="step-title">
|
||||||
|
<span class="step-number">шаг${stepIndex + 1}</span>
|
||||||
|
<span class="step-name">"${step.title || 'Без названия'}"</span>
|
||||||
|
<span class="step-type">(${getStepTypeLabel(step.type)})</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-actions">
|
||||||
|
<button type="button" class="module-action-btn edit-step" data-step-index="${stepIndex}">✏️</button>
|
||||||
|
<button type="button" class="module-action-btn delete delete-step" data-step-index="${stepIndex}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') :
|
||||||
|
'<div class="empty-steps"><p>Пока нет шагов. Добавьте первый шаг.</p></div>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancel-edit-lesson">Закрыть</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-lesson-title">Сохранить название</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = document.getElementById('edit-lesson-modal');
|
||||||
|
|
||||||
|
document.getElementById('cancel-edit-lesson').addEventListener('click', () => modal.remove());
|
||||||
|
document.getElementById('save-lesson-title').addEventListener('click', () => {
|
||||||
|
const title = document.getElementById('edit-lesson-title').value.trim();
|
||||||
|
if (!title) { alert('Введите название урока'); return; }
|
||||||
|
updateLesson(moduleIndex, lessonIndex, title);
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
document.getElementById('add-step-btn').addEventListener('click', () => {
|
||||||
|
import('./StepManager.js').then(m => m.showAddStepModal(moduleIndex, lessonIndex));
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll('.edit-step').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
import('./StepManager.js').then(m => m.showEditStepModal(moduleIndex, lessonIndex, parseInt(button.dataset.stepIndex)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.delete-step').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const stepIndex = parseInt(button.dataset.stepIndex);
|
||||||
|
if (confirm('Удалить этот шаг?')) {
|
||||||
|
import('./StepManager.js').then(m => {
|
||||||
|
m.deleteStep(moduleIndex, lessonIndex, stepIndex);
|
||||||
|
modal.remove();
|
||||||
|
showEditLessonModal(moduleIndex, lessonIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addLesson(moduleIndex, title) {
|
||||||
|
const module = courseStructure.modules[moduleIndex];
|
||||||
|
if (!module.lessons) module.lessons = [];
|
||||||
|
const newLesson = { title, steps: [] };
|
||||||
|
module.lessons.push(newLesson);
|
||||||
|
renderCourseStructure();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lesson = await coursesAPI.createLesson({ module_id: module.id, title, order_index: module.lessons.length - 1 });
|
||||||
|
if (lesson?.id) newLesson.id = lesson.id;
|
||||||
|
await saveDraft();
|
||||||
|
} catch (error) { console.error('Failed to create lesson:', error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLesson(moduleIndex, lessonIndex, title) {
|
||||||
|
const lesson = courseStructure.modules[moduleIndex].lessons[lessonIndex];
|
||||||
|
lesson.title = title;
|
||||||
|
renderCourseStructure();
|
||||||
|
try {
|
||||||
|
if (lesson.id) await coursesAPI.updateLesson(lesson.id, { title });
|
||||||
|
await saveDraft();
|
||||||
|
} catch (error) { console.error('Failed to update lesson:', error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLesson(moduleIndex, lessonIndex) {
|
||||||
|
const lesson = courseStructure.modules[moduleIndex].lessons[lessonIndex];
|
||||||
|
try {
|
||||||
|
if (lesson.id) await coursesAPI.deleteLesson(lesson.id);
|
||||||
|
courseStructure.modules[moduleIndex].lessons.splice(lessonIndex, 1);
|
||||||
|
renderCourseStructure();
|
||||||
|
await saveDraft();
|
||||||
|
} catch (error) { console.error('Failed to delete lesson:', error); showMessage('❌ Ошибка при удалении урока', 'error'); }
|
||||||
|
}
|
||||||
115
frontend/src/course-builder/ModuleManager.js
Normal file
115
frontend/src/course-builder/ModuleManager.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { currentCourseId, courseStructure } from './State.js';
|
||||||
|
import { saveDraft } from './DraftManager.js';
|
||||||
|
import { renderCourseStructure } from './StructureManager.js';
|
||||||
|
import { showMessage } from './CourseUI.js';
|
||||||
|
|
||||||
|
export function showAddModuleModal() {
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal-overlay" id="add-module-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header"><h3>Добавить модуль</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="module-title">Название модуля *</label>
|
||||||
|
<input type="text" id="module-title" placeholder="Введите название модуля" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="module-duration">Длительность (опционально)</label>
|
||||||
|
<input type="text" id="module-duration" placeholder="Например: 2 недели, 10 часов">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancel-module">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-module">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = document.getElementById('add-module-modal');
|
||||||
|
|
||||||
|
document.getElementById('cancel-module').addEventListener('click', () => modal.remove());
|
||||||
|
document.getElementById('save-module').addEventListener('click', () => {
|
||||||
|
const title = document.getElementById('module-title').value.trim();
|
||||||
|
const duration = document.getElementById('module-duration').value.trim();
|
||||||
|
if (!title) { alert('Введите название модуля'); return; }
|
||||||
|
addModule(title, duration);
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showEditModuleModal(moduleIndex) {
|
||||||
|
const module = courseStructure.modules[moduleIndex];
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal-overlay" id="edit-module-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header"><h3>Редактировать модуль</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-module-title">Название модуля *</label>
|
||||||
|
<input type="text" id="edit-module-title" value="${module.title || ''}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-module-duration">Длительность (опционально)</label>
|
||||||
|
<input type="text" id="edit-module-duration" value="${module.duration || ''}" placeholder="Например: 2 недели, 10 часов">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancel-edit-module">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-edit-module">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = document.getElementById('edit-module-modal');
|
||||||
|
|
||||||
|
document.getElementById('cancel-edit-module').addEventListener('click', () => modal.remove());
|
||||||
|
document.getElementById('save-edit-module').addEventListener('click', () => {
|
||||||
|
const title = document.getElementById('edit-module-title').value.trim();
|
||||||
|
const duration = document.getElementById('edit-module-duration').value.trim();
|
||||||
|
if (!title) { alert('Введите название модуля'); return; }
|
||||||
|
updateModule(moduleIndex, title, duration);
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addModule(title, duration) {
|
||||||
|
const newModule = { title, duration: duration || null, lessons: [] };
|
||||||
|
courseStructure.modules.push(newModule);
|
||||||
|
renderCourseStructure();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const module = await coursesAPI.createModule({ course_id: currentCourseId, title, duration: duration || null, order_index: courseStructure.modules.length - 1 });
|
||||||
|
if (module?.id) newModule.id = module.id;
|
||||||
|
await saveDraft();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create module:', error);
|
||||||
|
showMessage('⚠️ Модуль сохранен только локально', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModule(moduleIndex, title, duration) {
|
||||||
|
const module = courseStructure.modules[moduleIndex];
|
||||||
|
module.title = title;
|
||||||
|
module.duration = duration || null;
|
||||||
|
renderCourseStructure();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (module.id) await coursesAPI.updateModule(module.id, { title, duration: duration || null });
|
||||||
|
await saveDraft();
|
||||||
|
} catch (error) { console.error('Failed to update module:', error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModule(moduleIndex) {
|
||||||
|
const module = courseStructure.modules[moduleIndex];
|
||||||
|
try {
|
||||||
|
if (module.id) await coursesAPI.deleteModule(module.id);
|
||||||
|
courseStructure.modules.splice(moduleIndex, 1);
|
||||||
|
renderCourseStructure();
|
||||||
|
await saveDraft();
|
||||||
|
} catch (error) { console.error('Failed to delete module:', error); showMessage('❌ Ошибка при удалении модуля', 'error'); }
|
||||||
|
}
|
||||||
64
frontend/src/course-builder/State.js
Normal file
64
frontend/src/course-builder/State.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
let vditorInstance = null;
|
||||||
|
let globalStepVditorInstance = null;
|
||||||
|
let currentCourseId = null;
|
||||||
|
let isEditMode = false;
|
||||||
|
let isDraftMode = false;
|
||||||
|
let pendingDraftContent = null;
|
||||||
|
|
||||||
|
const courseStructure = { modules: [] };
|
||||||
|
|
||||||
|
export function getState() {
|
||||||
|
return { vditorInstance, globalStepVditorInstance, currentCourseId, isEditMode, isDraftMode, courseStructure };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVditorInstance() { return vditorInstance; }
|
||||||
|
export function setVditorInstance(instance) {
|
||||||
|
vditorInstance = instance;
|
||||||
|
if (pendingDraftContent !== null && instance) {
|
||||||
|
trySetValue(instance, pendingDraftContent);
|
||||||
|
if (pendingDraftContent === null) {
|
||||||
|
// Content was set successfully
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trySetValue(instance, content, retries = 5) {
|
||||||
|
if (!instance) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (instance.vditor && instance.vditor.currentMode && instance.vditor.ir) {
|
||||||
|
instance.setValue(content);
|
||||||
|
pendingDraftContent = null;
|
||||||
|
} else if (retries > 0) {
|
||||||
|
setTimeout(() => trySetValue(instance, content, retries - 1), 100);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (retries > 0) {
|
||||||
|
setTimeout(() => trySetValue(instance, content, retries - 1), 100);
|
||||||
|
} else {
|
||||||
|
console.warn('Failed to set pending content after retries:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGlobalStepVditorInstance() { return globalStepVditorInstance; }
|
||||||
|
export function setGlobalStepVditorInstance(instance) { globalStepVditorInstance = instance; }
|
||||||
|
export function getCurrentCourseId() { return currentCourseId; }
|
||||||
|
export function setCurrentCourseId(id) { currentCourseId = id; }
|
||||||
|
export function getIsEditMode() { return isEditMode; }
|
||||||
|
export function setIsEditMode(value) { isEditMode = value; }
|
||||||
|
export function getIsDraftMode() { return isDraftMode; }
|
||||||
|
export function setIsDraftMode(value) { isDraftMode = value; }
|
||||||
|
export function setPendingDraftContent(content) {
|
||||||
|
if (vditorInstance && vditorInstance.vditor && vditorInstance.vditor.currentMode) {
|
||||||
|
try {
|
||||||
|
vditorInstance.setValue(content);
|
||||||
|
} catch (e) {
|
||||||
|
pendingDraftContent = content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pendingDraftContent = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { courseStructure, vditorInstance, globalStepVditorInstance, currentCourseId, isEditMode, isDraftMode };
|
||||||
400
frontend/src/course-builder/StepManager.js
Normal file
400
frontend/src/course-builder/StepManager.js
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import { courseStructure, globalStepVditorInstance, setGlobalStepVditorInstance } from './State.js';
|
||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { showMessage } from './CourseUI.js';
|
||||||
|
import { getStepTypeLabel } from './Constants.js';
|
||||||
|
import { getStepVditorConfig } from '../editors/vditorConfig.js';
|
||||||
|
import { TestEditor } from './TestEditor.js';
|
||||||
|
|
||||||
|
export function showAddStepModal(moduleIndex, lessonIndex) {
|
||||||
|
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal-overlay" id="add-step-modal">
|
||||||
|
<div class="modal" style="max-width: 700px;">
|
||||||
|
<div class="modal-header"><h3>Добавить шаг</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-type">Тип учебного контента *</label>
|
||||||
|
<select id="step-type" class="step-type-select">
|
||||||
|
<option value="theory">Теоретический материал</option>
|
||||||
|
<option value="presentation">Презентация (PDF)</option>
|
||||||
|
<option value="practice">Практическое задание</option>
|
||||||
|
<option value="lab">Лабораторная работа</option>
|
||||||
|
<option value="test">Тест</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="step-content-fields">
|
||||||
|
<div class="theory-content-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-title">Заголовок шага (опционально)</label>
|
||||||
|
<input type="text" id="step-title" placeholder="Введите заголовок шага">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-content">Содержание *</label>
|
||||||
|
<div id="step-vditor-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancel-step">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-step">Сохранить шаг</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = document.getElementById('add-step-modal');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof Vditor !== 'undefined') {
|
||||||
|
const instance = new Vditor('step-vditor-container', getStepVditorConfig());
|
||||||
|
setGlobalStepVditorInstance(instance);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
document.getElementById('step-type').addEventListener('change', (e) => updateStepContentFields(e.target.value));
|
||||||
|
|
||||||
|
document.getElementById('cancel-step').addEventListener('click', () => {
|
||||||
|
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-step').addEventListener('click', () => saveNewStep(moduleIndex, lessonIndex, modal));
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) { if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); } modal.remove(); }});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNewStep(moduleIndex, lessonIndex, modal) {
|
||||||
|
const type = document.getElementById('step-type').value;
|
||||||
|
const title = document.getElementById('step-title')?.value.trim() || '';
|
||||||
|
const content = type === 'theory' && globalStepVditorInstance ? globalStepVditorInstance.getValue() : '';
|
||||||
|
const fileInput = document.getElementById('step-pdf-input');
|
||||||
|
let fileUrl = null;
|
||||||
|
|
||||||
|
if (type === 'presentation') {
|
||||||
|
if (!fileInput || !fileInput.files[0]) {
|
||||||
|
alert('Загрузите PDF файл для презентации');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
const response = await coursesAPI.uploadFile(formData);
|
||||||
|
fileUrl = response.url;
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка загрузки файла: ' + error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (type === 'theory' && !content.trim()) {
|
||||||
|
alert('Введите содержание теоретического материала');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addStep(moduleIndex, lessonIndex, {
|
||||||
|
type,
|
||||||
|
title: title || null,
|
||||||
|
content,
|
||||||
|
file_url: fileUrl,
|
||||||
|
order: courseStructure.modules[moduleIndex].lessons[lessonIndex].steps?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.remove();
|
||||||
|
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
|
||||||
|
|
||||||
|
const lessonModal = document.getElementById('edit-lesson-modal');
|
||||||
|
if (lessonModal) {
|
||||||
|
lessonModal.remove();
|
||||||
|
import('./LessonManager.js').then(m => m.showEditLessonModal(moduleIndex, lessonIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showEditStepModal(moduleIndex, lessonIndex, stepIndex) {
|
||||||
|
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
|
||||||
|
const step = courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex];
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal-overlay" id="edit-step-modal">
|
||||||
|
<div class="modal" style="max-width: 700px;">
|
||||||
|
<div class="modal-header"><h3>Редактировать шаг: ${getStepTypeLabel(step.type)}</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-step-type">Тип учебного контента *</label>
|
||||||
|
<select id="edit-step-type" class="step-type-select" disabled>
|
||||||
|
<option value="theory" ${step.type === 'theory' ? 'selected' : ''}>Теоретический материал</option>
|
||||||
|
<option value="practice" ${step.type === 'practice' ? 'selected' : ''}>Практическое задание</option>
|
||||||
|
<option value="lab" ${step.type === 'lab' ? 'selected' : ''}>Лабораторная работа</option>
|
||||||
|
<option value="test" ${step.type === 'test' ? 'selected' : ''}>Тест</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="edit-step-content-fields">
|
||||||
|
<div class="theory-content-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-step-title">Заголовок шага (опционально)</label>
|
||||||
|
<input type="text" id="edit-step-title" value="${step.title || ''}" placeholder="Введите заголовок шага">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-step-content">Содержание *</label>
|
||||||
|
<div id="edit-step-vditor-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancel-edit-step">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-edit-step">Сохранить изменения</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = document.getElementById('edit-step-modal');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof Vditor !== 'undefined') {
|
||||||
|
const instance = new Vditor('edit-step-vditor-container', { ...getStepVditorConfig(), value: '' });
|
||||||
|
setGlobalStepVditorInstance(instance);
|
||||||
|
setTimeout(() => { if (instance && step.content) instance.setValue(step.content); }, 200);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
document.getElementById('cancel-edit-step').addEventListener('click', () => {
|
||||||
|
if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); }
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-edit-step').addEventListener('click', () => {
|
||||||
|
const title = document.getElementById('edit-step-title')?.value.trim() || '';
|
||||||
|
const content = globalStepVditorInstance ? globalStepVditorInstance.getValue() : '';
|
||||||
|
|
||||||
|
if (step.type === 'theory' && !content.trim()) { alert('Введите содержание теоретического материала'); return; }
|
||||||
|
|
||||||
|
updateStep(moduleIndex, lessonIndex, stepIndex, { ...step, title: title || null, content });
|
||||||
|
modal.remove();
|
||||||
|
|
||||||
|
const lessonModal = document.getElementById('edit-lesson-modal');
|
||||||
|
if (lessonModal) {
|
||||||
|
lessonModal.remove();
|
||||||
|
import('./LessonManager.js').then(m => m.showEditLessonModal(moduleIndex, lessonIndex));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) { if (globalStepVditorInstance) { globalStepVditorInstance.setValue(''); setGlobalStepVditorInstance(null); } modal.remove(); }});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStepContentFields(stepType) {
|
||||||
|
const contentFields = document.getElementById('step-content-fields');
|
||||||
|
|
||||||
|
if (stepType === 'theory') {
|
||||||
|
contentFields.innerHTML = `
|
||||||
|
<div class="theory-content-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-title">Заголовок шага (опционально)</label>
|
||||||
|
<input type="text" id="step-title" placeholder="Введите заголовок шага">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-content">Содержание *</label>
|
||||||
|
<div id="step-vditor-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
setTimeout(() => { if (typeof Vditor !== 'undefined') new Vditor('step-vditor-container', getStepVditorConfig()); }, 50);
|
||||||
|
} else if (stepType === 'presentation') {
|
||||||
|
contentFields.innerHTML = `
|
||||||
|
<div class="presentation-content-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-title">Заголовок шага (опционально)</label>
|
||||||
|
<input type="text" id="step-title" placeholder="Введите заголовок презентации">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-pdf-input">PDF файл презентации *</label>
|
||||||
|
<div class="file-upload-area" id="pdf-drop-zone">
|
||||||
|
<div class="file-upload-content">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="14,2 14,8 20,8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="12" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="9,15 12,12 15,15" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<p>Перетащите PDF файл сюда или</p>
|
||||||
|
<input type="file" id="step-pdf-input" accept=".pdf" style="display: none;">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="document.getElementById('step-pdf-input').click()">Выбрать файл</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-preview" id="pdf-file-preview" style="display: none;">
|
||||||
|
<span class="file-name"></span>
|
||||||
|
<button type="button" class="btn-icon remove-file" onclick="clearPdfFile()">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="help-text">Поддерживаются файлы в формате PDF. Максимальный размер: 50 МБ.</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
initPdfDropZone();
|
||||||
|
} else if (stepType === 'test') {
|
||||||
|
contentFields.innerHTML = `
|
||||||
|
<div class="test-content-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-title">Заголовок шага (опционально)</label>
|
||||||
|
<input type="text" id="step-title" placeholder="Введите заголовок теста">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Тест будет создан после сохранения шага</label>
|
||||||
|
<p class="content-note">После создания шага вы сможете добавить вопросы и настроить тест.</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
contentFields.innerHTML = `
|
||||||
|
<div class="other-content-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-title">Заголовок шага (опционально)</label>
|
||||||
|
<input type="text" id="step-title" placeholder="Введите заголовок шага">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Тип контента: ${getStepTypeLabel(stepType)}</label>
|
||||||
|
<p class="content-note">Редактирование этого типа контента будет доступно в будущих версиях.</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPdfDropZone() {
|
||||||
|
const dropZone = document.getElementById('pdf-drop-zone');
|
||||||
|
const fileInput = document.getElementById('step-pdf-input');
|
||||||
|
|
||||||
|
if (!dropZone || !fileInput) return;
|
||||||
|
|
||||||
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function preventDefaults(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, () => dropZone.classList.add('highlight'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, () => dropZone.classList.remove('highlight'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0 && files[0].type === 'application/pdf') {
|
||||||
|
handlePdfFile(files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
handlePdfFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePdfFile(file) {
|
||||||
|
if (file.type !== 'application/pdf') {
|
||||||
|
alert('Пожалуйста, выберите файл в формате PDF');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
alert('Размер файла не должен превышать 50 МБ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = document.getElementById('pdf-file-preview');
|
||||||
|
const fileName = preview.querySelector('.file-name');
|
||||||
|
const uploadContent = document.querySelector('.file-upload-content');
|
||||||
|
|
||||||
|
if (preview && fileName && uploadContent) {
|
||||||
|
fileName.textContent = file.name;
|
||||||
|
preview.style.display = 'flex';
|
||||||
|
uploadContent.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearPdfFile = function() {
|
||||||
|
const fileInput = document.getElementById('step-pdf-input');
|
||||||
|
const preview = document.getElementById('pdf-file-preview');
|
||||||
|
const uploadContent = document.querySelector('.file-upload-content');
|
||||||
|
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
if (preview) preview.style.display = 'none';
|
||||||
|
if (uploadContent) uploadContent.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function addStep(moduleIndex, lessonIndex, stepData) {
|
||||||
|
if (!courseStructure.modules[moduleIndex].lessons[lessonIndex].steps) {
|
||||||
|
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lesson = courseStructure.modules[moduleIndex].lessons[lessonIndex];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (lesson.id) {
|
||||||
|
const createdStep = await coursesAPI.createStep({
|
||||||
|
lesson_id: lesson.id,
|
||||||
|
type: stepData.type,
|
||||||
|
title: stepData.title,
|
||||||
|
content: stepData.content,
|
||||||
|
order: stepData.order
|
||||||
|
});
|
||||||
|
|
||||||
|
const newStep = {
|
||||||
|
id: createdStep?.id,
|
||||||
|
type: stepData.type,
|
||||||
|
title: stepData.title,
|
||||||
|
content: stepData.content,
|
||||||
|
order: stepData.order
|
||||||
|
};
|
||||||
|
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.push(newStep);
|
||||||
|
showMessage('✅ Шаг создан', 'success');
|
||||||
|
} else {
|
||||||
|
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.push(stepData);
|
||||||
|
showMessage('⚠️ Шаг сохранён локально (урок не синхронизирован)', 'warning');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create step:', error);
|
||||||
|
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.push(stepData);
|
||||||
|
showMessage('❌ Ошибка при создании шага', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStep(moduleIndex, lessonIndex, stepIndex, stepData) {
|
||||||
|
const step = courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (step.id) {
|
||||||
|
await coursesAPI.updateStep(step.id, {
|
||||||
|
type: stepData.type,
|
||||||
|
title: stepData.title,
|
||||||
|
content: stepData.content,
|
||||||
|
order: stepData.order
|
||||||
|
});
|
||||||
|
showMessage('✅ Шаг обновлён', 'success');
|
||||||
|
}
|
||||||
|
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex] = stepData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update step:', error);
|
||||||
|
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex] = stepData;
|
||||||
|
showMessage('❌ Ошибка при обновлении шага', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteStep(moduleIndex, lessonIndex, stepIndex) {
|
||||||
|
const step = courseStructure.modules[moduleIndex].lessons[lessonIndex].steps[stepIndex];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (step?.id) {
|
||||||
|
await coursesAPI.deleteStep(step.id);
|
||||||
|
showMessage('✅ Шаг удалён', 'success');
|
||||||
|
}
|
||||||
|
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.splice(stepIndex, 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete step:', error);
|
||||||
|
courseStructure.modules[moduleIndex].lessons[lessonIndex].steps.splice(stepIndex, 1);
|
||||||
|
showMessage('❌ Ошибка при удалении шага', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
128
frontend/src/course-builder/StructureEvents.js
Normal file
128
frontend/src/course-builder/StructureEvents.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { courseStructure } from './State.js';
|
||||||
|
import { saveDraft } from './DraftManager.js';
|
||||||
|
import { showMessage } from './CourseUI.js';
|
||||||
|
import { renderCourseStructure } from './StructureManager.js';
|
||||||
|
|
||||||
|
export function addStructureEventListeners() {
|
||||||
|
// Add lesson buttons
|
||||||
|
document.querySelectorAll('.add-lesson-btn, .module-action-btn.add-lesson').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const moduleIndex = parseInt(button.dataset.moduleIndex);
|
||||||
|
import('./LessonManager.js').then(m => m.showAddLessonModal(moduleIndex));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit module buttons
|
||||||
|
document.querySelectorAll('.edit-module').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const moduleIndex = parseInt(button.dataset.moduleIndex);
|
||||||
|
import('./ModuleManager.js').then(m => m.showEditModuleModal(moduleIndex));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete module buttons
|
||||||
|
document.querySelectorAll('.delete-module').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const moduleIndex = parseInt(button.dataset.moduleIndex);
|
||||||
|
if (confirm('Удалить этот модуль и все его уроки?')) {
|
||||||
|
import('./ModuleManager.js').then(m => m.deleteModule(moduleIndex));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit lesson buttons - navigate to lesson editor
|
||||||
|
document.querySelectorAll('.edit-lesson').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const moduleIndex = parseInt(button.dataset.moduleIndex);
|
||||||
|
const lessonIndex = parseInt(button.dataset.lessonIndex);
|
||||||
|
const lesson = courseStructure.modules[moduleIndex]?.lessons?.[lessonIndex];
|
||||||
|
|
||||||
|
if (lesson && lesson.id) {
|
||||||
|
// Navigate to lesson editor page
|
||||||
|
window.location.href = `/course-builder/lesson/${lesson.id}?moduleIndex=${moduleIndex}&lessonIndex=${lessonIndex}`;
|
||||||
|
} else {
|
||||||
|
showMessage('❌ Сначала сохраните урок', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete lesson buttons
|
||||||
|
document.querySelectorAll('.delete-lesson').forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const moduleIndex = parseInt(button.dataset.moduleIndex);
|
||||||
|
const lessonIndex = parseInt(button.dataset.lessonIndex);
|
||||||
|
if (confirm('Удалить этот урок?')) {
|
||||||
|
import('./LessonManager.js').then(m => m.deleteLesson(moduleIndex, lessonIndex));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Module header click to toggle
|
||||||
|
document.querySelectorAll('.module-header').forEach(header => {
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
const moduleContent = header.nextElementSibling;
|
||||||
|
moduleContent.style.display = moduleContent.style.display === 'none' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop for lessons
|
||||||
|
setupDragAndDrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDragAndDrop() {
|
||||||
|
document.querySelectorAll('.lesson-item.draggable').forEach(lesson => {
|
||||||
|
lesson.addEventListener('dragstart', (e) => {
|
||||||
|
lesson.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', JSON.stringify({ moduleIndex: lesson.dataset.moduleIndex, lessonIndex: lesson.dataset.lessonIndex }));
|
||||||
|
});
|
||||||
|
|
||||||
|
lesson.addEventListener('dragend', () => {
|
||||||
|
lesson.classList.remove('dragging');
|
||||||
|
document.querySelectorAll('.lesson-item.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
});
|
||||||
|
|
||||||
|
lesson.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
lesson.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
lesson.addEventListener('dragleave', () => lesson.classList.remove('drag-over'));
|
||||||
|
|
||||||
|
lesson.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
lesson.classList.remove('drag-over');
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
||||||
|
const sourceModuleIndex = parseInt(data.moduleIndex);
|
||||||
|
const sourceLessonIndex = parseInt(data.lessonIndex);
|
||||||
|
const targetModuleIndex = parseInt(lesson.dataset.moduleIndex);
|
||||||
|
const targetLessonIndex = parseInt(lesson.dataset.lessonIndex);
|
||||||
|
if (sourceModuleIndex === targetModuleIndex && sourceLessonIndex === targetLessonIndex) return;
|
||||||
|
moveLesson(sourceModuleIndex, sourceLessonIndex, targetModuleIndex, targetLessonIndex);
|
||||||
|
} catch (err) { console.error('Error handling drop:', err); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveLesson(sourceModuleIndex, sourceLessonIndex, targetModuleIndex, targetLessonIndex) {
|
||||||
|
const sourceModule = courseStructure.modules[sourceModuleIndex];
|
||||||
|
const targetModule = courseStructure.modules[targetModuleIndex];
|
||||||
|
if (!sourceModule || !targetModule || !sourceModule.lessons || !targetModule.lessons) return;
|
||||||
|
|
||||||
|
const [lesson] = sourceModule.lessons.splice(sourceLessonIndex, 1);
|
||||||
|
if (!lesson) return;
|
||||||
|
|
||||||
|
if (targetLessonIndex === targetModule.lessons.length) targetModule.lessons.push(lesson);
|
||||||
|
else targetModule.lessons.splice(targetLessonIndex, 0, lesson);
|
||||||
|
|
||||||
|
renderCourseStructure();
|
||||||
|
saveDraft();
|
||||||
|
showMessage(sourceModule.id === targetModule.id ? '✅ Порядок уроков обновлен' : '✅ Урок перемещён между модулями', 'success');
|
||||||
|
}
|
||||||
136
frontend/src/course-builder/StructureManager.js
Normal file
136
frontend/src/course-builder/StructureManager.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { currentCourseId, isEditMode, isDraftMode, courseStructure, setCurrentCourseId, setIsEditMode, setIsDraftMode, setPendingDraftContent } from './State.js';
|
||||||
|
import { showMessage, updateBuilderTitle } from './CourseUI.js';
|
||||||
|
import { addStructureEventListeners } from './StructureEvents.js';
|
||||||
|
import { loadLastDraft } from './DraftManager.js';
|
||||||
|
|
||||||
|
export async function initCourseStructure() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const courseId = urlParams.get('course_id');
|
||||||
|
|
||||||
|
if (courseId) {
|
||||||
|
await loadCourseForEditing(courseId);
|
||||||
|
renderCourseStructure();
|
||||||
|
} else {
|
||||||
|
const lastDraft = await loadLastDraft();
|
||||||
|
if (lastDraft) {
|
||||||
|
// renderCourseStructure already called in loadDraftIntoEditor
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const course = await coursesAPI.createCourse({
|
||||||
|
title: 'Черновик курса', description: '', level: 'beginner', duration: 1,
|
||||||
|
content: '# Заголовок курса\n\nСоздайте ваш курс здесь...', structure: { modules: courseStructure.modules }
|
||||||
|
});
|
||||||
|
setCurrentCourseId(course.id);
|
||||||
|
setIsDraftMode(true);
|
||||||
|
showMessage('✅ Создан новый черновик', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create initial draft:', error);
|
||||||
|
loadStructureFromLocalStorage();
|
||||||
|
}
|
||||||
|
renderCourseStructure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('add-module-btn').addEventListener('click', () => import('./ModuleManager.js').then(m => m.showAddModuleModal()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCourseForEditing(courseId) {
|
||||||
|
try {
|
||||||
|
const course = await coursesAPI.getCourseStructure(courseId);
|
||||||
|
if (!course) { showMessage('❌ Курс не найден', 'error'); return; }
|
||||||
|
|
||||||
|
setCurrentCourseId(courseId);
|
||||||
|
setIsEditMode(true);
|
||||||
|
setIsDraftMode(false);
|
||||||
|
updateBuilderTitle(true);
|
||||||
|
|
||||||
|
document.getElementById('course-title').value = course.title || '';
|
||||||
|
document.getElementById('course-description').value = course.description || '';
|
||||||
|
document.getElementById('course-level').value = course.level || 'beginner';
|
||||||
|
|
||||||
|
setPendingDraftContent(course.content || '');
|
||||||
|
|
||||||
|
if (course.modules) {
|
||||||
|
courseStructure.modules = course.modules.map(module => ({
|
||||||
|
id: module.id,
|
||||||
|
title: module.title,
|
||||||
|
duration: module.duration,
|
||||||
|
lessons: module.lessons ? module.lessons.map(lesson => ({
|
||||||
|
id: lesson.id,
|
||||||
|
title: lesson.title,
|
||||||
|
steps: lesson.steps ? lesson.steps.map(step => ({
|
||||||
|
id: step.id,
|
||||||
|
type: step.step_type,
|
||||||
|
title: step.title,
|
||||||
|
content: step.content,
|
||||||
|
order: step.order_index
|
||||||
|
})) : []
|
||||||
|
})) : []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
showMessage(`✅ Загружен курс "${course.title}"`, 'success');
|
||||||
|
} catch (error) { console.error('Failed to load course:', error); showMessage('❌ Не удалось загрузить курс для редактирования', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCourseStructure() {
|
||||||
|
const container = document.getElementById('course-structure');
|
||||||
|
|
||||||
|
if (courseStructure.modules.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-structure"><p>Пока нет модулей. Добавьте первый модуль, чтобы начать.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
courseStructure.modules.forEach((module, moduleIndex) => {
|
||||||
|
html += `
|
||||||
|
<div class="module-item" data-module-index="${moduleIndex}">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title">
|
||||||
|
<span class="module-icon">📚</span>
|
||||||
|
<span>${module.title || 'Без названия'}</span>
|
||||||
|
${module.duration ? `<span class="module-duration">(${module.duration})</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="module-actions">
|
||||||
|
<button type="button" class="module-action-btn add-lesson" data-module-index="${moduleIndex}">+ Урок</button>
|
||||||
|
<button type="button" class="module-action-btn edit-module" data-module-index="${moduleIndex}">✏️</button>
|
||||||
|
<button type="button" class="module-action-btn delete delete-module" data-module-index="${moduleIndex}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-content">
|
||||||
|
<div class="lessons-list">
|
||||||
|
${module.lessons && module.lessons.length > 0 ?
|
||||||
|
module.lessons.map((lesson, lessonIndex) => `
|
||||||
|
<div class="lesson-item draggable" draggable="true" data-module-index="${moduleIndex}" data-lesson-index="${lessonIndex}">
|
||||||
|
<div class="lesson-info">
|
||||||
|
<div class="drag-handle" title="Перетащите для изменения порядка">⋮⋮</div>
|
||||||
|
<div class="lesson-number">${lessonIndex + 1}</div>
|
||||||
|
<div class="lesson-title">${lesson.title || 'Без названия'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-actions">
|
||||||
|
<button type="button" class="module-action-btn edit-lesson" data-module-index="${moduleIndex}" data-lesson-index="${lessonIndex}">Редактировать</button>
|
||||||
|
<button type="button" class="module-action-btn delete delete-lesson" data-module-index="${moduleIndex}" data-lesson-index="${lessonIndex}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') :
|
||||||
|
'<p class="no-lessons">Пока нет уроков в этом модуле.</p>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="add-lesson-btn" data-module-index="${moduleIndex}"><span>+</span> Добавить урок</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
addStructureEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStructureFromLocalStorage() {
|
||||||
|
const draft = localStorage.getItem('course_draft');
|
||||||
|
if (draft) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(draft);
|
||||||
|
if (data.structure && data.structure.modules) courseStructure.modules = data.structure.modules;
|
||||||
|
} catch (e) { console.error('Error loading structure from draft:', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
462
frontend/src/course-builder/TestEditor.js
Normal file
462
frontend/src/course-builder/TestEditor.js
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
|
||||||
|
export class TestEditor {
|
||||||
|
constructor(stepId, testData = null) {
|
||||||
|
this.stepId = stepId;
|
||||||
|
this.questions = testData?.questions || [];
|
||||||
|
this.settings = testData?.settings || {
|
||||||
|
passing_score: 70,
|
||||||
|
max_attempts: 0,
|
||||||
|
time_limit: 0,
|
||||||
|
show_answers: false
|
||||||
|
};
|
||||||
|
this.container = null;
|
||||||
|
this.currentQuestionIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadQuestions() {
|
||||||
|
try {
|
||||||
|
console.log('[TestEditor] Loading questions for step:', this.stepId);
|
||||||
|
const response = await coursesAPI.getTestQuestions(this.stepId);
|
||||||
|
console.log('[TestEditor] API response:', response);
|
||||||
|
if (response) {
|
||||||
|
this.questions = response.questions || [];
|
||||||
|
this.settings = response.settings || this.settings;
|
||||||
|
console.log('[TestEditor] Loaded questions:', this.questions.length);
|
||||||
|
console.log('[TestEditor] Settings:', this.settings);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TestEditor] Failed to load questions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(containerElement) {
|
||||||
|
if (containerElement) {
|
||||||
|
this.container = containerElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.container) {
|
||||||
|
console.warn('[TestEditor] No container element');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[TestEditor] Rendering with questions:', this.questions.length);
|
||||||
|
const html = this.getHTML();
|
||||||
|
this.container.innerHTML = html;
|
||||||
|
console.log('[TestEditor] HTML rendered, attaching listeners...');
|
||||||
|
this.attachEventListeners();
|
||||||
|
console.log('[TestEditor] Listeners attached');
|
||||||
|
|
||||||
|
return this.container.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHTML() {
|
||||||
|
return `
|
||||||
|
<div class="test-editor">
|
||||||
|
<div class="test-settings">
|
||||||
|
<h4>Настройки теста</h4>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="passing-score">Проходной балл (%)</label>
|
||||||
|
<input type="number" id="passing-score" class="form-control"
|
||||||
|
value="${this.settings.passing_score}" min="0" max="100">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="max-attempts">Макс. попыток (0 = без ограничений)</label>
|
||||||
|
<input type="number" id="max-attempts" class="form-control"
|
||||||
|
value="${this.settings.max_attempts}" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="time-limit">Ограничение по времени (мин, 0 = без ограничений)</label>
|
||||||
|
<input type="number" id="time-limit" class="form-control"
|
||||||
|
value="${this.settings.time_limit}" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="show-answers" ${this.settings.show_answers ? 'checked' : ''}>
|
||||||
|
Показывать правильные ответы после завершения
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="save-settings-btn">
|
||||||
|
Сохранить настройки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-questions">
|
||||||
|
<div class="questions-header">
|
||||||
|
<h4>Вопросы теста (${this.questions.length})</h4>
|
||||||
|
<div class="add-question-buttons">
|
||||||
|
<button class="btn btn-sm btn-outline" data-type="single_choice">✓ Один вариант</button>
|
||||||
|
<button class="btn btn-sm btn-outline" data-type="multiple_choice">☑ Несколько вариантов</button>
|
||||||
|
<button class="btn btn-sm btn-outline" data-type="text_answer">✎ Текстовый ответ</button>
|
||||||
|
<button class="btn btn-sm btn-outline" data-type="match">⇄ Соответствие</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="questions-list" id="questions-list">
|
||||||
|
${this.renderQuestionsList()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question-editor-panel" id="question-editor-panel" style="display: ${this.currentQuestionIndex >= 0 ? 'block' : 'none'};">
|
||||||
|
${this.currentQuestionIndex >= 0 ? this.renderQuestionEditor() : '<p>Выберите вопрос для редактирования</p>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEventListeners() {
|
||||||
|
if (!this.container) {
|
||||||
|
console.warn('[TestEditor] No container in attachEventListeners');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[TestEditor] Attaching event listeners...');
|
||||||
|
|
||||||
|
// Save settings button
|
||||||
|
const saveBtn = this.container.querySelector('#save-settings-btn');
|
||||||
|
if (saveBtn) {
|
||||||
|
console.log('[TestEditor] Found save settings button');
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
console.log('[TestEditor] Save settings clicked');
|
||||||
|
this.saveSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add question buttons
|
||||||
|
const addButtons = this.container.querySelectorAll('.add-question-buttons button');
|
||||||
|
console.log('[TestEditor] Found', addButtons.length, 'add question buttons');
|
||||||
|
addButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
console.log('[TestEditor] Add question clicked:', btn.dataset.type);
|
||||||
|
this.addQuestion(btn.dataset.type);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Question items
|
||||||
|
this.container.querySelectorAll('.question-item').forEach((item, index) => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.classList.contains('delete-question')) {
|
||||||
|
this.selectQuestion(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
item.querySelector('.delete-question')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.deleteQuestion(index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Question editor buttons
|
||||||
|
if (this.currentQuestionIndex >= 0) {
|
||||||
|
this.container.querySelector('#save-question-btn')?.addEventListener('click', () => this.saveCurrentQuestion());
|
||||||
|
this.container.querySelector('#add-answer-btn')?.addEventListener('click', () => this.addAnswer());
|
||||||
|
this.container.querySelector('#add-keyword-btn')?.addEventListener('click', () => this.addKeyword());
|
||||||
|
this.container.querySelector('#add-match-btn')?.addEventListener('click', () => this.addMatchItem());
|
||||||
|
|
||||||
|
// Answer delete buttons
|
||||||
|
this.container.querySelectorAll('.remove-answer').forEach((btn, idx) => {
|
||||||
|
btn.addEventListener('click', () => this.removeAnswer(idx));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.container.querySelectorAll('.remove-keyword').forEach((btn, idx) => {
|
||||||
|
btn.addEventListener('click', () => this.removeKeyword(idx));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.container.querySelectorAll('.remove-match').forEach((btn, idx) => {
|
||||||
|
btn.addEventListener('click', () => this.removeMatchItem(idx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderQuestionsList() {
|
||||||
|
if (this.questions.length === 0) {
|
||||||
|
return '<p class="no-questions">Нет вопросов. Нажмите кнопку выше, чтобы добавить первый вопрос.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.questions.map((q, index) => `
|
||||||
|
<div class="question-item ${index === this.currentQuestionIndex ? 'active' : ''}" data-index="${index}">
|
||||||
|
<span class="question-number">${index + 1}</span>
|
||||||
|
<span class="question-type-badge ${q.question_type}">${this.getQuestionTypeLabel(q.question_type)}</span>
|
||||||
|
<span class="question-text">${(q.question_text || 'Новый вопрос').substring(0, 50)}${(q.question_text || '').length > 50 ? '...' : ''}</span>
|
||||||
|
<span class="question-points">${q.points || 1} балл(ов)</span>
|
||||||
|
<button class="btn-icon delete-question" title="Удалить">×</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderQuestionEditor() {
|
||||||
|
const question = this.questions[this.currentQuestionIndex];
|
||||||
|
if (!question) return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="editor-header">
|
||||||
|
<h4>Редактирование вопроса ${this.currentQuestionIndex + 1}</h4>
|
||||||
|
<button class="btn btn-sm btn-primary" id="save-question-btn">Сохранить вопрос</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Текст вопроса</label>
|
||||||
|
<textarea id="question-text" class="form-control" rows="3">${question.question_text || ''}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Баллы за правильный ответ</label>
|
||||||
|
<input type="number" id="question-points" class="form-control" value="${question.points || 1}" min="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.renderQuestionTypeEditor(question)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderQuestionTypeEditor(question) {
|
||||||
|
switch (question.question_type) {
|
||||||
|
case 'single_choice':
|
||||||
|
return this.renderSingleChoiceEditor(question);
|
||||||
|
case 'multiple_choice':
|
||||||
|
return this.renderMultipleChoiceEditor(question);
|
||||||
|
case 'text_answer':
|
||||||
|
return this.renderTextAnswerEditor(question);
|
||||||
|
case 'match':
|
||||||
|
return this.renderMatchEditor(question);
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSingleChoiceEditor(question) {
|
||||||
|
return `
|
||||||
|
<div class="answers-editor">
|
||||||
|
<label>Варианты ответов (отметьте правильный)</label>
|
||||||
|
<div class="answers-list">
|
||||||
|
${(question.answers || []).map((a, idx) => `
|
||||||
|
<div class="answer-item" data-index="${idx}">
|
||||||
|
<input type="radio" name="correct-answer" value="${idx}" ${a.is_correct ? 'checked' : ''}>
|
||||||
|
<input type="text" class="form-control answer-text" value="${a.answer_text || ''}">
|
||||||
|
<button class="btn-icon remove-answer">×</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline" id="add-answer-btn">+ Добавить вариант</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMultipleChoiceEditor(question) {
|
||||||
|
return `
|
||||||
|
<div class="answers-editor">
|
||||||
|
<label>Варианты ответов (отметьте правильные)</label>
|
||||||
|
<div class="answers-list">
|
||||||
|
${(question.answers || []).map((a, idx) => `
|
||||||
|
<div class="answer-item" data-index="${idx}">
|
||||||
|
<input type="checkbox" class="correct-answer" ${a.is_correct ? 'checked' : ''}>
|
||||||
|
<input type="text" class="form-control answer-text" value="${a.answer_text || ''}">
|
||||||
|
<button class="btn-icon remove-answer">×</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline" id="add-answer-btn">+ Добавить вариант</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTextAnswerEditor(question) {
|
||||||
|
return `
|
||||||
|
<div class="text-answer-editor">
|
||||||
|
<label>Ключевые слова для проверки ответа</label>
|
||||||
|
<p class="help-text">Введите ключевые слова или фразы, которые должны содержаться в ответе студента</p>
|
||||||
|
<div class="keywords-list">
|
||||||
|
${(question.correct_answers || []).map((answer, idx) => `
|
||||||
|
<div class="keyword-item" data-index="${idx}">
|
||||||
|
<input type="text" class="form-control keyword-text" value="${answer}">
|
||||||
|
<button class="btn-icon remove-keyword">×</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline" id="add-keyword-btn">+ Добавить ключевое слово</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMatchEditor(question) {
|
||||||
|
return `
|
||||||
|
<div class="match-editor">
|
||||||
|
<label>Пары для сопоставления</label>
|
||||||
|
<div class="match-list">
|
||||||
|
${(question.match_items || []).map((item, idx) => `
|
||||||
|
<div class="match-item" data-index="${idx}">
|
||||||
|
<input type="text" class="form-control match-left" placeholder="Левая часть" value="${item.left_text || ''}">
|
||||||
|
<span class="match-arrow">→</span>
|
||||||
|
<input type="text" class="form-control match-right" placeholder="Правая часть" value="${item.right_text || ''}">
|
||||||
|
<button class="btn-icon remove-match">×</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline" id="add-match-btn">+ Добавить пару</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getQuestionTypeLabel(type) {
|
||||||
|
const labels = {
|
||||||
|
single_choice: 'Один вариант',
|
||||||
|
multiple_choice: 'Несколько',
|
||||||
|
text_answer: 'Текст',
|
||||||
|
match: 'Соответствие'
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings() {
|
||||||
|
const settings = {
|
||||||
|
passing_score: parseInt(document.getElementById('passing-score').value) || 70,
|
||||||
|
max_attempts: parseInt(document.getElementById('max-attempts').value) || 0,
|
||||||
|
time_limit: parseInt(document.getElementById('time-limit').value) || 0,
|
||||||
|
show_answers: document.getElementById('show-answers').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await coursesAPI.updateTestSettings(this.stepId, settings);
|
||||||
|
this.settings = settings;
|
||||||
|
alert('Настройки сохранены');
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка сохранения настроек: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addQuestion(type) {
|
||||||
|
const question = {
|
||||||
|
question_text: '',
|
||||||
|
question_type: type,
|
||||||
|
points: 1,
|
||||||
|
order_index: this.questions.length,
|
||||||
|
answers: type === 'single_choice' || type === 'multiple_choice' ? [
|
||||||
|
{ answer_text: '', is_correct: false, order_index: 0 },
|
||||||
|
{ answer_text: '', is_correct: false, order_index: 1 }
|
||||||
|
] : [],
|
||||||
|
match_items: type === 'match' ? [
|
||||||
|
{ left_text: '', right_text: '', order_index: 0 }
|
||||||
|
] : [],
|
||||||
|
correct_answers: type === 'text_answer' ? [] : []
|
||||||
|
};
|
||||||
|
|
||||||
|
this.questions.push(question);
|
||||||
|
this.currentQuestionIndex = this.questions.length - 1;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectQuestion(index) {
|
||||||
|
this.currentQuestionIndex = index;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteQuestion(index) {
|
||||||
|
if (!confirm('Удалить этот вопрос?')) return;
|
||||||
|
|
||||||
|
const question = this.questions[index];
|
||||||
|
|
||||||
|
if (question.id) {
|
||||||
|
try {
|
||||||
|
await coursesAPI.deleteTestQuestion(this.stepId, question.id);
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка удаления вопроса: ' + error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.questions.splice(index, 1);
|
||||||
|
|
||||||
|
if (this.currentQuestionIndex >= this.questions.length) {
|
||||||
|
this.currentQuestionIndex = this.questions.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
addAnswer() {
|
||||||
|
const question = this.questions[this.currentQuestionIndex];
|
||||||
|
if (!question.answers) question.answers = [];
|
||||||
|
question.answers.push({ answer_text: '', is_correct: false, order_index: question.answers.length });
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAnswer(index) {
|
||||||
|
const question = this.questions[this.currentQuestionIndex];
|
||||||
|
question.answers.splice(index, 1);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
addKeyword() {
|
||||||
|
const question = this.questions[this.currentQuestionIndex];
|
||||||
|
if (!question.correct_answers) question.correct_answers = [];
|
||||||
|
question.correct_answers.push('');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeKeyword(index) {
|
||||||
|
const question = this.questions[this.currentQuestionIndex];
|
||||||
|
question.correct_answers.splice(index, 1);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
addMatchItem() {
|
||||||
|
const question = this.questions[this.currentQuestionIndex];
|
||||||
|
if (!question.match_items) question.match_items = [];
|
||||||
|
question.match_items.push({ left_text: '', right_text: '', order_index: question.match_items.length });
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMatchItem(index) {
|
||||||
|
const question = this.questions[this.currentQuestionIndex];
|
||||||
|
question.match_items.splice(index, 1);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCurrentQuestion() {
|
||||||
|
const question = this.questions[this.currentQuestionIndex];
|
||||||
|
if (!question) return;
|
||||||
|
|
||||||
|
question.question_text = document.getElementById('question-text').value;
|
||||||
|
question.points = parseInt(document.getElementById('question-points').value) || 1;
|
||||||
|
|
||||||
|
// Collect answers based on question type
|
||||||
|
if (question.question_type === 'single_choice') {
|
||||||
|
const correctRadio = this.container.querySelector('input[name="correct-answer"]:checked');
|
||||||
|
const correctIndex = correctRadio ? parseInt(correctRadio.value) : -1;
|
||||||
|
|
||||||
|
question.answers = Array.from(this.container.querySelectorAll('.answer-item')).map((item, idx) => ({
|
||||||
|
answer_text: item.querySelector('.answer-text').value,
|
||||||
|
is_correct: idx === correctIndex,
|
||||||
|
order_index: idx
|
||||||
|
}));
|
||||||
|
} else if (question.question_type === 'multiple_choice') {
|
||||||
|
question.answers = Array.from(this.container.querySelectorAll('.answer-item')).map((item, idx) => ({
|
||||||
|
answer_text: item.querySelector('.answer-text').value,
|
||||||
|
is_correct: item.querySelector('.correct-answer').checked,
|
||||||
|
order_index: idx
|
||||||
|
}));
|
||||||
|
} else if (question.question_type === 'text_answer') {
|
||||||
|
question.correct_answers = Array.from(this.container.querySelectorAll('.keyword-text')).map(input => input.value);
|
||||||
|
} else if (question.question_type === 'match') {
|
||||||
|
question.match_items = Array.from(this.container.querySelectorAll('.match-item')).map((item, idx) => ({
|
||||||
|
left_text: item.querySelector('.match-left').value,
|
||||||
|
right_text: item.querySelector('.match-right').value,
|
||||||
|
order_index: idx
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (question.id) {
|
||||||
|
await coursesAPI.updateTestQuestion(this.stepId, question.id, question);
|
||||||
|
} else {
|
||||||
|
const created = await coursesAPI.createTestQuestion(this.stepId, question);
|
||||||
|
if (created) question.id = created.id;
|
||||||
|
}
|
||||||
|
alert('Вопрос сохранен');
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка сохранения вопроса: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/src/course-builder/index.js
Normal file
9
frontend/src/course-builder/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Course Builder - Main export file
|
||||||
|
export { courseStructure, currentCourseId, isEditMode, isDraftMode } from './State.js';
|
||||||
|
export { saveDraft, previewCourse, publishCourse, validateCourseStructure, updateDraftsCount, showDraftsModal, loadDraftIntoEditor, loadLastDraft } from './DraftManager.js';
|
||||||
|
export { initCourseStructure, loadCourseForEditing, renderCourseStructure } from './StructureManager.js';
|
||||||
|
export { showAddModuleModal, showEditModuleModal, addModule, updateModule, deleteModule } from './ModuleManager.js';
|
||||||
|
export { showAddLessonModal, showEditLessonModal, addLesson, updateLesson, deleteLesson } from './LessonManager.js';
|
||||||
|
export { showAddStepModal, showEditStepModal, addStep, updateStep, deleteStep } from './StepManager.js';
|
||||||
|
export { showMessage, updateBuilderTitle } from './CourseUI.js';
|
||||||
|
export { getStepTypeLabel, validationRules } from './Constants.js';
|
||||||
69
frontend/src/editors/vditorConfig.js
Normal file
69
frontend/src/editors/vditorConfig.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export const tooltipMap = {
|
||||||
|
'Emoji': 'Эмодзи', 'Headings': 'Заголовки', 'Bold': 'Жирный', 'Italic': 'Курсив',
|
||||||
|
'Strike': 'Зачеркнутый', 'Link': 'Ссылка', 'Unordered List': 'Маркированный список',
|
||||||
|
'Ordered List': 'Нумерованный список', 'Checklist': 'Список задач', 'Outdent': 'Уменьшить отступ',
|
||||||
|
'Indent': 'Увеличить отступ', 'Quote': 'Цитата', 'Line': 'Разделитель', 'Code Block': 'Блок кода',
|
||||||
|
'Inline Code': 'Встроенный код', 'Insert After': 'Вставить строку после', 'Insert Before': 'Вставить строку перед',
|
||||||
|
'Upload': 'Загрузить', 'Table': 'Таблица', 'Undo': 'Отменить', 'Redo': 'Повторить',
|
||||||
|
'Edit Mode': 'Режим редактирования', 'Both': 'Редактирование и просмотр', 'Preview': 'Только просмотр',
|
||||||
|
'Fullscreen': 'Полный экран', 'Outline': 'Оглавление', 'Code Theme': 'Тема кода',
|
||||||
|
'Content Theme': 'Тема контента', 'Export': 'Экспорт', 'Help': 'Помощь', 'More': 'Еще',
|
||||||
|
'Close': 'Закрыть', 'Confirm': 'Подтвердить', 'Cancel': 'Отмена', 'Insert': 'Вставить',
|
||||||
|
'Delete': 'Удалить', 'Edit': 'Редактировать', 'Save': 'Сохранить', 'Loading': 'Загрузка',
|
||||||
|
'Success': 'Успешно', 'Error': 'Ошибка', 'Warning': 'Предупреждение', 'Info': 'Информация'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVditorConfig = (initialContent = '') => ({
|
||||||
|
height: 500,
|
||||||
|
placeholder: 'Начните писать содержание курса здесь...',
|
||||||
|
theme: 'classic',
|
||||||
|
icon: 'material',
|
||||||
|
typewriterMode: true,
|
||||||
|
lang: 'ru_RU',
|
||||||
|
toolbar: ['emoji', 'headings', 'bold', 'italic', 'strike', 'link', '|', 'list', 'ordered-list',
|
||||||
|
'check', 'outdent', 'indent', '|', 'quote', 'line', 'code', 'inline-code', 'insert-before',
|
||||||
|
'insert-after', '|', 'upload', 'table', '|', 'undo', 'redo', '|', 'edit-mode', 'both',
|
||||||
|
'preview', 'fullscreen', 'outline', 'code-theme', 'content-theme', 'export', 'help'],
|
||||||
|
toolbarConfig: { pin: true },
|
||||||
|
cache: { enable: false },
|
||||||
|
preview: {
|
||||||
|
delay: 500,
|
||||||
|
hljs: { enable: true, style: 'github', lineNumber: true },
|
||||||
|
math: { engine: 'KaTeX', inlineDigit: true },
|
||||||
|
markdown: { toc: true, mark: true, footnotes: true, autoSpace: true }
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
emojiPath: 'https://unpkg.com/vditor@3.10.4/dist/images/emoji',
|
||||||
|
emojiTail: '<a href="https://ld246.com/settings/function" target="_blank">Настроить эмодзи</a>',
|
||||||
|
emoji: { '+1': '👍', '-1': '👎', 'heart': '❤️', 'smile': '😊', 'fire': '🔥', 'star': '⭐' }
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
accept: 'image/*,.pdf,.doc,.docx,.txt',
|
||||||
|
handler(files) {
|
||||||
|
console.log('Files to upload:', files);
|
||||||
|
return Promise.resolve(['https://example.com/upload/demo.jpg']);
|
||||||
|
},
|
||||||
|
filename(name) {
|
||||||
|
return name.replace(/[^(a-zA-Z0-9\u4e00-\u9fa5\.)]/g, '')
|
||||||
|
.replace(/[\?\\/:|<>\*\[\]\(\)\$%\{\}@~]/g, '').replace('/\\s/g', '');
|
||||||
|
},
|
||||||
|
msg: 'Перетащите файлы сюда или нажмите для выбора',
|
||||||
|
error(msg) { alert('Ошибка загрузки: ' + msg); },
|
||||||
|
success(fileName) { console.log('Файл загружен:', fileName); }
|
||||||
|
},
|
||||||
|
value: initialContent
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getStepVditorConfig = (initialContent = '') => ({
|
||||||
|
height: 300,
|
||||||
|
placeholder: 'Введите теоретический материал здесь...',
|
||||||
|
theme: 'classic',
|
||||||
|
icon: 'material',
|
||||||
|
lang: 'ru_RU',
|
||||||
|
toolbar: ['emoji', 'headings', 'bold', 'italic', 'strike', 'link', '|', 'list', 'ordered-list',
|
||||||
|
'check', 'outdent', 'indent', '|', 'quote', 'line', 'code', 'inline-code', '|', 'upload',
|
||||||
|
'table', '|', 'undo', 'redo', '|', 'edit-mode', 'both', 'preview', 'fullscreen', 'outline',
|
||||||
|
'code-theme', 'content-theme', 'export', 'help'],
|
||||||
|
preview: { hljs: { enable: true, style: 'github', lineNumber: true }, math: { engine: 'KaTeX', inlineDigit: true } },
|
||||||
|
value: initialContent
|
||||||
|
});
|
||||||
51
frontend/src/editors/vditorLoader.js
Normal file
51
frontend/src/editors/vditorLoader.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { getVditorConfig, getStepVditorConfig } from './vditorConfig.js';
|
||||||
|
import { applyRussianLabels, addRussianUILabels, monitorAndUpdateRussianContent } from './vditorLocalization.js';
|
||||||
|
import { setVditorInstance, setGlobalStepVditorInstance, setPendingDraftContent } from '../course-builder/State.js';
|
||||||
|
|
||||||
|
let vditorInstance = null;
|
||||||
|
let globalStepVditorInstance = null;
|
||||||
|
|
||||||
|
export function loadVditor() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof Vditor !== 'undefined') { resolve(); return; }
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://unpkg.com/vditor@3.10.4/dist/index.min.js';
|
||||||
|
script.onload = resolve;
|
||||||
|
script.onerror = reject;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initVditorEditor(containerId, initialContent = '') {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) { console.error(`Container #${containerId} not found`); return null; }
|
||||||
|
|
||||||
|
const config = getVditorConfig(initialContent);
|
||||||
|
config.after = () => {
|
||||||
|
console.log('Vditor initialized with Russian locale');
|
||||||
|
vditorInstance = window.vditor || vditorInstance;
|
||||||
|
setVditorInstance(vditorInstance);
|
||||||
|
setTimeout(() => {
|
||||||
|
applyRussianLabels();
|
||||||
|
addRussianUILabels();
|
||||||
|
monitorAndUpdateRussianContent();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
vditorInstance = new Vditor(containerId, config);
|
||||||
|
return vditorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initStepVditorEditor(containerId, initialContent = '') {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) { console.error(`Container #${containerId} not found`); return null; }
|
||||||
|
|
||||||
|
const config = getStepVditorConfig(initialContent);
|
||||||
|
config.after = () => {
|
||||||
|
setGlobalStepVditorInstance(globalStepVditorInstance);
|
||||||
|
};
|
||||||
|
globalStepVditorInstance = new Vditor(containerId, config);
|
||||||
|
return globalStepVditorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { vditorInstance, globalStepVditorInstance };
|
||||||
95
frontend/src/editors/vditorLocalization.js
Normal file
95
frontend/src/editors/vditorLocalization.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { tooltipMap } from './vditorConfig.js';
|
||||||
|
|
||||||
|
export function applyRussianLabels() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const toolbar = document.querySelector('.vditor-toolbar');
|
||||||
|
if (!toolbar) return;
|
||||||
|
|
||||||
|
toolbar.querySelectorAll('[aria-label]').forEach(button => {
|
||||||
|
const originalLabel = button.getAttribute('aria-label');
|
||||||
|
if (tooltipMap[originalLabel]) {
|
||||||
|
button.setAttribute('aria-label', tooltipMap[originalLabel]);
|
||||||
|
button.setAttribute('title', tooltipMap[originalLabel]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const modeButtons = toolbar.querySelectorAll('.vditor-toolbar__item[data-type="edit-mode"]');
|
||||||
|
modeButtons.forEach(button => {
|
||||||
|
const span = button.querySelector('span');
|
||||||
|
if (span) {
|
||||||
|
span.textContent = span.textContent.replace('Edit', 'Редакт.')
|
||||||
|
.replace('Preview', 'Просмотр').replace('Both', 'Оба');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateDropdownMenus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDropdownMenus() {
|
||||||
|
const menus = [
|
||||||
|
{ selector: '.vditor-menu--headings', map: { 'Heading': 'Заголовок', 'Paragraph': 'Параграф' } },
|
||||||
|
{ selector: '.vditor-menu--code-theme', map: { 'Light': 'Светлая', 'Dark': 'Темная' } },
|
||||||
|
{ selector: '.vditor-menu--content-theme', map: { 'Light': 'Светлая', 'Dark': 'Темная' } },
|
||||||
|
{ selector: '.vditor-menu--export', map: { 'HTML': 'HTML', 'PDF': 'PDF', 'Markdown': 'Markdown' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
menus.forEach(({ selector, map }) => {
|
||||||
|
const menu = document.querySelector(selector);
|
||||||
|
if (menu) {
|
||||||
|
menu.querySelectorAll('.vditor-menu__item').forEach(item => {
|
||||||
|
Object.entries(map).forEach(([en, ru]) => {
|
||||||
|
if (item.textContent.includes(en)) item.textContent = item.textContent.replace(en, ru);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addRussianUILabels() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const dialogs = [
|
||||||
|
{ selector: '.vditor-dialog--link', labelMap: { 'Text': 'Текст ссылки:', 'Link': 'URL ссылки:', 'Title': 'Заголовок (опционально):' }, btnMap: { 'Insert': 'Вставить', 'Cancel': 'Отмена' } },
|
||||||
|
{ selector: '.vditor-dialog--table', labelMap: { 'Rows': 'Строки:', 'Columns': 'Столбцы:' }, btnMap: { 'Insert': 'Вставить', 'Cancel': 'Отмена' } },
|
||||||
|
{ selector: '.vditor-dialog--upload', btnMap: { 'Upload': 'Загрузить', 'Cancel': 'Отмена', 'Browse': 'Выбрать файл' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
dialogs.forEach(({ selector, labelMap = {}, btnMap = {} }) => {
|
||||||
|
const dialog = document.querySelector(selector);
|
||||||
|
if (dialog) {
|
||||||
|
dialog.querySelectorAll('label').forEach(label => {
|
||||||
|
Object.entries(labelMap).forEach(([en, ru]) => {
|
||||||
|
if (label.textContent.includes(en)) label.textContent = ru;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dialog.querySelectorAll('button').forEach(button => {
|
||||||
|
Object.entries(btnMap).forEach(([en, ru]) => {
|
||||||
|
if (button.textContent.includes(en)) button.textContent = ru;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const helpDialog = document.querySelector('.vditor-dialog--help');
|
||||||
|
if (helpDialog) {
|
||||||
|
const title = helpDialog.querySelector('.vditor-dialog__title');
|
||||||
|
if (title && title.textContent.includes('Help')) title.textContent = 'Помощь по Markdown';
|
||||||
|
}
|
||||||
|
updateContextMenu();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateContextMenu() {
|
||||||
|
const contextMenu = document.querySelector('.vditor-contextmenu');
|
||||||
|
if (!contextMenu) return;
|
||||||
|
|
||||||
|
const map = { 'Cut': 'Вырезать', 'Copy': 'Копировать', 'Paste': 'Вставить', 'Select All': 'Выделить все', 'Delete': 'Удалить' };
|
||||||
|
contextMenu.querySelectorAll('.vditor-contextmenu__item').forEach(item => {
|
||||||
|
Object.entries(map).forEach(([en, ru]) => {
|
||||||
|
if (item.textContent.includes(en)) item.textContent = ru;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monitorAndUpdateRussianContent() {
|
||||||
|
setInterval(() => { updateDropdownMenus(); updateContextMenu(); applyRussianLabels(); }, 1000);
|
||||||
|
}
|
||||||
22
frontend/src/main.js
Normal file
22
frontend/src/main.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { router } from './router.js';
|
||||||
|
import { initAuth } from './components/auth.js';
|
||||||
|
import { renderNav } from './components/nav.js';
|
||||||
|
import { API_BASE_URL } from './utils/config.js';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
console.log('App starting...');
|
||||||
|
|
||||||
|
// Render navigation first (with default state for non-authenticated user)
|
||||||
|
renderNav();
|
||||||
|
|
||||||
|
// Initialize authentication (will update navigation if user is authenticated)
|
||||||
|
await initAuth();
|
||||||
|
|
||||||
|
// Initialize router
|
||||||
|
router.init();
|
||||||
|
|
||||||
|
// Handle initial route
|
||||||
|
router.handleRoute();
|
||||||
|
|
||||||
|
console.log('App started');
|
||||||
|
});
|
||||||
83
frontend/src/router.js
Normal file
83
frontend/src/router.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
export const router = {
|
||||||
|
routes: {
|
||||||
|
'/': 'home',
|
||||||
|
'/courses': 'courses',
|
||||||
|
'/courses/:id': 'courseDetail',
|
||||||
|
'/courses/:id/view': 'courseViewer',
|
||||||
|
'/course-builder': 'courseBuilder',
|
||||||
|
'/course-builder/lesson/:lessonId': 'lessonEditor',
|
||||||
|
'/favorites': 'favorites',
|
||||||
|
'/progress': 'progress',
|
||||||
|
'/login': 'login',
|
||||||
|
'/dev-tools': 'devTools'
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
window.addEventListener('popstate', () => this.handleRoute());
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.matches('[data-link]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const href = e.target.getAttribute('href');
|
||||||
|
console.log('Data-link clicked:', href);
|
||||||
|
this.navigate(href);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
navigate(path) {
|
||||||
|
console.log('Navigating to:', path);
|
||||||
|
history.pushState(null, null, path);
|
||||||
|
this.handleRoute();
|
||||||
|
},
|
||||||
|
|
||||||
|
handleRoute() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
console.log('Handling route:', path);
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
|
||||||
|
// Parse route params
|
||||||
|
let matchedRoute = null;
|
||||||
|
let params = {};
|
||||||
|
|
||||||
|
for (const route in this.routes) {
|
||||||
|
const routePattern = route.replace(/:\w+/g, '([^/]+)');
|
||||||
|
const regex = new RegExp(`^${routePattern}$`);
|
||||||
|
const match = path.match(regex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
matchedRoute = route;
|
||||||
|
const paramNames = [...route.matchAll(/:(\w+)/g)].map(match => match[1]);
|
||||||
|
paramNames.forEach((name, index) => {
|
||||||
|
params[name] = match[index + 1];
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedRoute) {
|
||||||
|
console.log('No route matched, showing 404');
|
||||||
|
app.innerHTML = '<h1>404 - Page Not Found</h1>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewName = this.routes[matchedRoute];
|
||||||
|
console.log('Loading view:', viewName, 'with params:', params);
|
||||||
|
this.loadView(viewName, params, app);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadView(viewName, params, container) {
|
||||||
|
try {
|
||||||
|
console.log(`Importing view module: ./views/${viewName}.js`);
|
||||||
|
const module = await import(`./views/${viewName}.js`);
|
||||||
|
console.log(`Rendering view: ${viewName}`);
|
||||||
|
container.innerHTML = await module.render(params);
|
||||||
|
if (module.afterRender) {
|
||||||
|
console.log(`Calling afterRender for: ${viewName}`);
|
||||||
|
module.afterRender(params);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load view ${viewName}:`, error);
|
||||||
|
container.innerHTML = '<h1>Error loading page</h1>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
558
frontend/src/styles/course-viewer.css
Normal file
558
frontend/src/styles/course-viewer.css
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
.course-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e1e5eb;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left .back-link {
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left .back-link:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left .course-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-spacer {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-tab:hover {
|
||||||
|
background: #e4e6eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-tab.active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-tab .tab-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structure-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e1e5eb;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e1e5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structure-tree {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-module {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-module .module-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-module .module-header:hover {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-module.active > .module-header {
|
||||||
|
background: #e8f0fe;
|
||||||
|
color: #1a73e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-duration {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-lessons {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-lesson {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-lesson:hover {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-lesson.active {
|
||||||
|
background: #e8f0fe;
|
||||||
|
border-left-color: #1a73e8;
|
||||||
|
color: #1a73e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-lessons {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px 48px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #e1e5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-sep {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-badge.step-type-theory {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-badge.step-type-practice {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #ef6c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-badge.step-type-lab {
|
||||||
|
background: #f3e5f5;
|
||||||
|
color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-badge.step-type-test {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-badge.step-type-presentation {
|
||||||
|
background: #e8eaf6;
|
||||||
|
color: #3949ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin: 24px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body p {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body ul, .step-body ol {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body code {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body pre {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body blockquote {
|
||||||
|
border-left: 4px solid #2563eb;
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body th, .step-body td {
|
||||||
|
border: 1px solid #e1e5eb;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body th {
|
||||||
|
background: #f5f7fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-navigation-bottom {
|
||||||
|
padding: 16px 48px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e1e5eb;
|
||||||
|
margin: 0 16px 16px;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:not(:disabled):hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:disabled {
|
||||||
|
background: #e1e5eb;
|
||||||
|
color: #999;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-counter {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-content h2 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid #e1e5eb;
|
||||||
|
border-top-color: #2563eb;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page {
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page h1 {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-learning-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.viewer-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structure-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
padding: 16px;
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-navigation-bottom {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-presentation {
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-presentation .step-header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-presentation .step-body {
|
||||||
|
padding: 0;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-viewer-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 500px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-viewer.presentation-fullscreen {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-viewer.presentation-fullscreen .content-area {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-viewer.presentation-fullscreen .step-content {
|
||||||
|
height: calc(100vh - 70px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-btn:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
1154
frontend/src/styles/landing.css
Normal file
1154
frontend/src/styles/landing.css
Normal file
File diff suppressed because it is too large
Load Diff
630
frontend/src/styles/lesson-editor.css
Normal file
630
frontend/src/styles/lesson-editor.css
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
.lesson-editor-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e1e5eb;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-title-input input {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: transparent;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-title-input input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-bottom-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.dirty {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-editor-page .editor-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20% 80%;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-sidebar {
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e1e5eb;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e1e5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:hover {
|
||||||
|
background: #e8f0fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.active {
|
||||||
|
background: #e8f0fe;
|
||||||
|
border-left: 3px solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-drag-handle {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-actions {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:hover .step-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-steps {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 16px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-steps {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-main {
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e1e5eb;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-editor {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #999;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-editor-content {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-control {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#vditor-container {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area {
|
||||||
|
border: 2px dashed #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area:hover,
|
||||||
|
.file-upload-area.dragover {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-prompt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-prompt svg {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-prompt p {
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-preview-note {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-preview-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-preview-container .file-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-preview-container .file-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-preview-hint {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: fadeIn 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 900px;
|
||||||
|
animation: slideUp 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #e1e5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid #e1e5eb;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #e1e5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-type {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #e8f0fe;
|
||||||
|
color: #6366f1;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body {
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body h1,
|
||||||
|
.preview-step-body h2,
|
||||||
|
.preview-step-body h3,
|
||||||
|
.preview-step-body h4,
|
||||||
|
.preview-step-body h5,
|
||||||
|
.preview-step-body h6 {
|
||||||
|
margin: 24px 0 12px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body h1 { font-size: 28px; }
|
||||||
|
.preview-step-body h2 { font-size: 24px; }
|
||||||
|
.preview-step-body h3 { font-size: 20px; }
|
||||||
|
.preview-step-body h4 { font-size: 18px; }
|
||||||
|
|
||||||
|
.preview-step-body p {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body ul,
|
||||||
|
.preview-step-body ol {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding-left: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body li {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body code {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body pre {
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body blockquote {
|
||||||
|
border-left: 4px solid #6366f1;
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body th,
|
||||||
|
.preview-step-body td {
|
||||||
|
border: 1px solid #e1e5eb;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body th {
|
||||||
|
background: #f8fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body a {
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body h1,
|
||||||
|
.preview-step-body h2,
|
||||||
|
.preview-step-body h3 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body ul,
|
||||||
|
.preview-step-body ol {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body code {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-step-body pre {
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.editor-body {
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.editor-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: -280px;
|
||||||
|
top: 60px;
|
||||||
|
width: 280px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
z-index: 1000;
|
||||||
|
transition: left 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-title-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview-btn {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
188
frontend/src/styles/pdf-viewer.css
Normal file
188
frontend/src/styles/pdf-viewer.css
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
.pdf-viewer {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-viewer.fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 9999;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 3px solid #e0e0e0;
|
||||||
|
border-top-color: #6366f1;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pdf-spin 1s linear infinite;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pdf-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-controls {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-nav, .pdf-zoom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-btn:hover {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-info {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-zoom-level {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-fullscreen-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pdf-canvas {
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-error-content {
|
||||||
|
text-align: center;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-error-content p {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Presentation mode styles */
|
||||||
|
.step-type-presentation .step-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-type-presentation .pdf-viewer {
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pdf-controls {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-info {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-canvas-container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
265
frontend/src/styles/test-editor.css
Normal file
265
frontend/src/styles/test-editor.css
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/* Test Editor Styles */
|
||||||
|
.test-editor {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-settings {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-settings h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-questions {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questions-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questions-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-question-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questions-list {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-questions {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-item.active {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-left: 3px solid #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-number {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-type-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-type-badge.single_choice {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-type-badge.multiple_choice {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #f57c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-type-badge.text_answer {
|
||||||
|
background: #f3e5f5;
|
||||||
|
color: #7b1fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-type-badge.match {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
flex: 1;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-points {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #999;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Question Editor Panel */
|
||||||
|
.question-editor-panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Answer Editor Styles */
|
||||||
|
.answers-editor,
|
||||||
|
.text-answer-editor,
|
||||||
|
.match-editor {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answers-editor label,
|
||||||
|
.text-answer-editor label,
|
||||||
|
.match-editor label {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answers-list,
|
||||||
|
.keywords-list,
|
||||||
|
.match-list {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-item,
|
||||||
|
.keyword-item,
|
||||||
|
.match-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-item input[type="radio"],
|
||||||
|
.answer-item input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-text,
|
||||||
|
.keyword-text,
|
||||||
|
.match-left,
|
||||||
|
.match-right {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-arrow {
|
||||||
|
color: #666;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.questions-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-question-buttons {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
width: 100%;
|
||||||
|
order: 5;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-item,
|
||||||
|
.keyword-item,
|
||||||
|
.match-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
306
frontend/src/styles/test-viewer.css
Normal file
306
frontend/src/styles/test-viewer.css
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
/* Test Viewer Styles */
|
||||||
|
.test-viewer {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-counter {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-timer {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-timer.warning {
|
||||||
|
color: #f44336;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-progress {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #2196f3, #21cbf3);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-container {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-points {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-answers {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Answer Options */
|
||||||
|
.answers-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-option:hover {
|
||||||
|
border-color: #2196f3;
|
||||||
|
background: #f5f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-option.selected {
|
||||||
|
border-color: #2196f3;
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-option input[type="radio"],
|
||||||
|
.answer-option input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Answer */
|
||||||
|
.text-answer textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-answer textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match Question */
|
||||||
|
.match-question {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-column {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item span {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.test-navigation {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:hover {
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.active {
|
||||||
|
background: #2196f3;
|
||||||
|
border-color: #2196f3;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.answered {
|
||||||
|
background: #4caf50;
|
||||||
|
border-color: #4caf50;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results */
|
||||||
|
.test-results {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header.passed h2 {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header.failed h2 {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-circle {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 24px auto;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header.passed .score-circle {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #4caf50;
|
||||||
|
border: 4px solid #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header.failed .score-circle {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #f44336;
|
||||||
|
border: 4px solid #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-details {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-details p {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempts-info {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error State */
|
||||||
|
.test-error {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
93
frontend/src/utils/api.js
Normal file
93
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
export const API_BASE_URL = window.location.origin;
|
||||||
|
|
||||||
|
export async function apiRequest(endpoint, options = {}) {
|
||||||
|
const url = endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`;
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
console.log(`apiRequest to ${url}, token exists: ${!!token}`);
|
||||||
|
|
||||||
|
// Don't set Content-Type for FormData - browser will set it automatically with boundary
|
||||||
|
const isFormData = options.body instanceof FormData;
|
||||||
|
|
||||||
|
const defaultHeaders = {
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
...options.headers
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only set Content-Type if not FormData
|
||||||
|
if (!isFormData && !defaultHeaders['Content-Type']) {
|
||||||
|
defaultHeaders['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized - try refresh token first
|
||||||
|
if (response.status === 401 && !options._isRetry) {
|
||||||
|
// Consume response body to avoid locking the stream
|
||||||
|
await response.text();
|
||||||
|
|
||||||
|
console.log('Received 401, attempting token refresh...');
|
||||||
|
const { refreshAccessToken } = await import('../components/auth.js');
|
||||||
|
const refreshSuccess = await refreshAccessToken();
|
||||||
|
|
||||||
|
if (refreshSuccess) {
|
||||||
|
// Retry request with new token
|
||||||
|
console.log('Refresh successful, retrying request...');
|
||||||
|
const newToken = getAuthToken();
|
||||||
|
const retryHeaders = {
|
||||||
|
...defaultHeaders,
|
||||||
|
'Authorization': `Bearer ${newToken}`
|
||||||
|
};
|
||||||
|
return apiRequest(endpoint, {
|
||||||
|
...options,
|
||||||
|
headers: retryHeaders,
|
||||||
|
_isRetry: true // Prevent infinite retry loop
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Refresh failed, logout
|
||||||
|
console.log('Refresh failed, removing auth token');
|
||||||
|
removeAuthToken();
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
console.log('Redirecting to login page');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
error.status = response.status;
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
error.data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
error.data = text;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if response has content
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthToken() {
|
||||||
|
return localStorage.getItem('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuthToken(token) {
|
||||||
|
localStorage.setItem('access_token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAuthToken() {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
}
|
||||||
2
frontend/src/utils/config.js
Normal file
2
frontend/src/utils/config.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const API_BASE_URL = window.location.origin;
|
||||||
|
export const APP_NAME = 'Auth Learning App';
|
||||||
143
frontend/src/views/courseBuilder.js
Normal file
143
frontend/src/views/courseBuilder.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { currentUser } from '../components/auth.js';
|
||||||
|
|
||||||
|
// Import editors
|
||||||
|
import { loadVditor, initVditorEditor } from '../editors/vditorLoader.js';
|
||||||
|
|
||||||
|
// Import course-builder modules
|
||||||
|
import { courseStructure, currentCourseId, isEditMode, isDraftMode, setVditorInstance, getVditorInstance } from '../course-builder/State.js';
|
||||||
|
import { saveDraft, previewCourse, publishCourse, updateDraftsCount, showDraftsModal, loadDraftIntoEditor } from '../course-builder/DraftManager.js';
|
||||||
|
import { initCourseStructure, loadCourseForEditing, renderCourseStructure } from '../course-builder/StructureManager.js';
|
||||||
|
import { showMessage, updateBuilderTitle } from '../course-builder/CourseUI.js';
|
||||||
|
|
||||||
|
export { courseStructure, currentCourseId, isEditMode, isDraftMode };
|
||||||
|
|
||||||
|
export async function render() {
|
||||||
|
if (!currentUser) {
|
||||||
|
return `
|
||||||
|
<div class="auth-required">
|
||||||
|
<h2>Требуется авторизация</h2>
|
||||||
|
<p>Для доступа к конструктору курса необходимо войти в систему.</p>
|
||||||
|
<a href="/login" data-link>Войти</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="course-builder-page">
|
||||||
|
<div class="course-builder-container">
|
||||||
|
<h2>Конструктор курса</h2>
|
||||||
|
<p class="subtitle">Создайте новый курс с помощью редактора Markdown с поддержкой LaTeX</p>
|
||||||
|
<div class="course-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="course-title">Название курса</label>
|
||||||
|
<input type="text" id="course-title" placeholder="Введите название курса" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="course-description">Краткое описание</label>
|
||||||
|
<textarea id="course-description" placeholder="Краткое описание курса" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="course-level">Уровень сложности</label>
|
||||||
|
<select id="course-level">
|
||||||
|
<option value="beginner">Начальный</option>
|
||||||
|
<option value="intermediate">Средний</option>
|
||||||
|
<option value="advanced">Продвинутый</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="course-structure-section">
|
||||||
|
<h3>Структура курса</h3>
|
||||||
|
<p class="section-description">Создайте модули и уроки для вашего курса</p>
|
||||||
|
<div class="structure-actions">
|
||||||
|
<button type="button" id="add-module-btn" class="btn btn-outline"><span class="btn-icon">+</span> Добавить модуль</button>
|
||||||
|
</div>
|
||||||
|
<div id="course-structure" class="course-structure">
|
||||||
|
<div class="empty-structure"><p>Пока нет модулей. Добавьте первый модуль, чтобы начать.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-section">
|
||||||
|
<h3>Содержание курса (Vditor редактор)</h3>
|
||||||
|
<div id="vditor-container"></div>
|
||||||
|
<div class="editor-help">
|
||||||
|
<h4>Возможности редактора:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Редактирование Markdown в реальном времени</li>
|
||||||
|
<li>Поддержка LaTeX формул через KaTeX</li>
|
||||||
|
<li>Встроенный предпросмотр</li>
|
||||||
|
<li>Подсветка синтаксиса кода</li>
|
||||||
|
<li>Экспорт в HTML и PDF</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" id="manage-drafts" class="btn btn-outline">📝 Мои черновики <span id="drafts-count" class="badge">0</span></button>
|
||||||
|
<button type="button" id="save-draft" class="btn btn-secondary">Сохранить черновик</button>
|
||||||
|
<button type="button" id="preview-course" class="btn btn-outline">Предпросмотр</button>
|
||||||
|
<button type="button" id="publish-course" class="btn btn-primary">Опубликовать курс</button>
|
||||||
|
</div>
|
||||||
|
<div id="builder-message" class="message"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function afterRender() {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
initVditor();
|
||||||
|
initCourseStructure();
|
||||||
|
|
||||||
|
document.getElementById('save-draft').addEventListener('click', saveDraft);
|
||||||
|
document.getElementById('preview-course').addEventListener('click', previewCourse);
|
||||||
|
document.getElementById('publish-course').addEventListener('click', publishCourse);
|
||||||
|
document.getElementById('manage-drafts').addEventListener('click', showDraftsModal);
|
||||||
|
|
||||||
|
updateDraftsCount();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const vditor = getVditorInstance();
|
||||||
|
if (vditor) {
|
||||||
|
let debounceTimeout;
|
||||||
|
vditor.vditor.element.addEventListener('keyup', () => {
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
debounceTimeout = setTimeout(() => { if (currentCourseId) saveDraft(); }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVditor() {
|
||||||
|
const initialContent = getInitialContent();
|
||||||
|
if (typeof Vditor === 'undefined') loadVditor().then(() => { const inst = initVditorEditor('vditor-container', initialContent); if (inst) setVditorInstance(inst); });
|
||||||
|
else { const inst = initVditorEditor('vditor-container', initialContent); if (inst) setVditorInstance(inst); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialContent() {
|
||||||
|
const draft = localStorage.getItem('course_draft');
|
||||||
|
let initialContent = '# Заголовок курса\n\n## Введение\nНапишите введение к вашему курсу здесь...\n\n## Основная часть\n- Пункт 1\n- Пункт 2\n\n## Формулы LaTeX\nВведите формулы в формате LaTeX:\n$$E = mc^2$$\n$$\\int_{a}^{b} f(x) dx$$\n\n## Заключение\nПодведите итоги курса...';
|
||||||
|
if (draft) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(draft);
|
||||||
|
if (data.content) initialContent = data.content;
|
||||||
|
} catch (e) { console.error('Error loading draft for editor:', e); }
|
||||||
|
}
|
||||||
|
return initialContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const draft = localStorage.getItem('course_draft');
|
||||||
|
if (draft) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(draft);
|
||||||
|
const titleEl = document.getElementById('course-title');
|
||||||
|
const descEl = document.getElementById('course-description');
|
||||||
|
const levelEl = document.getElementById('course-level');
|
||||||
|
const messageEl = document.getElementById('builder-message');
|
||||||
|
if (titleEl) titleEl.value = data.title || '';
|
||||||
|
if (descEl) descEl.value = data.description || '';
|
||||||
|
if (levelEl) levelEl.value = data.level || 'beginner';
|
||||||
|
if (messageEl && data.savedAt) {
|
||||||
|
messageEl.textContent = `📝 Загружен черновик от ${new Date(data.savedAt).toLocaleString()}`;
|
||||||
|
messageEl.className = 'message info';
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Error loading draft:', e); }
|
||||||
|
}
|
||||||
|
});
|
||||||
153
frontend/src/views/courseDetail.js
Normal file
153
frontend/src/views/courseDetail.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { currentUser } from '../components/auth.js';
|
||||||
|
import { router } from '../router.js';
|
||||||
|
|
||||||
|
export async function render(params) {
|
||||||
|
const courseId = parseInt(params.id);
|
||||||
|
const course = await coursesAPI.getById(courseId);
|
||||||
|
|
||||||
|
if (!course) {
|
||||||
|
return `<h1>Course not found</h1>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFavorite = false;
|
||||||
|
let progress = null;
|
||||||
|
let isAuthor = false;
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
const favorites = await coursesAPI.getFavorites();
|
||||||
|
isFavorite = favorites.some(fav => fav.course_id === courseId);
|
||||||
|
progress = await coursesAPI.getProgress(courseId);
|
||||||
|
isAuthor = course.author_id === currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="course-detail-page">
|
||||||
|
<a href="/courses" class="back-link" data-link>← Back to Courses</a>
|
||||||
|
|
||||||
|
<div class="course-header">
|
||||||
|
<h1>${course.title}</h1>
|
||||||
|
<div class="course-meta">
|
||||||
|
<span class="course-level ${course.level}">${course.level}</span>
|
||||||
|
<span class="course-duration">${course.duration} hours</span>
|
||||||
|
<span class="course-date">Added ${new Date(course.created_at).toLocaleDateString()}</span>
|
||||||
|
<span class="course-status ${course.status}">${course.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="course-actions">
|
||||||
|
${course.status === 'published' ? `
|
||||||
|
<a href="/courses/${courseId}/view" data-link class="btn btn-primary start-learning-btn">
|
||||||
|
🎓 Начать обучение
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
${isAuthor ? `
|
||||||
|
<a href="/course-builder?course_id=${courseId}" data-link class="btn btn-outline edit-course-btn">
|
||||||
|
✏️ Редактировать курс
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="course-content">
|
||||||
|
<div class="course-description">
|
||||||
|
<h3>Description</h3>
|
||||||
|
<p>${course.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${currentUser ? `
|
||||||
|
<div class="course-actions-panel">
|
||||||
|
<div class="action-group">
|
||||||
|
<h3>Favorites</h3>
|
||||||
|
<button id="toggle-favorite" class="btn ${isFavorite ? 'btn-secondary' : 'btn-primary'}">
|
||||||
|
${isFavorite ? '♥ Remove from Favorites' : '♡ Add to Favorites'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-group">
|
||||||
|
<h3>Progress Tracking</h3>
|
||||||
|
${progress ? `
|
||||||
|
<div class="progress-display">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: ${progress.percentage || (progress.completed_lessons / progress.total_lessons * 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
<p>${progress.completed_lessons} of ${progress.total_lessons} lessons completed</p>
|
||||||
|
<button id="update-progress" class="btn btn-outline">Update Progress</button>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<p>You haven't started this course yet.</p>
|
||||||
|
<button id="start-course" class="btn btn-primary">Start Learning</button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="auth-prompt">
|
||||||
|
<p><a href="/login" data-link>Login</a> to add this course to favorites and track your progress.</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function afterRender(params) {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const courseId = parseInt(params.id);
|
||||||
|
|
||||||
|
// Toggle favorite
|
||||||
|
const favoriteBtn = document.getElementById('toggle-favorite');
|
||||||
|
if (favoriteBtn) {
|
||||||
|
favoriteBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
if (favoriteBtn.textContent.includes('Add to')) {
|
||||||
|
await coursesAPI.addFavorite(courseId);
|
||||||
|
favoriteBtn.textContent = '♥ Remove from Favorites';
|
||||||
|
favoriteBtn.classList.remove('btn-primary');
|
||||||
|
favoriteBtn.classList.add('btn-secondary');
|
||||||
|
} else {
|
||||||
|
await coursesAPI.removeFavorite(courseId);
|
||||||
|
favoriteBtn.textContent = '♡ Add to Favorites';
|
||||||
|
favoriteBtn.classList.remove('btn-secondary');
|
||||||
|
favoriteBtn.classList.add('btn-primary');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to update favorites: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start course
|
||||||
|
const startBtn = document.getElementById('start-course');
|
||||||
|
if (startBtn) {
|
||||||
|
startBtn.addEventListener('click', async () => {
|
||||||
|
const totalLessons = prompt('Enter total number of lessons for this course:');
|
||||||
|
if (totalLessons) {
|
||||||
|
try {
|
||||||
|
await coursesAPI.updateProgress(courseId, 0, parseInt(totalLessons));
|
||||||
|
alert('Course started! You can now track your progress.');
|
||||||
|
location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to start course: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
const updateBtn = document.getElementById('update-progress');
|
||||||
|
if (updateBtn) {
|
||||||
|
updateBtn.addEventListener('click', async () => {
|
||||||
|
const completed = prompt('Enter number of completed lessons:');
|
||||||
|
const total = prompt('Enter total number of lessons:');
|
||||||
|
|
||||||
|
if (completed && total) {
|
||||||
|
try {
|
||||||
|
await coursesAPI.updateProgress(courseId, parseInt(completed), parseInt(total));
|
||||||
|
alert('Progress updated successfully!');
|
||||||
|
location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to update progress: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
451
frontend/src/views/courseViewer.js
Normal file
451
frontend/src/views/courseViewer.js
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { PdfViewer } from '../components/PdfViewer.js';
|
||||||
|
import { TestViewer } from '../components/TestViewer.js';
|
||||||
|
|
||||||
|
let currentCourse = null;
|
||||||
|
let currentModuleIndex = 0;
|
||||||
|
let currentLessonIndex = 0;
|
||||||
|
let currentStepIndex = 0;
|
||||||
|
let loadedSteps = new Map();
|
||||||
|
let pdfViewerInstance = null;
|
||||||
|
let testViewerInstance = null;
|
||||||
|
|
||||||
|
const stepTypeLabels = {
|
||||||
|
theory: 'Теория',
|
||||||
|
practice: 'Практика',
|
||||||
|
lab: 'Лабораторная',
|
||||||
|
test: 'Тест',
|
||||||
|
presentation: 'Презентация'
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepTypeIcons = {
|
||||||
|
theory: '📖',
|
||||||
|
practice: '✍️',
|
||||||
|
lab: '🔬',
|
||||||
|
test: '📝',
|
||||||
|
presentation: '📊'
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function render(params) {
|
||||||
|
const courseId = parseInt(params.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentCourse = await coursesAPI.getCourseStructure(courseId);
|
||||||
|
} catch (error) {
|
||||||
|
return `<div class="error-page"><h1>Ошибка загрузки курса</h1><p>${error.message}</p><a href="/courses" data-link>← К курсам</a></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentCourse || !currentCourse.modules || currentCourse.modules.length === 0) {
|
||||||
|
return `<div class="error-page"><h1>Курс пуст</h1><p>В курсе нет модулей.</p><a href="/courses/${courseId}" data-link>← К описанию курса</a></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedSteps.clear();
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="course-viewer">
|
||||||
|
<div class="viewer-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<a href="/courses/${courseId}" data-link class="back-link">← К описанию</a>
|
||||||
|
<h1 class="course-title">${currentCourse.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="step-navigation" id="step-nav"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="viewer-body">
|
||||||
|
<aside class="structure-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>Содержание</h3>
|
||||||
|
</div>
|
||||||
|
<div class="structure-tree" id="structure-tree">
|
||||||
|
${renderStructureTree()}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content-area">
|
||||||
|
<div class="step-content" id="step-content">
|
||||||
|
<div class="loading-placeholder">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Загрузка контента...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-navigation-bottom" id="step-nav-bottom"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStructureTree() {
|
||||||
|
if (!currentCourse.modules) return '<p>Нет модулей</p>';
|
||||||
|
|
||||||
|
return currentCourse.modules.map((module, mIdx) => `
|
||||||
|
<div class="tree-module ${mIdx === currentModuleIndex ? 'active' : ''}" data-module="${mIdx}">
|
||||||
|
<div class="module-header" onclick="window.selectModule(${mIdx})">
|
||||||
|
<span class="module-icon">📁</span>
|
||||||
|
<span class="module-title">${module.title}</span>
|
||||||
|
${module.duration ? `<span class="module-duration">${module.duration}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="module-lessons">
|
||||||
|
${module.lessons && module.lessons.length > 0 ? module.lessons.map((lesson, lIdx) => `
|
||||||
|
<div class="tree-lesson ${mIdx === currentModuleIndex && lIdx === currentLessonIndex ? 'active' : ''}"
|
||||||
|
data-module="${mIdx}" data-lesson="${lIdx}"
|
||||||
|
onclick="window.selectLesson(${mIdx}, ${lIdx})">
|
||||||
|
<span class="lesson-icon">📄</span>
|
||||||
|
<span class="lesson-title">${lesson.title}</span>
|
||||||
|
</div>
|
||||||
|
`).join('') : '<div class="no-lessons">Нет уроков</div>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentStep() {
|
||||||
|
const module = currentCourse.modules[currentModuleIndex];
|
||||||
|
if (!module || !module.lessons) return null;
|
||||||
|
const lesson = module.lessons[currentLessonIndex];
|
||||||
|
if (!lesson || !lesson.steps) return null;
|
||||||
|
return lesson.steps[currentStepIndex] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStepContent(stepId) {
|
||||||
|
if (loadedSteps.has(stepId)) {
|
||||||
|
return loadedSteps.get(stepId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = await coursesAPI.getStepContent(stepId);
|
||||||
|
if (step) {
|
||||||
|
loadedSteps.set(stepId, step);
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStepContent(step, module, lesson, isLoading = false) {
|
||||||
|
if (isLoading) {
|
||||||
|
return `
|
||||||
|
<div class="loading-placeholder">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Загрузка контента...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
return `<div class="no-content"><p>Шаг не найден</p></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepTypeClass = `step-type-${step.step_type}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="step-breadcrumb">
|
||||||
|
<span class="breadcrumb-module">${module.title}</span>
|
||||||
|
<span class="breadcrumb-sep">›</span>
|
||||||
|
<span class="breadcrumb-lesson">${lesson.title}</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-type-badge ${stepTypeClass}">
|
||||||
|
<span class="step-icon">${stepTypeIcons[step.step_type] || '📄'}</span>
|
||||||
|
<span class="step-type-label">${stepTypeLabels[step.step_type] || step.step_type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${step.title ? `<h2 class="step-title">${step.title}</h2>` : ''}
|
||||||
|
|
||||||
|
<div class="step-body ${step.step_type === 'presentation' ? 'step-type-presentation' : ''}">
|
||||||
|
${renderStepBodyContent(step)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStepBodyContent(step) {
|
||||||
|
if (step.step_type === 'presentation' && step.file_url) {
|
||||||
|
return `<div id="pdf-container" data-pdf-url="${step.file_url}"></div>`;
|
||||||
|
}
|
||||||
|
if (step.step_type === 'test') {
|
||||||
|
return `<div id="test-container"></div>`;
|
||||||
|
}
|
||||||
|
return step.content_html ? step.content_html : `<div class="markdown-content">${step.content || '<p>Нет содержимого</p>'}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNoContent(message) {
|
||||||
|
return `<div class="no-content"><p>${message}</p></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStepNavigation() {
|
||||||
|
const module = currentCourse.modules[currentModuleIndex];
|
||||||
|
if (!module || !module.lessons) return;
|
||||||
|
|
||||||
|
const lesson = module.lessons[currentLessonIndex];
|
||||||
|
if (!lesson || !lesson.steps || lesson.steps.length === 0) return;
|
||||||
|
|
||||||
|
const steps = lesson.steps;
|
||||||
|
const stepNav = document.getElementById('step-nav');
|
||||||
|
const stepNavBottom = document.getElementById('step-nav-bottom');
|
||||||
|
|
||||||
|
const navHtml = `
|
||||||
|
<div class="step-tabs">
|
||||||
|
${steps.map((step, idx) => `
|
||||||
|
<button class="step-tab ${idx === currentStepIndex ? 'active' : ''}"
|
||||||
|
onclick="window.selectStep(${idx})"
|
||||||
|
title="${step.title || stepTypeLabels[step.step_type]}">
|
||||||
|
<span class="tab-icon">${stepTypeIcons[step.step_type]}</span>
|
||||||
|
<span class="tab-label">${idx + 1}</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (stepNav) stepNav.innerHTML = navHtml;
|
||||||
|
|
||||||
|
const bottomNavHtml = `
|
||||||
|
<div class="nav-buttons">
|
||||||
|
<button class="nav-btn prev-btn" onclick="window.navigateStep(-1)" ${!canNavigate(-1) ? 'disabled' : ''}>
|
||||||
|
← Предыдущий шаг
|
||||||
|
</button>
|
||||||
|
<span class="step-counter">Шаг ${currentStepIndex + 1} из ${steps.length}</span>
|
||||||
|
<button class="nav-btn next-btn" onclick="window.navigateStep(1)" ${!canNavigate(1) ? 'disabled' : ''}>
|
||||||
|
Следующий шаг →
|
||||||
|
</button>
|
||||||
|
${getCurrentStep()?.step_type === 'presentation' ? `
|
||||||
|
<button class="nav-btn fullscreen-btn" onclick="window.togglePresentationFullscreen()" title="Полноэкранный режим">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
На весь экран
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (stepNavBottom) stepNavBottom.innerHTML = bottomNavHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canNavigate(direction) {
|
||||||
|
const module = currentCourse.modules[currentModuleIndex];
|
||||||
|
if (!module || !module.lessons) return false;
|
||||||
|
|
||||||
|
const lesson = module.lessons[currentLessonIndex];
|
||||||
|
if (!lesson || !lesson.steps) return false;
|
||||||
|
|
||||||
|
const newStepIndex = currentStepIndex + direction;
|
||||||
|
return newStepIndex >= 0 && newStepIndex < lesson.steps.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateContent() {
|
||||||
|
const stepContent = document.getElementById('step-content');
|
||||||
|
const structureTree = document.getElementById('structure-tree');
|
||||||
|
|
||||||
|
if (structureTree) {
|
||||||
|
structureTree.innerHTML = renderStructureTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStepNavigation();
|
||||||
|
|
||||||
|
const module = currentCourse.modules[currentModuleIndex];
|
||||||
|
if (!module || !module.lessons || module.lessons.length === 0) {
|
||||||
|
if (stepContent) stepContent.innerHTML = renderNoContent('В этом модуле нет уроков');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lesson = module.lessons[currentLessonIndex];
|
||||||
|
if (!lesson) {
|
||||||
|
if (stepContent) stepContent.innerHTML = renderNoContent('Урок не найден');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lesson.steps || lesson.steps.length === 0) {
|
||||||
|
if (stepContent) stepContent.innerHTML = `<div class="no-content"><h2>${lesson.title}</h2><p>В этом уроке нет шагов</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = lesson.steps[currentStepIndex];
|
||||||
|
if (!step) {
|
||||||
|
currentStepIndex = 0;
|
||||||
|
updateContent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepContent) {
|
||||||
|
stepContent.innerHTML = renderStepContent(null, module, lesson, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fullStep = await loadStepContent(step.id);
|
||||||
|
stepContent.innerHTML = renderStepContent(fullStep, module, lesson);
|
||||||
|
renderMath(stepContent);
|
||||||
|
initPdfViewer();
|
||||||
|
initTestViewer();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load step content:', error);
|
||||||
|
stepContent.innerHTML = `<div class="error-content"><p>Ошибка загрузки контента: ${error.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentArea = document.querySelector('.content-area');
|
||||||
|
if (contentArea) {
|
||||||
|
contentArea.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectModule = function(mIdx) {
|
||||||
|
if (mIdx >= 0 && mIdx < currentCourse.modules.length) {
|
||||||
|
currentModuleIndex = mIdx;
|
||||||
|
currentLessonIndex = 0;
|
||||||
|
currentStepIndex = 0;
|
||||||
|
updateContent();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.selectLesson = function(mIdx, lIdx) {
|
||||||
|
const module = currentCourse.modules[mIdx];
|
||||||
|
if (module && module.lessons && lIdx >= 0 && lIdx < module.lessons.length) {
|
||||||
|
currentModuleIndex = mIdx;
|
||||||
|
currentLessonIndex = lIdx;
|
||||||
|
currentStepIndex = 0;
|
||||||
|
updateContent();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.selectStep = function(sIdx) {
|
||||||
|
const module = currentCourse.modules[currentModuleIndex];
|
||||||
|
if (module && module.lessons) {
|
||||||
|
const lesson = module.lessons[currentLessonIndex];
|
||||||
|
if (lesson && lesson.steps && sIdx >= 0 && sIdx < lesson.steps.length) {
|
||||||
|
currentStepIndex = sIdx;
|
||||||
|
updateContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.navigateStep = function(direction) {
|
||||||
|
const module = currentCourse.modules[currentModuleIndex];
|
||||||
|
if (!module || !module.lessons) return;
|
||||||
|
|
||||||
|
const lesson = module.lessons[currentLessonIndex];
|
||||||
|
if (!lesson || !lesson.steps) return;
|
||||||
|
|
||||||
|
const newStepIndex = currentStepIndex + direction;
|
||||||
|
if (newStepIndex >= 0 && newStepIndex < lesson.steps.length) {
|
||||||
|
currentStepIndex = newStepIndex;
|
||||||
|
updateContent();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let isPresentationFullscreen = false;
|
||||||
|
|
||||||
|
window.togglePresentationFullscreen = function() {
|
||||||
|
const viewer = document.querySelector('.course-viewer');
|
||||||
|
const header = document.querySelector('.viewer-header');
|
||||||
|
const sidebar = document.querySelector('.structure-sidebar');
|
||||||
|
const contentArea = document.querySelector('.content-area');
|
||||||
|
|
||||||
|
isPresentationFullscreen = !isPresentationFullscreen;
|
||||||
|
|
||||||
|
if (isPresentationFullscreen) {
|
||||||
|
viewer.classList.add('presentation-fullscreen');
|
||||||
|
header.style.display = 'none';
|
||||||
|
sidebar.style.display = 'none';
|
||||||
|
contentArea.style.width = '100%';
|
||||||
|
} else {
|
||||||
|
viewer.classList.remove('presentation-fullscreen');
|
||||||
|
header.style.display = '';
|
||||||
|
sidebar.style.display = '';
|
||||||
|
contentArea.style.width = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderMath(element) {
|
||||||
|
if (typeof renderMathInElement !== 'undefined') {
|
||||||
|
try {
|
||||||
|
renderMathInElement(element, {
|
||||||
|
delimiters: [
|
||||||
|
{left: '$$', right: '$$', display: true},
|
||||||
|
{left: '$', right: '$', display: false},
|
||||||
|
{left: '\\[', right: '\\]', display: true},
|
||||||
|
{left: '\\(', right: '\\)', display: false}
|
||||||
|
],
|
||||||
|
throwOnError: false
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('KaTeX render error:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('KaTeX not loaded, retrying...');
|
||||||
|
setTimeout(() => renderMath(element), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function afterRender(params) {
|
||||||
|
updateContent();
|
||||||
|
document.addEventListener('keydown', handleKeyboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPdfViewer() {
|
||||||
|
const pdfContainer = document.getElementById('pdf-container');
|
||||||
|
if (!pdfContainer) {
|
||||||
|
if (pdfViewerInstance) {
|
||||||
|
pdfViewerInstance.destroy();
|
||||||
|
pdfViewerInstance = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfUrl = pdfContainer.dataset.pdfUrl;
|
||||||
|
if (!pdfUrl) return;
|
||||||
|
|
||||||
|
if (pdfViewerInstance) {
|
||||||
|
pdfViewerInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfViewerInstance = new PdfViewer(pdfContainer, pdfUrl, {
|
||||||
|
showControls: true,
|
||||||
|
fullscreenButton: true
|
||||||
|
});
|
||||||
|
pdfViewerInstance.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTestViewer() {
|
||||||
|
const testContainer = document.getElementById('test-container');
|
||||||
|
if (!testContainer) {
|
||||||
|
if (testViewerInstance) {
|
||||||
|
testViewerInstance.destroy();
|
||||||
|
testViewerInstance = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = getCurrentStep();
|
||||||
|
if (!step) return;
|
||||||
|
|
||||||
|
if (testViewerInstance) {
|
||||||
|
testViewerInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
testViewerInstance = new TestViewer(testContainer, step.id);
|
||||||
|
window.currentTestViewer = testViewerInstance;
|
||||||
|
testViewerInstance.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboard(e) {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'PageDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
window.navigateStep(1);
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
window.navigateStep(-1);
|
||||||
|
} else if (e.key === 'Escape' && isPresentationFullscreen) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.togglePresentationFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanup() {
|
||||||
|
if (autoSaveTimer) clearInterval(autoSaveTimer);
|
||||||
|
if (vditorInstance) vditorInstance.destroy();
|
||||||
|
if (pdfViewerInstance) pdfViewerInstance.destroy();
|
||||||
|
if (testViewerInstance) testViewerInstance.destroy();
|
||||||
|
document.removeEventListener("keydown", handleKeyboard);
|
||||||
|
}
|
||||||
61
frontend/src/views/courses.js
Normal file
61
frontend/src/views/courses.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { currentUser } from '../components/auth.js';
|
||||||
|
|
||||||
|
export async function render(params) {
|
||||||
|
const level = new URLSearchParams(window.location.search).get('level');
|
||||||
|
const courses = await coursesAPI.getAll(level);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="courses-page">
|
||||||
|
<h1>Course Catalog</h1>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<a href="/courses" class="filter-btn ${!level ? 'active' : ''}" data-link>All</a>
|
||||||
|
<a href="/courses?level=beginner" class="filter-btn ${level === 'beginner' ? 'active' : ''}" data-link>Beginner</a>
|
||||||
|
<a href="/courses?level=intermediate" class="filter-btn ${level === 'intermediate' ? 'active' : ''}" data-link>Intermediate</a>
|
||||||
|
<a href="/courses?level=advanced" class="filter-btn ${level === 'advanced' ? 'active' : ''}" data-link>Advanced</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="courses-grid">
|
||||||
|
${courses.map(course => `
|
||||||
|
<div class="course-card" data-course-id="${course.id}">
|
||||||
|
<div class="course-header">
|
||||||
|
<h3>${course.title}</h3>
|
||||||
|
<span class="course-level ${course.level}">${course.level}</span>
|
||||||
|
</div>
|
||||||
|
<p class="course-description">${course.description}</p>
|
||||||
|
<div class="course-meta">
|
||||||
|
<span class="course-duration">${course.duration} hours</span>
|
||||||
|
<span class="course-date">${new Date(course.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="course-actions">
|
||||||
|
<a href="/courses/${course.id}" class="btn btn-outline" data-link>View Details</a>
|
||||||
|
${currentUser ? `
|
||||||
|
<button class="btn-favorite" data-course-id="${course.id}">
|
||||||
|
<span class="heart-icon">♡</span> Add to Favorites
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function afterRender() {
|
||||||
|
// Add event listeners for favorite buttons
|
||||||
|
document.querySelectorAll('.btn-favorite').forEach(button => {
|
||||||
|
button.addEventListener('click', async (e) => {
|
||||||
|
const courseId = parseInt(e.target.dataset.courseId);
|
||||||
|
try {
|
||||||
|
await coursesAPI.addFavorite(courseId);
|
||||||
|
e.target.innerHTML = '<span class="heart-icon">♥</span> Added to Favorites';
|
||||||
|
e.target.disabled = true;
|
||||||
|
e.target.classList.add('favorited');
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to add to favorites: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
160
frontend/src/views/devTools.js
Normal file
160
frontend/src/views/devTools.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { currentUser } from '../components/auth.js';
|
||||||
|
import { apiRequest } from '../utils/api.js';
|
||||||
|
import { updateNav } from '../components/nav.js';
|
||||||
|
|
||||||
|
export async function render() {
|
||||||
|
if (!currentUser) {
|
||||||
|
return `
|
||||||
|
<div class="auth-required">
|
||||||
|
<h2>Требуется авторизация</h2>
|
||||||
|
<p>Для доступа к инструментам разработчика необходимо войти в систему.</p>
|
||||||
|
<a href="/login" data-link>Войти</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="dev-tools-page">
|
||||||
|
<div class="dev-tools-container">
|
||||||
|
<h1>Инструменты разработчика</h1>
|
||||||
|
|
||||||
|
<div class="dev-tools-section">
|
||||||
|
<h2>Статус разработчика</h2>
|
||||||
|
<p class="section-description">Текущий статус: ${currentUser.is_developer ? '✅ Разработчик (виден раздел Инструменты)' : '❌ Не разработчик'}</p>
|
||||||
|
|
||||||
|
<button type="button" class="btn ${currentUser.is_developer ? 'btn-warning' : 'btn-primary'}" id="toggle-developer">
|
||||||
|
${currentUser.is_developer ? '❌ Отключить права разработчика' : '✅ Включить права разработчика'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dev-tools-section">
|
||||||
|
<h2>Управление хранилищем</h2>
|
||||||
|
<p class="section-description">Очистка локального хранилища приложения</p>
|
||||||
|
|
||||||
|
<div class="tools-list">
|
||||||
|
<div class="tool-item">
|
||||||
|
<div class="tool-info">
|
||||||
|
<h3>Очистить localStorage</h3>
|
||||||
|
<p class="tool-desc">Удалить все данные из localStorage браузера</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-danger" id="clear-local-storage">
|
||||||
|
Очистить localStorage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-item">
|
||||||
|
<div class="tool-info">
|
||||||
|
<h3>Очистить session storage</h3>
|
||||||
|
<p class="tool-desc">Удалить все данные из session storage</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-warning" id="clear-session-storage">
|
||||||
|
Очистить session storage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-item">
|
||||||
|
<div class="tool-info">
|
||||||
|
<h3>Скачать localStorage</h3>
|
||||||
|
<p class="tool-desc">Сохранить содержимое localStorage в файл</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary" id="download-local-storage">
|
||||||
|
Скачать localStorage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dev-message" class="message"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function afterRender() {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
// Toggle developer status button
|
||||||
|
const toggleDeveloperBtn = document.getElementById('toggle-developer');
|
||||||
|
if (toggleDeveloperBtn) {
|
||||||
|
toggleDeveloperBtn.addEventListener('click', async () => {
|
||||||
|
const newStatus = !currentUser.is_developer;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/me/developer-status', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ is_developer: newStatus })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
currentUser.is_developer = newStatus;
|
||||||
|
showMessage(`✅ Статус ${newStatus ? 'включён' : 'отключён'} перезагрузите страницу чтобы увидеть изменения`, 'success');
|
||||||
|
updateCurrentUserList();
|
||||||
|
} else {
|
||||||
|
showMessage('❌ Ошибка updating developer status', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle developer status:', error);
|
||||||
|
showMessage('❌ Ошибка сети', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear localStorage button
|
||||||
|
const clearLocalStorageBtn = document.getElementById('clear-local-storage');
|
||||||
|
if (clearLocalStorageBtn) {
|
||||||
|
clearLocalStorageBtn.addEventListener('click', () => {
|
||||||
|
if (confirm('Вы уверены, что хотите очистить localStorage? Все локально сохранённые данные будут удалены.')) {
|
||||||
|
localStorage.clear();
|
||||||
|
showMessage('✅ localStorage очищен', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear session storage button
|
||||||
|
const clearSessionStorageBtn = document.getElementById('clear-session-storage');
|
||||||
|
if (clearSessionStorageBtn) {
|
||||||
|
clearSessionStorageBtn.addEventListener('click', () => {
|
||||||
|
if (confirm('Вы уверены, что хотите очистить session storage?')) {
|
||||||
|
sessionStorage.clear();
|
||||||
|
showMessage('✅ Session storage очищен', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download localStorage button
|
||||||
|
const downloadLocalStorageBtn = document.getElementById('download-local-storage');
|
||||||
|
if (downloadLocalStorageBtn) {
|
||||||
|
downloadLocalStorageBtn.addEventListener('click', () => {
|
||||||
|
const data = {};
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
data[key] = localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataStr = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([dataStr], {type: 'application/json'});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `localStorage_backup_${new Date().toISOString()}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showMessage('✅ localStorage скачивается', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text, type) {
|
||||||
|
const messageEl = document.getElementById('dev-message');
|
||||||
|
if (messageEl) {
|
||||||
|
messageEl.textContent = text;
|
||||||
|
messageEl.className = `message ${type}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCurrentUserList() {
|
||||||
|
updateNav();
|
||||||
|
}
|
||||||
86
frontend/src/views/favorites.js
Normal file
86
frontend/src/views/favorites.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { currentUser } from '../components/auth.js';
|
||||||
|
|
||||||
|
export async function render() {
|
||||||
|
if (!currentUser) {
|
||||||
|
return `
|
||||||
|
<div class="auth-required">
|
||||||
|
<h2>Authentication Required</h2>
|
||||||
|
<p>Please <a href="/login" data-link>login</a> to view your favorite courses.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorites = await coursesAPI.getFavorites();
|
||||||
|
const favoriteCourseIds = favorites.map(fav => fav.course_id);
|
||||||
|
|
||||||
|
// Fetch all courses to get details
|
||||||
|
const allCourses = await coursesAPI.getAll();
|
||||||
|
const favoriteCourses = allCourses.filter(course =>
|
||||||
|
favoriteCourseIds.includes(course.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="favorites-page">
|
||||||
|
<h1>My Favorite Courses</h1>
|
||||||
|
|
||||||
|
${favoriteCourses.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>You haven't added any courses to favorites yet.</p>
|
||||||
|
<a href="/courses" class="btn" data-link>Browse Courses</a>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="courses-grid">
|
||||||
|
${favoriteCourses.map(course => `
|
||||||
|
<div class="course-card" data-course-id="${course.id}">
|
||||||
|
<div class="course-header">
|
||||||
|
<h3>${course.title}</h3>
|
||||||
|
<span class="course-level ${course.level}">${course.level}</span>
|
||||||
|
</div>
|
||||||
|
<p class="course-description">${course.description}</p>
|
||||||
|
<div class="course-meta">
|
||||||
|
<span class="course-duration">${course.duration} hours</span>
|
||||||
|
<span class="course-date">${new Date(course.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="course-actions">
|
||||||
|
<a href="/courses/${course.id}" class="btn btn-outline" data-link>View Details</a>
|
||||||
|
<button class="btn-remove-favorite" data-course-id="${course.id}">
|
||||||
|
Remove from Favorites
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function afterRender() {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
// Add event listeners for remove favorite buttons
|
||||||
|
document.querySelectorAll('.btn-remove-favorite').forEach(button => {
|
||||||
|
button.addEventListener('click', async (e) => {
|
||||||
|
const courseId = parseInt(e.target.dataset.courseId);
|
||||||
|
if (confirm('Remove this course from favorites?')) {
|
||||||
|
try {
|
||||||
|
await coursesAPI.removeFavorite(courseId);
|
||||||
|
e.target.closest('.course-card').remove();
|
||||||
|
|
||||||
|
// If no cards left, show empty state
|
||||||
|
if (document.querySelectorAll('.course-card').length === 0) {
|
||||||
|
document.querySelector('.courses-grid').innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>You haven't added any courses to favorites yet.</p>
|
||||||
|
<a href="/courses" class="btn" data-link>Browse Courses</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to remove from favorites: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
618
frontend/src/views/home.js
Normal file
618
frontend/src/views/home.js
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
export async function render() {
|
||||||
|
return `
|
||||||
|
<div class="landing">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-bg">
|
||||||
|
<canvas id="particles-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="container hero-content">
|
||||||
|
<div class="hero-text">
|
||||||
|
<h1 class="hero-title">
|
||||||
|
Умное корпоративное обучение<br>
|
||||||
|
<span class="gradient-text">в формате Markdown</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
Создавайте курсы так же быстро, как пишете текст.
|
||||||
|
Автоматическая синхронизация прогресса и аналитики
|
||||||
|
напрямую с вашим корпоративным контуром 1С.
|
||||||
|
</p>
|
||||||
|
<div class="hero-buttons">
|
||||||
|
<button class="btn btn-primary btn-lg" onclick="scrollToSection('demo')">
|
||||||
|
Запросить демо
|
||||||
|
</button>
|
||||||
|
<a href="#features" class="btn btn-outline btn-lg">
|
||||||
|
Возможности платформы
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">10x</span>
|
||||||
|
<span class="stat-label">быстрее создание курсов</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">99.9%</span>
|
||||||
|
<span class="stat-label">uptime</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">50+</span>
|
||||||
|
<span class="stat-label">интеграций</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-visual">
|
||||||
|
<div class="editor-demo">
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-dots">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<span class="editor-title">course.md</span>
|
||||||
|
</div>
|
||||||
|
<div class="editor-body">
|
||||||
|
<div class="editor-input" id="editor-input">
|
||||||
|
<pre><code><span class="line"><span class="md-hash">#</span> <span class="md-heading">Введение в Python</span></span>
|
||||||
|
<span class="line"></span>
|
||||||
|
<span class="line"><span class="md-hash">##</span> <span class="md-heading">Переменные и типы данных</span></span>
|
||||||
|
<span class="line"></span>
|
||||||
|
<span class="line"><span class="md-text">Python — язык с </span><span class="md-bold">**динамической**</span><span class="md-text"> типизацией.</span></span>
|
||||||
|
<span class="line"></span>
|
||||||
|
<span class="line"><span class="md-code">\`\`\`python</span></span>
|
||||||
|
<span class="line"><span class="code-keyword">name</span> = <span class="code-string">"Alice"</span></span>
|
||||||
|
<span class="line"><span class="code-keyword">age</span> = <span class="code-number">30</span></span>
|
||||||
|
<span class="line"><span class="md-code">\`\`\`</span></span>
|
||||||
|
<span class="line"></span>
|
||||||
|
<span class="line"><span class="md-latex">$x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$</span></span></code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="editor-arrow">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="editor-output">
|
||||||
|
<div class="preview-content">
|
||||||
|
<h1 class="preview-title">Введение в Python</h1>
|
||||||
|
<h2 class="preview-subtitle">Переменные и типы данных</h2>
|
||||||
|
<p class="preview-text">Python — язык с <strong>динамической</strong> типизацией.</p>
|
||||||
|
<div class="preview-code">
|
||||||
|
<code><span class="code-keyword">name</span> = <span class="code-string">"Alice"</span></code>
|
||||||
|
<code><span class="code-keyword">age</span> = <span class="code-number">30</span></code>
|
||||||
|
</div>
|
||||||
|
<div class="preview-formula">x = (-b ± √(b²-4ac)) / 2a</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-scroll">
|
||||||
|
<span>Листайте вниз</span>
|
||||||
|
<div class="scroll-indicator"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Simplicity Section -->
|
||||||
|
<section class="section simplicity" id="features">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-badge">Простота</span>
|
||||||
|
<h2 class="section-title">Создавайте курсы за минуты, не часы</h2>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Забудьте о громоздких визуальных конструкторах.
|
||||||
|
Используйте привычный Markdown-синтаксис для создания интерактивного контента.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="simplicity-grid">
|
||||||
|
<div class="simplicity-card">
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 2V8H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 13H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 17H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Markdown-first</h3>
|
||||||
|
<p>Пишите контент в привычном формате. Платформа автоматически преобразует его в интерактивные модули.</p>
|
||||||
|
</div>
|
||||||
|
<div class="simplicity-card">
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="3" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Автоструктура</h3>
|
||||||
|
<p>Заголовки автоматически формируют модули и уроки. Не нужно настраивать навигацию вручную.</p>
|
||||||
|
</div>
|
||||||
|
<div class="simplicity-card">
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Мультимедиа</h3>
|
||||||
|
<p>Встраивайте изображения, видео, код с подсветкой и математические формулы LaTeX.</p>
|
||||||
|
</div>
|
||||||
|
<div class="simplicity-card">
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Мгновенный предпросмотр</h3>
|
||||||
|
<p>Видите результат в реальном времени. WYSIWYG-режим для тех, кто предпочитает визуальное редактирование.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="simplicity-demo">
|
||||||
|
<div class="transform-visual">
|
||||||
|
<div class="transform-item text">
|
||||||
|
<span class="transform-icon">📝</span>
|
||||||
|
<span>Плоский текст</span>
|
||||||
|
</div>
|
||||||
|
<div class="transform-arrow">
|
||||||
|
<svg width="100" height="24" viewBox="0 0 100 24">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="arrow-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#10b981"/>
|
||||||
|
<stop offset="100%" style="stop-color:#6366f1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M0 12 H85 M80 6 L90 12 L80 18" stroke="url(#arrow-grad)" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="transform-item interactive">
|
||||||
|
<span class="transform-icon">🎓</span>
|
||||||
|
<span>Интерактивный курс</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Integration Section -->
|
||||||
|
<section class="section integration">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-badge">Интеграция</span>
|
||||||
|
<h2 class="section-title">Глубокая интеграция с 1С</h2>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Автоматический обмен данными между платформой обучения
|
||||||
|
и корпоративными системами учёта. Никакого ручного переноса.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="integration-flow">
|
||||||
|
<div class="flow-center">
|
||||||
|
<div class="platform-hub">
|
||||||
|
<div class="hub-icon">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="hub-label">Платформа<br>обучения</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-left">
|
||||||
|
<div class="flow-item" data-direction="to">
|
||||||
|
<div class="flow-icon">📊</div>
|
||||||
|
<div class="flow-content">
|
||||||
|
<h4>Результаты тестов</h4>
|
||||||
|
<p>Автоматическая передача оценок и сертификатов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-item" data-direction="to">
|
||||||
|
<div class="flow-icon">⏱️</div>
|
||||||
|
<div class="flow-content">
|
||||||
|
<h4>Время обучения</h4>
|
||||||
|
<p>Учёт рабочего времени в ЗУП</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-item" data-direction="to">
|
||||||
|
<div class="flow-icon">📈</div>
|
||||||
|
<div class="flow-content">
|
||||||
|
<h4>Метрики вовлечённости</h4>
|
||||||
|
<p>Аналитика для HR-отчётности</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-right">
|
||||||
|
<div class="flow-item" data-direction="from">
|
||||||
|
<div class="flow-icon">👥</div>
|
||||||
|
<div class="flow-content">
|
||||||
|
<h4>Сотрудники</h4>
|
||||||
|
<p>Синхронизация оргструктуры</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-item" data-direction="from">
|
||||||
|
<div class="flow-icon">🏢</div>
|
||||||
|
<div class="flow-content">
|
||||||
|
<h4>Подразделения</h4>
|
||||||
|
<p>Автоматическое назначение курсов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-item" data-direction="from">
|
||||||
|
<div class="flow-icon">📋</div>
|
||||||
|
<div class="flow-content">
|
||||||
|
<h4>Должности</h4>
|
||||||
|
<p>Персональные траектории обучения</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-1c">
|
||||||
|
<div class="c1-badge">
|
||||||
|
<span class="c1-logo">1С</span>
|
||||||
|
<span class="c1-products">ЗУП • ERP • CRM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg class="flow-lines" viewBox="0 0 800 400">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="line-grad-left" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#10b981"/>
|
||||||
|
<stop offset="100%" style="stop-color:#6366f1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="line-grad-right" x1="100%" y1="0%" x2="0%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#6366f1"/>
|
||||||
|
<stop offset="100%" style="stop-color:#10b981"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path class="flow-line line-left" d="M150 80 Q300 80 400 200" stroke="url(#line-grad-left)" fill="none"/>
|
||||||
|
<path class="flow-line line-left" d="M150 200 Q250 200 400 200" stroke="url(#line-grad-left)" fill="none"/>
|
||||||
|
<path class="flow-line line-left" d="M150 320 Q300 320 400 200" stroke="url(#line-grad-left)" fill="none"/>
|
||||||
|
<path class="flow-line line-right" d="M650 80 Q500 80 400 200" stroke="url(#line-grad-right)" fill="none"/>
|
||||||
|
<path class="flow-line line-right" d="M650 200 Q550 200 400 200" stroke="url(#line-grad-right)" fill="none"/>
|
||||||
|
<path class="flow-line line-right" d="M650 320 Q500 320 400 200" stroke="url(#line-grad-right)" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="integration-benefits">
|
||||||
|
<div class="benefit">
|
||||||
|
<span class="benefit-value">100%</span>
|
||||||
|
<span class="benefit-label">автоматизация отчётности</span>
|
||||||
|
</div>
|
||||||
|
<div class="benefit">
|
||||||
|
<span class="benefit-value">0</span>
|
||||||
|
<span class="benefit-label">ручного ввода данных</span>
|
||||||
|
</div>
|
||||||
|
<div class="benefit">
|
||||||
|
<span class="benefit-value">24/7</span>
|
||||||
|
<span class="benefit-label">синхронизация в реальном времени</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Technologies Section -->
|
||||||
|
<section class="section technologies">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-badge">Технологии</span>
|
||||||
|
<h2 class="section-title">Умное обучение для современных команд</h2>
|
||||||
|
</div>
|
||||||
|
<div class="tech-grid">
|
||||||
|
<div class="tech-card featured">
|
||||||
|
<div class="tech-visual">
|
||||||
|
<div class="adaptive-diagram">
|
||||||
|
<svg viewBox="0 0 200 150">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="adapt-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#10b981"/>
|
||||||
|
<stop offset="100%" style="stop-color:#6366f1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path class="adapt-path" d="M20 75 Q50 30 80 75 T140 75 T200 75" stroke="url(#adapt-grad)" stroke-width="3" fill="none"/>
|
||||||
|
<circle class="adapt-node" cx="50" cy="55" r="8"/>
|
||||||
|
<circle class="adapt-node" cx="100" cy="75" r="8"/>
|
||||||
|
<circle class="adapt-node" cx="150" cy="55" r="8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3>Динамическая адаптация</h3>
|
||||||
|
<p>Алгоритмы платформы анализируют результаты промежуточных тестов и перестраивают траекторию обучения для каждого сотрудника индивидуально.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tech-card">
|
||||||
|
<div class="tech-visual">
|
||||||
|
<div class="multi-tenant-visual">
|
||||||
|
<div class="tenant tenant-1">Филиал А</div>
|
||||||
|
<div class="tenant tenant-2">Филиал Б</div>
|
||||||
|
<div class="tenant tenant-3">HQ</div>
|
||||||
|
<div class="tenant-center">Центр управления</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3>Мультиарендность</h3>
|
||||||
|
<p>Изолированные личные кабинеты для разных филиалов и отделов с единой точкой администрирования.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tech-card">
|
||||||
|
<div class="tech-visual">
|
||||||
|
<div class="performance-meters">
|
||||||
|
<div class="meter">
|
||||||
|
<div class="meter-fill" style="--fill: 95%"></div>
|
||||||
|
<span>SSR</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter">
|
||||||
|
<div class="meter-fill" style="--fill: 90%"></div>
|
||||||
|
<span>Cache</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter">
|
||||||
|
<div class="meter-fill" style="--fill: 99%"></div>
|
||||||
|
<span>CDN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3>Высокая производительность</h3>
|
||||||
|
<p>Плавная работа на любых устройствах благодаря SSR, умному кэшированию и глобальной CDN-сети.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Security Section -->
|
||||||
|
<section class="section security">
|
||||||
|
<div class="container">
|
||||||
|
<div class="security-content">
|
||||||
|
<div class="security-text">
|
||||||
|
<span class="section-badge">Безопасность</span>
|
||||||
|
<h2 class="section-title">Защита корпоративных данных</h2>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Гибкая ролевая модель доступа и соответствие требованиям
|
||||||
|
информационной безопасности корпоративного сектора.
|
||||||
|
</p>
|
||||||
|
<ul class="security-features">
|
||||||
|
<li>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>RBAC — ролевая модель доступа</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>JWT-токены с ротацией</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Шифрование данных в покое и в транзите</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Audit Log — полный аудит действий</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 4L12 14.01L9 11.01" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Возможность on-premise развертывания</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="security-visual">
|
||||||
|
<div class="shield-container">
|
||||||
|
<svg class="shield-svg" viewBox="0 0 200 240">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="shield-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#6366f1"/>
|
||||||
|
<stop offset="100%" style="stop-color:#10b981"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path class="shield-path" d="M100 10 L180 50 L180 120 Q180 200 100 230 Q20 200 20 120 L20 50 Z" stroke="url(#shield-grad)" stroke-width="4" fill="none"/>
|
||||||
|
<path class="shield-check" d="M70 120 L90 140 L130 100" stroke="url(#shield-grad)" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<div class="shield-glow"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<section class="section cta" id="demo">
|
||||||
|
<div class="container">
|
||||||
|
<div class="cta-content">
|
||||||
|
<h2 class="cta-title">Готовы оцифровать и автоматизировать обучение?</h2>
|
||||||
|
<p class="cta-subtitle">
|
||||||
|
Получите персональную демонстрацию платформы и узнайте,
|
||||||
|
как оптимизировать корпоративное обучение в вашей компании.
|
||||||
|
</p>
|
||||||
|
<form class="cta-form" id="demo-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="text" placeholder="Ваше имя" required>
|
||||||
|
<input type="email" placeholder="Корпоративный email" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="text" placeholder="Компания">
|
||||||
|
<input type="tel" placeholder="Телефон">
|
||||||
|
</div>
|
||||||
|
<textarea placeholder="Расскажите о ваших задачах обучения..." rows="3"></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg btn-full">
|
||||||
|
Запросить демо
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="cta-note">
|
||||||
|
Нажимая кнопку, вы соглашаетесь с
|
||||||
|
<a href="#">политикой конфиденциальности</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<div class="footer-logo">
|
||||||
|
<span class="logo-icon">📚</span>
|
||||||
|
<span class="logo-text">LearnMD</span>
|
||||||
|
</div>
|
||||||
|
<p>Умная платформа корпоративного обучения в формате Markdown</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<h4>Продукт</h4>
|
||||||
|
<a href="#features">Возможности</a>
|
||||||
|
<a href="#">Тарифы</a>
|
||||||
|
<a href="#">Интеграции</a>
|
||||||
|
<a href="#">API</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<h4>Компания</h4>
|
||||||
|
<a href="#">О нас</a>
|
||||||
|
<a href="#">Блог</a>
|
||||||
|
<a href="#">Карьера</a>
|
||||||
|
<a href="#">Контакты</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<h4>Поддержка</h4>
|
||||||
|
<a href="#">Документация</a>
|
||||||
|
<a href="#">FAQ</a>
|
||||||
|
<a href="#">Статус системы</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<h4>Юридическое</h4>
|
||||||
|
<a href="#">Политика конфиденциальности</a>
|
||||||
|
<a href="#">Условия использования</a>
|
||||||
|
<a href="#">SLA</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<p>© 2024 LearnMD. Все права защищены.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function afterRender() {
|
||||||
|
initParticles();
|
||||||
|
initAnimations();
|
||||||
|
initFormHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.scrollToSection = function(id) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function initParticles() {
|
||||||
|
const canvas = document.getElementById('particles-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let particles = [];
|
||||||
|
let animationId;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = canvas.offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParticles() {
|
||||||
|
particles = [];
|
||||||
|
const count = Math.floor((canvas.width * canvas.height) / 15000);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
vx: (Math.random() - 0.5) * 0.5,
|
||||||
|
vy: (Math.random() - 0.5) * 0.5,
|
||||||
|
radius: Math.random() * 2 + 1,
|
||||||
|
opacity: Math.random() * 0.5 + 0.2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
particles.forEach((p, i) => {
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
|
||||||
|
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
|
||||||
|
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(99, 102, 241, ${p.opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
particles.slice(i + 1).forEach(p2 => {
|
||||||
|
const dx = p.x - p2.x;
|
||||||
|
const dy = p.y - p2.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < 120) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
ctx.lineTo(p2.x, p2.y);
|
||||||
|
ctx.strokeStyle = `rgba(99, 102, 241, ${0.15 * (1 - dist / 120)})`;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
animationId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize();
|
||||||
|
createParticles();
|
||||||
|
draw();
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
resize();
|
||||||
|
createParticles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAnimations() {
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('animate-in');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
|
||||||
|
document.querySelectorAll('.section, .simplicity-card, .tech-card, .flow-item').forEach(el => {
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFormHandler() {
|
||||||
|
const form = document.getElementById('demo-form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = 'Отправка...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
|
||||||
|
btn.textContent = 'Заявка отправлена ✓';
|
||||||
|
btn.classList.add('success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('success');
|
||||||
|
form.reset();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
775
frontend/src/views/lessonEditor.js
Normal file
775
frontend/src/views/lessonEditor.js
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
import { currentUser } from '../components/auth.js';
|
||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { loadVditor, initVditorEditor } from '../editors/vditorLoader.js';
|
||||||
|
import { TestEditor } from '../course-builder/TestEditor.js';
|
||||||
|
|
||||||
|
let currentLesson = null;
|
||||||
|
let currentStepIndex = 0;
|
||||||
|
let vditorInstance = null;
|
||||||
|
let testEditorInstance = null;
|
||||||
|
let isDirty = false;
|
||||||
|
let autoSaveTimer = null;
|
||||||
|
let moduleIndex = null;
|
||||||
|
let lessonIndex = null;
|
||||||
|
|
||||||
|
const stepTypeLabels = {
|
||||||
|
theory: 'Теория',
|
||||||
|
practice: 'Практика',
|
||||||
|
lab: 'Лабораторная',
|
||||||
|
test: 'Тест',
|
||||||
|
presentation: 'Презентация'
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepTypeIcons = {
|
||||||
|
theory: '📖',
|
||||||
|
practice: '✍️',
|
||||||
|
lab: '🔬',
|
||||||
|
test: '📝',
|
||||||
|
presentation: '📊'
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function render(params) {
|
||||||
|
if (!currentUser) {
|
||||||
|
return `
|
||||||
|
<div class="auth-required">
|
||||||
|
<h2>Требуется авторизация</h2>
|
||||||
|
<p>Для редактирования урока необходимо войти в систему.</p>
|
||||||
|
<a href="/login" data-link>Войти</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
moduleIndex = parseInt(urlParams.get('moduleIndex'));
|
||||||
|
lessonIndex = parseInt(urlParams.get('lessonIndex'));
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="lesson-editor-page">
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<a href="/course-builder" data-link class="back-link">← К конструктору</a>
|
||||||
|
<div class="lesson-title-input">
|
||||||
|
<input type="text" id="lesson-title" placeholder="Название урока" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="save-status" id="save-status">Сохранено</span>
|
||||||
|
<button class="btn btn-outline" id="preview-btn">👁️ Предпросмотр</button>
|
||||||
|
<button class="btn btn-primary" id="save-lesson-btn">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-body">
|
||||||
|
<aside class="steps-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>Шаги урока</h3>
|
||||||
|
<button class="btn btn-sm btn-primary" id="add-step-btn">+ Добавить</button>
|
||||||
|
</div>
|
||||||
|
<div class="steps-list" id="steps-list">
|
||||||
|
<div class="loading-steps">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="editor-main">
|
||||||
|
<div class="step-editor" id="step-editor">
|
||||||
|
<div class="editor-placeholder">Выберите шаг для редактирования</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function afterRender(params) {
|
||||||
|
const lessonId = parseInt(params.lessonId);
|
||||||
|
|
||||||
|
await loadLesson(lessonId);
|
||||||
|
|
||||||
|
document.getElementById('save-lesson-btn').addEventListener('click', saveLesson);
|
||||||
|
document.getElementById('add-step-btn').addEventListener('click', addNewStep);
|
||||||
|
document.getElementById('preview-btn').addEventListener('click', showPreviewModal);
|
||||||
|
document.getElementById('lesson-title').addEventListener('input', markDirty);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyboard);
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', (e) => {
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
startAutoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLesson(lessonId) {
|
||||||
|
try {
|
||||||
|
console.log('Loading lesson with ID:', lessonId);
|
||||||
|
currentLesson = await coursesAPI.getLesson(lessonId);
|
||||||
|
|
||||||
|
console.log('Loaded lesson:', currentLesson);
|
||||||
|
console.log('Steps count:', currentLesson?.steps?.length);
|
||||||
|
|
||||||
|
if (!currentLesson) {
|
||||||
|
document.getElementById('steps-list').innerHTML = '<p>Урок не найден</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('lesson-title').value = currentLesson.title || '';
|
||||||
|
|
||||||
|
renderStepsList();
|
||||||
|
|
||||||
|
if (currentLesson.steps && currentLesson.steps.length > 0) {
|
||||||
|
console.log('Selecting first step');
|
||||||
|
selectStep(0);
|
||||||
|
} else {
|
||||||
|
console.log('No steps found');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load lesson:', error);
|
||||||
|
document.getElementById('steps-list').innerHTML = `<p>Ошибка загрузки: ${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStepsList() {
|
||||||
|
const container = document.getElementById('steps-list');
|
||||||
|
|
||||||
|
if (!currentLesson.steps || currentLesson.steps.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-steps"><p>Нет шагов</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = currentLesson.steps.map((step, index) => `
|
||||||
|
<div class="step-item ${index === currentStepIndex ? 'active' : ''}"
|
||||||
|
data-step-index="${index}"
|
||||||
|
draggable="true">
|
||||||
|
<div class="step-drag-handle">⋮⋮</div>
|
||||||
|
<div class="step-info">
|
||||||
|
<span class="step-icon">${stepTypeIcons[step.step_type] || '📄'}</span>
|
||||||
|
<span class="step-number">${index + 1}</span>
|
||||||
|
<span class="step-title">${step.title || stepTypeLabels[step.step_type]}</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-actions">
|
||||||
|
<button class="btn-icon delete-step" data-step-index="${index}" title="Удалить">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.step-item').forEach(item => {
|
||||||
|
const index = parseInt(item.dataset.stepIndex);
|
||||||
|
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.classList.contains('delete-step') && !e.target.classList.contains('step-drag-handle')) {
|
||||||
|
selectStep(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
item.querySelector('.delete-step').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteStep(index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
initDragAndDrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectStep(index) {
|
||||||
|
if (index < 0 || index >= currentLesson.steps.length) return;
|
||||||
|
|
||||||
|
if (isDirty && currentStepIndex !== index) {
|
||||||
|
await saveCurrentStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStepIndex = index;
|
||||||
|
renderStepsList();
|
||||||
|
renderStepEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStepEditor() {
|
||||||
|
console.log('renderStepEditor called, currentStepIndex:', currentStepIndex);
|
||||||
|
|
||||||
|
const container = document.getElementById('step-editor');
|
||||||
|
const step = currentLesson.steps[currentStepIndex];
|
||||||
|
|
||||||
|
console.log('Step to render:', step);
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
container.innerHTML = '<div class="editor-placeholder">Выберите шаг для редактирования</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Rendering step editor for step type:', step.step_type);
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="step-editor-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-type">Тип шага</label>
|
||||||
|
<select id="step-type" class="form-control">
|
||||||
|
<option value="theory" ${step.step_type === 'theory' ? 'selected' : ''}>Теория</option>
|
||||||
|
<option value="presentation" ${step.step_type === 'presentation' ? 'selected' : ''}>Презентация (PDF)</option>
|
||||||
|
<option value="practice" ${step.step_type === 'practice' ? 'selected' : ''}>Практика</option>
|
||||||
|
<option value="lab" ${step.step_type === 'lab' ? 'selected' : ''}>Лабораторная</option>
|
||||||
|
<option value="test" ${step.step_type === 'test' ? 'selected' : ''}>Тест</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="step-title-input">Заголовок шага</label>
|
||||||
|
<input type="text" id="step-title-input" class="form-control"
|
||||||
|
value="${step.title || ''}" placeholder="Заголовок шага (опционально)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="step-content-editor">
|
||||||
|
${renderStepContentEditor(step)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('step-type').addEventListener('change', onStepTypeChange);
|
||||||
|
document.getElementById('step-title-input').addEventListener('input', markDirty);
|
||||||
|
|
||||||
|
if (step.step_type === 'theory') {
|
||||||
|
console.log('Initializing Vditor with content:', step.content?.substring(0, 50) + '...');
|
||||||
|
initVditor(step.content || '');
|
||||||
|
} else if (step.step_type === 'presentation') {
|
||||||
|
initPdfUpload();
|
||||||
|
} else if (step.step_type === 'test') {
|
||||||
|
initTestEditor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStepContentEditor(step) {
|
||||||
|
switch (step.step_type) {
|
||||||
|
case 'theory':
|
||||||
|
return `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Содержание</label>
|
||||||
|
<div id="vditor-container"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case 'presentation':
|
||||||
|
return `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PDF файл презентации</label>
|
||||||
|
<div class="file-upload-area" id="pdf-upload-area">
|
||||||
|
${step.file_url ? `
|
||||||
|
<div class="file-preview">
|
||||||
|
<span class="file-name">${step.file_url.split('/').pop()}</span>
|
||||||
|
<button class="btn btn-sm btn-outline" id="change-pdf-btn">Изменить</button>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="upload-prompt">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" stroke-width="2"/>
|
||||||
|
<polyline points="14,2 14,8 20,8" stroke-width="2"/>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="12" stroke-width="2"/>
|
||||||
|
<polyline points="9,15 12,12 15,15" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<p>Перетащите PDF файл сюда или</p>
|
||||||
|
<input type="file" id="pdf-file-input" accept=".pdf" style="display: none;">
|
||||||
|
<button class="btn btn-outline" id="select-pdf-btn">Выбрать файл</button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case 'test':
|
||||||
|
return `<div id="test-editor-container"></div>`;
|
||||||
|
default:
|
||||||
|
return `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Содержание</label>
|
||||||
|
<textarea id="step-content-textarea" class="form-control" rows="10"
|
||||||
|
placeholder="Введите содержание шага">${step.content || ''}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStepTypeChange(e) {
|
||||||
|
const newType = e.target.value;
|
||||||
|
const step = currentLesson.steps[currentStepIndex];
|
||||||
|
|
||||||
|
if (step.step_type === 'theory' && vditorInstance) {
|
||||||
|
step.content = vditorInstance.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
step.step_type = newType;
|
||||||
|
step.content = '';
|
||||||
|
step.file_url = null;
|
||||||
|
|
||||||
|
document.getElementById('step-content-editor').innerHTML = renderStepContentEditor(step);
|
||||||
|
|
||||||
|
if (newType === 'theory') {
|
||||||
|
initVditor('');
|
||||||
|
} else if (newType === 'presentation') {
|
||||||
|
initPdfUpload();
|
||||||
|
} else if (newType === 'test') {
|
||||||
|
initTestEditor();
|
||||||
|
} else {
|
||||||
|
document.getElementById('step-content-textarea')?.addEventListener('input', markDirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVditor(initialContent) {
|
||||||
|
if (typeof Vditor === 'undefined') {
|
||||||
|
loadVditor().then(() => createVditor(initialContent));
|
||||||
|
} else {
|
||||||
|
createVditor(initialContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVditor(initialContent) {
|
||||||
|
console.log('createVditor called with content length:', initialContent?.length);
|
||||||
|
|
||||||
|
if (vditorInstance) {
|
||||||
|
console.log('Destroying previous vditor instance');
|
||||||
|
vditorInstance.destroy();
|
||||||
|
vditorInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('vditor-container');
|
||||||
|
if (!container) {
|
||||||
|
console.error('vditor-container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Creating new Vditor instance');
|
||||||
|
vditorInstance = new Vditor('vditor-container', {
|
||||||
|
height: 500,
|
||||||
|
placeholder: 'Введите содержание шага...',
|
||||||
|
theme: 'classic',
|
||||||
|
lang: 'ru_RU',
|
||||||
|
value: initialContent || '',
|
||||||
|
cache: { enable: false },
|
||||||
|
mode: 'wysiwyg',
|
||||||
|
toolbar: [
|
||||||
|
'headings', 'bold', 'italic', 'strike', '|',
|
||||||
|
'list', 'ordered-list', 'check', '|',
|
||||||
|
'quote', 'code', 'inline-code', '|',
|
||||||
|
'table', '|',
|
||||||
|
'undo', 'redo', '|',
|
||||||
|
'edit-mode', 'preview', 'fullscreen', 'help'
|
||||||
|
],
|
||||||
|
preview: {
|
||||||
|
hljs: { enable: true, lineNumber: true },
|
||||||
|
markdown: { toc: true }
|
||||||
|
},
|
||||||
|
counter: { enable: true },
|
||||||
|
input: () => {
|
||||||
|
console.log('Vditor input event');
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Vditor instance created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPdfUpload() {
|
||||||
|
const uploadArea = document.getElementById('pdf-upload-area');
|
||||||
|
const fileInput = document.getElementById('pdf-file-input');
|
||||||
|
const selectBtn = document.getElementById('select-pdf-btn');
|
||||||
|
|
||||||
|
if (!uploadArea || !fileInput) return;
|
||||||
|
|
||||||
|
selectBtn?.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragleave', () => {
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file && file.type === 'application/pdf') {
|
||||||
|
handlePdfUpload(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) handlePdfUpload(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePdfUpload(file) {
|
||||||
|
console.log('handlePdfUpload called, file:', file.name, 'size:', file.size);
|
||||||
|
|
||||||
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
alert('Файл слишком большой (максимум 50 МБ)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Uploading PDF file...');
|
||||||
|
const response = await coursesAPI.uploadFile(file);
|
||||||
|
console.log('Upload response:', response);
|
||||||
|
|
||||||
|
if (response && response.url) {
|
||||||
|
currentLesson.steps[currentStepIndex].file_url = response.url;
|
||||||
|
renderStepEditor();
|
||||||
|
markDirty();
|
||||||
|
} else {
|
||||||
|
throw new Error('Неверный ответ сервера');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert('Ошибка загрузки файла: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initTestEditor() {
|
||||||
|
console.log('[lessonEditor] initTestEditor called');
|
||||||
|
const container = document.getElementById('test-editor-container');
|
||||||
|
console.log('[lessonEditor] Container:', container);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const step = currentLesson.steps[currentStepIndex];
|
||||||
|
console.log('[lessonEditor] Current step:', step);
|
||||||
|
if (!step || !step.id) {
|
||||||
|
console.warn('[lessonEditor] Step not saved yet');
|
||||||
|
container.innerHTML = '<p class="test-placeholder">Сохраните шаг, чтобы редактировать тест</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[lessonEditor] Creating TestEditor for step', step.id);
|
||||||
|
|
||||||
|
testEditorInstance = new TestEditor(step.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[lessonEditor] Loading questions...');
|
||||||
|
await testEditorInstance.loadQuestions();
|
||||||
|
console.log('[lessonEditor] Questions loaded, rendering...');
|
||||||
|
testEditorInstance.render(container);
|
||||||
|
console.log('[lessonEditor] TestEditor initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[lessonEditor] Failed to load test editor:', error);
|
||||||
|
container.innerHTML = '<p class="test-error">Ошибка загрузки редактора теста: ' + error.message + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
isDirty = true;
|
||||||
|
document.getElementById('save-status').textContent = 'Не сохранено';
|
||||||
|
document.getElementById('save-status').classList.add('dirty');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markClean() {
|
||||||
|
isDirty = false;
|
||||||
|
document.getElementById('save-status').textContent = 'Сохранено';
|
||||||
|
document.getElementById('save-status').classList.remove('dirty');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrentStep() {
|
||||||
|
const step = currentLesson.steps[currentStepIndex];
|
||||||
|
if (!step) return;
|
||||||
|
|
||||||
|
if (step.step_type === 'theory' && vditorInstance) {
|
||||||
|
step.content = vditorInstance.getValue();
|
||||||
|
} else if (step.step_type !== 'presentation' && step.step_type !== 'theory') {
|
||||||
|
const textarea = document.getElementById('step-content-textarea');
|
||||||
|
if (textarea) step.content = textarea.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleInput = document.getElementById('step-title-input');
|
||||||
|
if (titleInput) step.title = titleInput.value;
|
||||||
|
|
||||||
|
const typeSelect = document.getElementById('step-type');
|
||||||
|
if (typeSelect) step.step_type = typeSelect.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (step.id) {
|
||||||
|
await coursesAPI.updateStep(step.id, {
|
||||||
|
type: step.step_type,
|
||||||
|
title: step.title,
|
||||||
|
content: step.content,
|
||||||
|
file_url: step.file_url,
|
||||||
|
order: step.order_index
|
||||||
|
});
|
||||||
|
markClean();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save step:', error);
|
||||||
|
alert('Ошибка сохранения шага: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLesson() {
|
||||||
|
await saveCurrentStep();
|
||||||
|
|
||||||
|
const title = document.getElementById('lesson-title').value.trim();
|
||||||
|
if (!title) {
|
||||||
|
alert('Введите название урока');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentLesson.id) {
|
||||||
|
await coursesAPI.updateLesson(currentLesson.id, { title });
|
||||||
|
}
|
||||||
|
|
||||||
|
markClean();
|
||||||
|
alert('Урок сохранен');
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка сохранения: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNewStep() {
|
||||||
|
const newStep = {
|
||||||
|
step_type: 'theory',
|
||||||
|
title: null,
|
||||||
|
content: '',
|
||||||
|
file_url: null,
|
||||||
|
order_index: currentLesson.steps ? currentLesson.steps.length : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdStep = await coursesAPI.createStep({
|
||||||
|
lesson_id: currentLesson.id,
|
||||||
|
type: newStep.step_type,
|
||||||
|
title: newStep.title,
|
||||||
|
content: newStep.content,
|
||||||
|
order: newStep.order_index
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createdStep) {
|
||||||
|
newStep.id = createdStep.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentLesson.steps) currentLesson.steps = [];
|
||||||
|
currentLesson.steps.push(newStep);
|
||||||
|
|
||||||
|
renderStepsList();
|
||||||
|
selectStep(currentLesson.steps.length - 1);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка создания шага: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStep(index) {
|
||||||
|
if (!confirm('Удалить этот шаг?')) return;
|
||||||
|
|
||||||
|
const step = currentLesson.steps[index];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (step.id) {
|
||||||
|
await coursesAPI.deleteStep(step.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLesson.steps.splice(index, 1);
|
||||||
|
|
||||||
|
currentLesson.steps.forEach((s, i) => {
|
||||||
|
s.order_index = i;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentStepIndex >= currentLesson.steps.length) {
|
||||||
|
currentStepIndex = Math.max(0, currentLesson.steps.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStepsList();
|
||||||
|
|
||||||
|
if (currentLesson.steps.length > 0) {
|
||||||
|
selectStep(currentStepIndex);
|
||||||
|
} else {
|
||||||
|
document.getElementById('step-editor').innerHTML = '<div class="editor-placeholder">Нет шагов</div>';
|
||||||
|
document.getElementById('preview-content').innerHTML = '<div class="preview-placeholder">Предпросмотр появится здесь</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка удаления шага: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPreviewModal() {
|
||||||
|
const step = currentLesson.steps[currentStepIndex];
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
alert('Выберите шаг для предпросмотра');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
if (step.step_type === 'theory') {
|
||||||
|
if (vditorInstance) {
|
||||||
|
content = vditorInstance.getHTML();
|
||||||
|
} else if (step.content_html) {
|
||||||
|
content = step.content_html;
|
||||||
|
} else {
|
||||||
|
content = `<div class="markdown-content">${step.content || '<p>Нет содержимого</p>'}</div>`;
|
||||||
|
}
|
||||||
|
} else if (step.step_type === 'presentation' && step.file_url) {
|
||||||
|
content = `
|
||||||
|
<div class="pdf-preview-container">
|
||||||
|
<div class="pdf-preview-note">
|
||||||
|
<span class="file-icon">📊</span>
|
||||||
|
<span class="file-name">${step.file_url.split('/').pop()}</span>
|
||||||
|
</div>
|
||||||
|
<p class="pdf-preview-hint">PDF файл будет отображён в режиме просмотра курса</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
content = `<div class="markdown-content">${step.content || '<p>Нет содержимого</p>'}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal-overlay" id="preview-modal">
|
||||||
|
<div class="modal" style="max-width: 900px; max-height: 90vh;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Предпросмотр шага</h3>
|
||||||
|
<button class="modal-close" id="close-preview-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="max-height: calc(90vh - 120px); overflow-y: auto;">
|
||||||
|
<div class="preview-step">
|
||||||
|
<div class="preview-step-header">
|
||||||
|
<span class="preview-step-type">${stepTypeIcons[step.step_type]} ${stepTypeLabels[step.step_type]}</span>
|
||||||
|
<h4>${step.title || 'Без названия'}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="preview-step-body">
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="close-preview-btn">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = document.getElementById('preview-modal');
|
||||||
|
|
||||||
|
document.getElementById('close-preview-modal').addEventListener('click', () => modal.remove());
|
||||||
|
document.getElementById('close-preview-btn').addEventListener('click', () => modal.remove());
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof renderMathInElement !== 'undefined') {
|
||||||
|
renderMathInElement(modal.querySelector('.preview-step-body'), {
|
||||||
|
delimiters: [
|
||||||
|
{left: '$$', right: '$$', display: true},
|
||||||
|
{left: '$', right: '$', display: false}
|
||||||
|
],
|
||||||
|
throwOnError: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDragAndDrop() {
|
||||||
|
const container = document.getElementById('steps-list');
|
||||||
|
const items = container.querySelectorAll('.step-item');
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
item.addEventListener('dragstart', handleDragStart);
|
||||||
|
item.addEventListener('dragover', handleDragOver);
|
||||||
|
item.addEventListener('drop', handleDrop);
|
||||||
|
item.addEventListener('dragend', handleDragEnd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let draggedItem = null;
|
||||||
|
|
||||||
|
function handleDragStart(e) {
|
||||||
|
draggedItem = this;
|
||||||
|
this.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
const afterElement = getDragAfterElement(this.parentElement, e.clientY);
|
||||||
|
if (afterElement == null) {
|
||||||
|
this.parentElement.appendChild(draggedItem);
|
||||||
|
} else {
|
||||||
|
this.parentElement.insertBefore(draggedItem, afterElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const items = Array.from(this.parentElement.querySelectorAll('.step-item'));
|
||||||
|
const newOrder = items.map(item => parseInt(item.dataset.stepIndex));
|
||||||
|
|
||||||
|
const reorderedSteps = newOrder.map(oldIndex => currentLesson.steps[oldIndex]);
|
||||||
|
currentLesson.steps = reorderedSteps;
|
||||||
|
|
||||||
|
currentLesson.steps.forEach((step, index) => {
|
||||||
|
step.order_index = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentStepId = currentLesson.steps[currentStepIndex]?.id;
|
||||||
|
currentStepIndex = currentLesson.steps.findIndex(s => s.id === currentStepId);
|
||||||
|
|
||||||
|
renderStepsList();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(e) {
|
||||||
|
this.classList.remove('dragging');
|
||||||
|
draggedItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDragAfterElement(container, y) {
|
||||||
|
const draggableElements = [...container.querySelectorAll('.step-item:not(.dragging)')];
|
||||||
|
|
||||||
|
return draggableElements.reduce((closest, child) => {
|
||||||
|
const box = child.getBoundingClientRect();
|
||||||
|
const offset = y - box.top - box.height / 2;
|
||||||
|
|
||||||
|
if (offset < 0 && offset > closest.offset) {
|
||||||
|
return { offset: offset, element: child };
|
||||||
|
} else {
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoSave() {
|
||||||
|
autoSaveTimer = setInterval(() => {
|
||||||
|
if (isDirty) {
|
||||||
|
saveCurrentStep();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyboard(e) {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveLesson();
|
||||||
|
} else if (e.key === 'n') {
|
||||||
|
e.preventDefault();
|
||||||
|
addNewStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp' && e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectStep(Math.max(0, currentStepIndex - 1));
|
||||||
|
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectStep(Math.min(currentLesson.steps.length - 1, currentStepIndex + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanup() {
|
||||||
|
if (autoSaveTimer) clearInterval(autoSaveTimer);
|
||||||
|
if (vditorInstance) vditorInstance.destroy();
|
||||||
|
document.removeEventListener('keydown', handleKeyboard);
|
||||||
|
}
|
||||||
136
frontend/src/views/login.js
Normal file
136
frontend/src/views/login.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { login, register } from '../components/auth.js';
|
||||||
|
import { router } from '../router.js';
|
||||||
|
|
||||||
|
export async function render() {
|
||||||
|
return `
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-container">
|
||||||
|
<h2>С возвращением</h2>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="login">Вход</button>
|
||||||
|
<button class="tab-btn" data-tab="register">Регистрация</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<div id="login-form" class="form-container active">
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="login-email">Эл. почта</label>
|
||||||
|
<input type="email" id="login-email" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="login-password">Пароль</label>
|
||||||
|
<input type="password" id="login-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Войти</button>
|
||||||
|
</form>
|
||||||
|
<div id="login-message" class="message"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="register-form" class="form-container">
|
||||||
|
<form id="registerForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="register-email">Эл. почта</label>
|
||||||
|
<input type="email" id="register-email" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="register-password">Пароль (минимум 8 символов)</label>
|
||||||
|
<input type="password" id="register-password" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="register-confirm">Подтверждение пароля</label>
|
||||||
|
<input type="password" id="register-confirm" minlength="8" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Зарегистрироваться</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="register-message" class="message global-message"></div>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<a href="/" data-link>← Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function afterRender() {
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const tab = button.dataset.tab;
|
||||||
|
|
||||||
|
// Update active tab button
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
|
||||||
|
// Show active form
|
||||||
|
document.querySelectorAll('.form-container').forEach(form => form.classList.remove('active'));
|
||||||
|
document.getElementById(`${tab}-form`).classList.add('active');
|
||||||
|
|
||||||
|
// Clear messages when switching tabs (except global registration message)
|
||||||
|
document.getElementById('login-message').textContent = '';
|
||||||
|
// Don't clear register-message if it contains success message
|
||||||
|
const registerMsg = document.getElementById('register-message');
|
||||||
|
if (!registerMsg.textContent.includes('✅')) {
|
||||||
|
registerMsg.textContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login form submission
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('login-email').value;
|
||||||
|
const password = document.getElementById('login-password').value;
|
||||||
|
const messageEl = document.getElementById('login-message');
|
||||||
|
|
||||||
|
messageEl.textContent = 'Вход в систему...';
|
||||||
|
messageEl.className = 'message info';
|
||||||
|
|
||||||
|
const result = await login(email, password);
|
||||||
|
if (result.success) {
|
||||||
|
messageEl.textContent = '✅ Вход выполнен успешно! Перенаправление...';
|
||||||
|
messageEl.className = 'message success';
|
||||||
|
setTimeout(() => router.navigate('/'), 1000);
|
||||||
|
} else {
|
||||||
|
messageEl.textContent = `❌ Ошибка: ${result.error}`;
|
||||||
|
messageEl.className = 'message error';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register form submission
|
||||||
|
document.getElementById('registerForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('register-email').value;
|
||||||
|
const password = document.getElementById('register-password').value;
|
||||||
|
const confirmPassword = document.getElementById('register-confirm').value;
|
||||||
|
const messageEl = document.getElementById('register-message');
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
messageEl.textContent = '❌ Пароли не совпадают';
|
||||||
|
messageEl.className = 'message error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messageEl.textContent = 'Создание аккаунта...';
|
||||||
|
messageEl.className = 'message info';
|
||||||
|
|
||||||
|
const result = await register(email, password);
|
||||||
|
if (result.success) {
|
||||||
|
messageEl.textContent = '✅ Регистрация успешна! Теперь вы можете войти в систему.';
|
||||||
|
messageEl.className = 'message success global-message';
|
||||||
|
// Switch to login tab
|
||||||
|
document.querySelector('.tab-btn[data-tab="login"]').click();
|
||||||
|
document.getElementById('login-email').value = email;
|
||||||
|
document.getElementById('login-password').value = '';
|
||||||
|
// Clear login message
|
||||||
|
document.getElementById('login-message').textContent = '';
|
||||||
|
} else {
|
||||||
|
messageEl.textContent = `❌ Ошибка: ${result.error}`;
|
||||||
|
messageEl.className = 'message error global-message';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
120
frontend/src/views/progress.js
Normal file
120
frontend/src/views/progress.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { coursesAPI } from '../api/courses.js';
|
||||||
|
import { currentUser } from '../components/auth.js';
|
||||||
|
|
||||||
|
export async function render() {
|
||||||
|
if (!currentUser) {
|
||||||
|
return `
|
||||||
|
<div class="auth-required">
|
||||||
|
<h2>Authentication Required</h2>
|
||||||
|
<p>Please <a href="/login" data-link>login</a> to view your progress.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch progress for all courses
|
||||||
|
const allCourses = await coursesAPI.getAll();
|
||||||
|
const progressPromises = allCourses.map(async (course) => {
|
||||||
|
const progress = await coursesAPI.getProgress(course.id);
|
||||||
|
return { course, progress };
|
||||||
|
});
|
||||||
|
|
||||||
|
const courseProgress = await Promise.all(progressPromises);
|
||||||
|
const coursesWithProgress = courseProgress.filter(cp => cp.progress);
|
||||||
|
const coursesWithoutProgress = courseProgress.filter(cp => !cp.progress);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="progress-page">
|
||||||
|
<h1>My Learning Progress</h1>
|
||||||
|
|
||||||
|
${coursesWithProgress.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>You haven't started any courses yet.</p>
|
||||||
|
<a href="/courses" class="btn" data-link>Browse Courses</a>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="progress-list">
|
||||||
|
${coursesWithProgress.map(({ course, progress }) => {
|
||||||
|
const percentage = progress.percentage ||
|
||||||
|
(progress.completed_lessons / progress.total_lessons * 100);
|
||||||
|
return `
|
||||||
|
<div class="progress-item" data-course-id="${course.id}">
|
||||||
|
<div class="progress-header">
|
||||||
|
<h3>${course.title}</h3>
|
||||||
|
<span class="progress-percentage">${percentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: ${percentage}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-details">
|
||||||
|
<span>${progress.completed_lessons} of ${progress.total_lessons} lessons completed</span>
|
||||||
|
<button class="btn-update-progress" data-course-id="${course.id}">
|
||||||
|
Update Progress
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
${coursesWithoutProgress.length > 0 ? `
|
||||||
|
<div class="available-courses">
|
||||||
|
<h2>Available Courses</h2>
|
||||||
|
<div class="courses-grid">
|
||||||
|
${coursesWithoutProgress.slice(0, 3).map(({ course }) => `
|
||||||
|
<div class="course-card small">
|
||||||
|
<h4>${course.title}</h4>
|
||||||
|
<span class="course-level ${course.level}">${course.level}</span>
|
||||||
|
<button class="btn-start-course" data-course-id="${course.id}">
|
||||||
|
Start Learning
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function afterRender() {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
// Add event listeners for update progress buttons
|
||||||
|
document.querySelectorAll('.btn-update-progress').forEach(button => {
|
||||||
|
button.addEventListener('click', async (e) => {
|
||||||
|
const courseId = parseInt(e.target.dataset.courseId);
|
||||||
|
const completed = prompt('Enter number of completed lessons:');
|
||||||
|
const total = prompt('Enter total number of lessons:');
|
||||||
|
|
||||||
|
if (completed && total) {
|
||||||
|
try {
|
||||||
|
await coursesAPI.updateProgress(courseId, parseInt(completed), parseInt(total));
|
||||||
|
alert('Progress updated successfully!');
|
||||||
|
location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to update progress: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners for start course buttons
|
||||||
|
document.querySelectorAll('.btn-start-course').forEach(button => {
|
||||||
|
button.addEventListener('click', async (e) => {
|
||||||
|
const courseId = parseInt(e.target.dataset.courseId);
|
||||||
|
const completed = 0;
|
||||||
|
const total = prompt('Enter total number of lessons for this course:');
|
||||||
|
|
||||||
|
if (total) {
|
||||||
|
try {
|
||||||
|
await coursesAPI.updateProgress(courseId, completed, parseInt(total));
|
||||||
|
alert('Course started! You can now track your progress.');
|
||||||
|
location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to start course: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
254
plan130226.md
Normal file
254
plan130226.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# План доработок конструктора курса
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Текущее состояние
|
||||||
|
|
||||||
|
### ✅ Реализовано:
|
||||||
|
- Форма создания курса (название, описание, уровень)
|
||||||
|
- Vditor редактор с русской локализацией и поддержкой KaTeX
|
||||||
|
- Структура модулей (добавление, редактирование, удаление)
|
||||||
|
- Автосохранение в localStorage
|
||||||
|
- Загрузка файлов на бэкенд
|
||||||
|
- Рендеринг markdown на сервере
|
||||||
|
|
||||||
|
### ❌ Не реализовано:
|
||||||
|
- Создание/сохранение курсов через API (publishCourse — демо)
|
||||||
|
- Загрузка существующего курса для редактирования
|
||||||
|
- Синхронизация модулей с БД (только localStorage)
|
||||||
|
- API для создания модулей, уроков, шагов
|
||||||
|
- Валидация контента перед публикацией
|
||||||
|
- Управление черновиками (несколько курсов)
|
||||||
|
- Восстановление черновиков
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 ЭТАП 1: Базовый функционал создания курсов (ПРИОРИТЕТ)
|
||||||
|
|
||||||
|
### Задачи:
|
||||||
|
|
||||||
|
#### 1.1 **API для создания курса**
|
||||||
|
```
|
||||||
|
POST /api/course-builder/create
|
||||||
|
- title, description, level, content (markdown)
|
||||||
|
- response: Course с id
|
||||||
|
```
|
||||||
|
|
||||||
|
| Backend | Схема | Priority |
|
||||||
|
|---------|-------|----------|
|
||||||
|
| Создать endpoint в `routes/course_builder.py` | `CourseCreateRequest` schema | P0 |
|
||||||
|
| Реализовать CRUD операции в `crud.py` | `create_course_with_content()` | P0 |
|
||||||
|
- Связать с автором (current_user.id)
|
||||||
|
|
||||||
|
#### 1.2 **Фронтенд: Реализовать publishCourse**
|
||||||
|
```
|
||||||
|
- Заменить демо на API вызов
|
||||||
|
- Сохранение структуры модулей
|
||||||
|
- Очистка формы после успеха
|
||||||
|
```
|
||||||
|
|
||||||
|
| Компонент | Функция | Priority |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| `courseBuilder.js`, publishCourse() | API вызов на `/api/course-builder/create` | P0 |
|
||||||
|
| | Отправка structure.modules | P0 |
|
||||||
|
- Обработка ошибок (ошибки валидации)
|
||||||
|
|
||||||
|
#### 1.3 **API для сохранения черновика**
|
||||||
|
```
|
||||||
|
POST /api/course-builder/save-draft
|
||||||
|
- content (markdown)
|
||||||
|
- structure (modules, lessons, steps)
|
||||||
|
- duration
|
||||||
|
- response: { draft_id, saved_at }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Endpoint | CRUD | Priority |
|
||||||
|
|----------|------|----------|
|
||||||
|
| Добавить в `course_builder.py` | `save_draft()` | P1 |
|
||||||
|
- Связать с current_user
|
||||||
|
|
||||||
|
#### 1.4 **API для загрузки черновика**
|
||||||
|
```
|
||||||
|
GET /api/course-builder/drafts
|
||||||
|
GET /api/course-builder/drafts/{draft_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 ЭТАП 2: Управление структурой курса
|
||||||
|
|
||||||
|
### Задачи:
|
||||||
|
|
||||||
|
#### 2.1 **API для создания модулей**
|
||||||
|
```
|
||||||
|
POST /api/course-builder/modules
|
||||||
|
- course_id, title, description, duration, order_index
|
||||||
|
- response: Module
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 **API для создания уроков**
|
||||||
|
```
|
||||||
|
POST /api/course-builder/lessons
|
||||||
|
- module_id, title, description, order_index
|
||||||
|
- response: Lesson
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 **API для создания шагов**
|
||||||
|
```
|
||||||
|
POST /api/course-builder/steps
|
||||||
|
- lesson_id, step_type, title, content, order_index
|
||||||
|
- response: Step
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.4 **Frontend: Синхронизация с БД**
|
||||||
|
```
|
||||||
|
- Сохранять module/lesson после создания
|
||||||
|
- Обновлять courseStructure при редактировании
|
||||||
|
- Удалять из БД при удалении модуля
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 ЭТАП 3: Редактирование существующих курсов
|
||||||
|
|
||||||
|
### Задачи:
|
||||||
|
|
||||||
|
#### 3.1 **API для загрузки курса для редактирования**
|
||||||
|
```
|
||||||
|
GET /api/course-builder/courses/{course_id}/edit
|
||||||
|
- return: { title, description, level, content, structure }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 **Frontend: Загрузка существующего курса**
|
||||||
|
```
|
||||||
|
- Добавить параметр ?course_id=123 в route
|
||||||
|
- Запрос данных при входе
|
||||||
|
- Заполнять форму и редактор
|
||||||
|
- Отображать структуру
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3 **API для обновления курса**
|
||||||
|
```
|
||||||
|
PUT /api/course-builder/courses/{course_id}
|
||||||
|
- обновление: title, description, level, content
|
||||||
|
- response: Course
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4 **API для обновления модулей/уроков**
|
||||||
|
```
|
||||||
|
PUT /api/course-builder/modules/{module_id}
|
||||||
|
PUT /api/course-builder/lessons/{lesson_id}
|
||||||
|
PUT /api/course-builder/steps/{step_id}
|
||||||
|
DELETE /api/{modules|lessons|steps}/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 ЭТАП 4: Валидация и безопасность
|
||||||
|
|
||||||
|
### Задачи:
|
||||||
|
|
||||||
|
#### 4.1 **Валидация на бэкенде**
|
||||||
|
```
|
||||||
|
- Схемы Pydantic для всех API запросов
|
||||||
|
- Проверка обязательных полей
|
||||||
|
- Ограничение длины полей
|
||||||
|
- Валидация содержимого markdown (размер)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 **Проверки доступа**
|
||||||
|
```
|
||||||
|
- Проверка авторства курса при редактировании
|
||||||
|
- Доступ только к своим的课程
|
||||||
|
- Проверка авторизации при всех операциях
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.3 **Rate limiting**
|
||||||
|
```
|
||||||
|
- Ограничение количества создаваемых курсов
|
||||||
|
- Ограничение размера загружаемых файлов
|
||||||
|
- Ограничение количества модулей/уроков
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 ЭТАП 5: Улучшения UX
|
||||||
|
|
||||||
|
### Задачи:
|
||||||
|
|
||||||
|
#### 5.1 **Управление черновиками**
|
||||||
|
```
|
||||||
|
DELETE /api/course-builder/drafts/{id}
|
||||||
|
GET /api/course-builder/drafts (с пагинацией)
|
||||||
|
- Фронтенд: список черновиков
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 **Автосохранение с индикацией**
|
||||||
|
```
|
||||||
|
- Индикатор сохранения (last saved at...)
|
||||||
|
- Лайв preview контента
|
||||||
|
- Быстрая вставка модулей (drag & drop)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.3 **Медиа библиотека**
|
||||||
|
```
|
||||||
|
- Список загруженных файлов
|
||||||
|
- Вставка в редактор по клику
|
||||||
|
- Управление файлами (удаление)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 ЭТАП 6: Продвинутые функции (опционально)
|
||||||
|
|
||||||
|
### Задачи:
|
||||||
|
|
||||||
|
#### 6.1 **Клонирование курсов**
|
||||||
|
```
|
||||||
|
POST /api/course-builder/courses/{id}/clone
|
||||||
|
- Создает копию курса
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.2 **Импорт/экспорт**
|
||||||
|
```
|
||||||
|
- Экспорт курса в JSON/ZIP
|
||||||
|
- Импорт курса из файла
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3 **Коллаборация** (будущее)
|
||||||
|
```
|
||||||
|
- Совместная редактирование
|
||||||
|
- История изменений
|
||||||
|
- Версионирование контента
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Рекомендуемый порядок реализации
|
||||||
|
|
||||||
|
| Этап | Приоритет | Сложность | Ожидаемое время |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Этап 1.1** | P0 | Низкая | 2-3 часа |
|
||||||
|
| **Этап 1.2** | P0 | Средняя | 2-3 часа |
|
||||||
|
| **Этап 1.3** | P1 | Низкая | 1-2 часа |
|
||||||
|
| **Этап 1.4** | P1 | Низкая | 1 час |
|
||||||
|
| **Этап 2** | P1 | Средняя | 3-4 часа |
|
||||||
|
| **Этап 3** | P2 | Средняя | 4-5 часов |
|
||||||
|
| **Этап 4** | P2 | Средняя | 2-3 часа |
|
||||||
|
| **Этап 5** | P3 | Средняя | 4-5 часов |
|
||||||
|
| **Этап 6** | P4 | Высокая | TBD |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ Вопросы для уточнения
|
||||||
|
|
||||||
|
1. **С приоритетом хотите начать?**
|
||||||
|
- С минимального функционала (быстро, базовое)
|
||||||
|
- С полного функционала (медленно, полноценно)
|
||||||
|
|
||||||
|
2. **Нужно ли:**
|
||||||
|
- Версионирование редакций курса?
|
||||||
|
- Автосохранение с откатом?
|
||||||
|
- Публикация без модерации?
|
||||||
|
|
||||||
|
Соблюдение одного этапа за один раз или сразу запускать полную разработку?
|
||||||
1038
veditor.md
Normal file
1038
veditor.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user