Старт

This commit is contained in:
2026-03-13 14:39:43 +08:00
commit a2cc480644
88 changed files with 18526 additions and 0 deletions

36
.env.example Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,338 @@
# Auth Learning App
<div align="center">
![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=for-the-badge&logo=postgresql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2CA5E0?style=for-the-badge&logo=docker&logoColor=white)
![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black)
![Nginx](https://img.shields.io/badge/Nginx-009639?style=for-the-badge&logo=nginx&logoColor=white)
**Полнофункциональное 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
View 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)

View 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
View 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
View 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
View 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()

View 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"}

View 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')

View 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')

View 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')

View 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')

View 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')

View File

@@ -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')

View 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'))")

View 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
View 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
View 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
View 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
View 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
View 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"}

View 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
View 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")

View 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
View 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
View 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"
)

View 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"
)

View 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})

View 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
View 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})

View 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"}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

67
frontend/nginx.conf Normal file
View 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";
}
}
}

View 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
View 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 || [];
}
};

View 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();
}
}
}

View 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();
};

View 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');
};
}
}

View 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();
}

View 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
};

View 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 = 'Опубликовать курс';
}
}

View 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;
}
}

View 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'); }
}

View 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'); }
}

View 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 };

View 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');
}
}

View 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');
}

View 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); }
}
}

View 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);
}
}
}

View 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';

View 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
});

View 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 };

View 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
View 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
View 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>';
}
}
};

View 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;
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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
View 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');
}

View File

@@ -0,0 +1,2 @@
export const API_BASE_URL = window.location.origin;
export const APP_NAME = 'Auth Learning App';

View 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); }
}
});

View 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);
}
}
});
}
}

View 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);
}

View 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);
}
});
});
}

View 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();
}

View 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
View 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>&copy; 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);
});
}

View 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
View 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';
}
});
}

View 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
View 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. **Нужно ли:**
- Версионирование редакций курса?
- Автосохранение с откатом?
- Публикация без модерации?
Соблюдение одного этапа за один раз или сразу запускать полную разработку?

1111
v2.md Normal file

File diff suppressed because it is too large Load Diff

1038
veditor.md Normal file

File diff suppressed because it is too large Load Diff