commit a2cc480644fc5dd95c7ac6f0edf565db11e6ae32 Author: Kedrin Victor Date: Fri Mar 13 14:39:43 2026 +0800 Старт diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..42447d1 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..075e26a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/FIX_INSTRUCTIONS.md b/FIX_INSTRUCTIONS.md new file mode 100644 index 0000000..91708c5 --- /dev/null +++ b/FIX_INSTRUCTIONS.md @@ -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 +``` \ No newline at end of file diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..2a8a7cf --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,126 @@ +# Быстрый старт Auth Learning App + +## Требования +- Docker 20.10+ (запущенный) +- Docker Compose 2.0+ + +## Установка и запуск + +1. **Клонируйте проект** (если нужно): + ```bash + git clone + 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) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dac1d3 --- /dev/null +++ b/README.md @@ -0,0 +1,338 @@ +# Auth Learning App + +
+ +![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 приложение для обучения с системой авторизации, избранными курсами и отслеживанием прогресса** + +
+ +## 📋 О проекте + +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 + 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 + +--- + +
+ +**Сделано с ❤️ для обучения современным веб-технологиям** + +
\ No newline at end of file diff --git a/START_AFTER_FIX.md b/START_AFTER_FIX.md new file mode 100644 index 0000000..dbd86b1 --- /dev/null +++ b/START_AFTER_FIX.md @@ -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) \ No newline at end of file diff --git a/arch-node-recommendations.md b/arch-node-recommendations.md new file mode 100644 index 0000000..a638777 --- /dev/null +++ b/arch-node-recommendations.md @@ -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 ` +- **Хеширование паролей**: 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; + 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 { + // Проверка существования пользователя + 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) обеспечивает хороший баланс между производительностью, поддерживаемостью и скоростью разработки, делая его подходящим выбором для данного проекта. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8fd519a --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..4dc8e03 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..8d0d20f --- /dev/null +++ b/backend/alembic/env.py @@ -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() \ No newline at end of file diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..c80eca5 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} \ No newline at end of file diff --git a/backend/alembic/versions/001_initial_migration.py b/backend/alembic/versions/001_initial_migration.py new file mode 100644 index 0000000..02d8a70 --- /dev/null +++ b/backend/alembic/versions/001_initial_migration.py @@ -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') \ No newline at end of file diff --git a/backend/alembic/versions/002_add_course_drafts.py b/backend/alembic/versions/002_add_course_drafts.py new file mode 100644 index 0000000..2531086 --- /dev/null +++ b/backend/alembic/versions/002_add_course_drafts.py @@ -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') \ No newline at end of file diff --git a/backend/alembic/versions/003_add_content_to_courses.py b/backend/alembic/versions/003_add_content_to_courses.py new file mode 100644 index 0000000..719656c --- /dev/null +++ b/backend/alembic/versions/003_add_content_to_courses.py @@ -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') \ No newline at end of file diff --git a/backend/alembic/versions/004_add_status_to_courses.py b/backend/alembic/versions/004_add_status_to_courses.py new file mode 100644 index 0000000..aaffde7 --- /dev/null +++ b/backend/alembic/versions/004_add_status_to_courses.py @@ -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') \ No newline at end of file diff --git a/backend/alembic/versions/005_add_is_developer_to_users.py b/backend/alembic/versions/005_add_is_developer_to_users.py new file mode 100644 index 0000000..9034e44 --- /dev/null +++ b/backend/alembic/versions/005_add_is_developer_to_users.py @@ -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') \ No newline at end of file diff --git a/backend/alembic/versions/006_add_content_html_to_lesson_steps.py b/backend/alembic/versions/006_add_content_html_to_lesson_steps.py new file mode 100644 index 0000000..ff204cd --- /dev/null +++ b/backend/alembic/versions/006_add_content_html_to_lesson_steps.py @@ -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') diff --git a/backend/alembic/versions/007_add_file_url_to_lesson_steps.py b/backend/alembic/versions/007_add_file_url_to_lesson_steps.py new file mode 100644 index 0000000..2dd3a88 --- /dev/null +++ b/backend/alembic/versions/007_add_file_url_to_lesson_steps.py @@ -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'))") diff --git a/backend/alembic/versions/008_add_test_system.py b/backend/alembic/versions/008_add_test_system.py new file mode 100644 index 0000000..2a04991 --- /dev/null +++ b/backend/alembic/versions/008_add_test_system.py @@ -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') diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..a1294e5 --- /dev/null +++ b/backend/app/__init__.py @@ -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"] diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..fcbab67 --- /dev/null +++ b/backend/app/auth.py @@ -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) diff --git a/backend/app/crud.py b/backend/app/crud.py new file mode 100644 index 0000000..3743729 --- /dev/null +++ b/backend/app/crud.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..f983054 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..d55642b --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/middleware/cors.py b/backend/app/middleware/cors.py new file mode 100644 index 0000000..5ad98df --- /dev/null +++ b/backend/app/middleware/cors.py @@ -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=["*"], + ) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..9d685c9 --- /dev/null +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..749b031 --- /dev/null +++ b/backend/app/routes/__init__.py @@ -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"] diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py new file mode 100644 index 0000000..ef60295 --- /dev/null +++ b/backend/app/routes/admin.py @@ -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 + ] + } \ No newline at end of file diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..54310c6 --- /dev/null +++ b/backend/app/routes/auth.py @@ -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" + ) diff --git a/backend/app/routes/course_builder.py b/backend/app/routes/course_builder.py new file mode 100644 index 0000000..574041d --- /dev/null +++ b/backend/app/routes/course_builder.py @@ -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", "
") + + 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" + ) \ No newline at end of file diff --git a/backend/app/routes/courses.py b/backend/app/routes/courses.py new file mode 100644 index 0000000..f31a1f2 --- /dev/null +++ b/backend/app/routes/courses.py @@ -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}) diff --git a/backend/app/routes/favorites.py b/backend/app/routes/favorites.py new file mode 100644 index 0000000..1145872 --- /dev/null +++ b/backend/app/routes/favorites.py @@ -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"} diff --git a/backend/app/routes/learn.py b/backend/app/routes/learn.py new file mode 100644 index 0000000..ed5354f --- /dev/null +++ b/backend/app/routes/learn.py @@ -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}) diff --git a/backend/app/routes/progress.py b/backend/app/routes/progress.py new file mode 100644 index 0000000..ca2da1e --- /dev/null +++ b/backend/app/routes/progress.py @@ -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"} diff --git a/backend/app/routes/stats.py b/backend/app/routes/stats.py new file mode 100644 index 0000000..4938b38 --- /dev/null +++ b/backend/app/routes/stats.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..abb3f3c --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/init.sql b/backend/init.sql new file mode 100644 index 0000000..16c608c --- /dev/null +++ b/backend/init.sql @@ -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 $$; \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9025542 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-cleanup.sh b/docker-cleanup.sh new file mode 100644 index 0000000..122d10b --- /dev/null +++ b/docker-cleanup.sh @@ -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 "" | 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 "$@" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2527494 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a5bff09 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/css/auth.css b/frontend/css/auth.css new file mode 100644 index 0000000..6b60118 --- /dev/null +++ b/frontend/css/auth.css @@ -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; +} \ No newline at end of file diff --git a/frontend/css/courses.css b/frontend/css/courses.css new file mode 100644 index 0000000..683f675 --- /dev/null +++ b/frontend/css/courses.css @@ -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; +} \ No newline at end of file diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..d3b47c2 --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,1201 @@ +/* Base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + line-height: 1.6; + color: #333; + background-color: #f5f5f5; +} + +#app { + width: 100%; + margin: 0 auto; + padding: 1rem; +} + +@media (min-width: 768px) and (max-width: 1023px) { + #app { + padding: 1.5rem; + } +} + +@media (min-width: 1024px) { + #app { + padding: 2rem; + } +} + +/* Navigation */ +.main-nav { + background-color: #fff; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + padding: 1rem 0; + margin-bottom: 2rem; +} + +.nav-container { + width: 100%; + margin: 0 auto; + padding: 0 20px; + display: flex; + justify-content: space-between; + align-items: center; +} + +@media (max-width: 575px) { + .nav-container { + padding: 0 1rem; + flex-direction: column; + gap: 1rem; + } + + .nav-links { + width: 100%; + overflow-x: auto; + } +} + +.nav-brand a { + font-size: 1.5rem; + font-weight: bold; + color: #2563eb; + text-decoration: none; +} + +.nav-links { + display: flex; + gap: 1.5rem; +} + +.nav-links a { + color: #4b5563; + text-decoration: none; + padding: 0.5rem 0; + position: relative; +} + +.nav-links a:hover { + color: #2563eb; +} + +.nav-links a[data-link]:after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 0; + height: 2px; + background-color: #2563eb; + transition: width 0.3s; +} + +.nav-links a[data-link]:hover:after { + width: 100%; +} + +.auth-btn { + background-color: #2563eb; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; +} + +.auth-btn:hover { + background-color: #1d4ed8; +} + +/* Buttons */ +.btn { + display: inline-block; + background-color: #2563eb; + color: white; + padding: 0.75rem 1.5rem; + border-radius: 6px; + text-decoration: none; + border: none; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.3s; +} + +.btn:hover { + background-color: #1d4ed8; +} + +.btn-outline { + background-color: transparent; + color: #2563eb; + border: 2px solid #2563eb; +} + +.btn-outline:hover { + background-color: #2563eb; + color: white; +} + +.btn-primary { + background-color: #2563eb; + color: white; +} + +.btn-secondary { + background-color: #6b7280; + color: white; +} + +/* Cards */ +.course-card { + background-color: white; + border-radius: 10px; + padding: 1.5rem; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + transition: transform 0.3s, box-shadow 0.3s; +} + +.course-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 15px rgba(0,0,0,0.1); +} + +.course-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 1rem; +} + +.course-level { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + font-weight: bold; + text-transform: uppercase; +} + +.course-level.beginner { + background-color: #dcfce7; + color: #166534; +} + +.course-level.intermediate { + background-color: #fef3c7; + color: #92400e; +} + +.course-level.advanced { + background-color: #fee2e2; + color: #991b1b; +} + +.course-meta { + display: flex; + gap: 1rem; + margin-top: 1rem; + font-size: 0.9rem; + color: #6b7280; +} + +.course-actions { + margin-top: 1.5rem; + display: flex; + gap: 1rem; + align-items: center; +} + +.btn-favorite { + background: none; + border: 1px solid #d1d5db; + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + transition: background-color 0.3s; +} + +.btn-favorite:hover { + background-color: #f3f4f6; +} + +.btn-favorite.favorited { + background-color: #fef2f2; + border-color: #fecaca; + color: #dc2626; +} + +.heart-icon { + font-size: 1.2rem; +} + +/* Progress bar */ +.progress-bar { + height: 8px; + background-color: #e5e7eb; + border-radius: 4px; + overflow: hidden; + margin: 0.5rem 0; +} + +.progress-fill { + height: 100%; + background-color: #2563eb; + border-radius: 4px; + transition: width 0.3s; +} + +/* Forms */ +.form-container { + display: none; +} + +.form-container.active { + display: block; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1rem; +} + +.form-group input:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +/* Messages */ +.message { + padding: 1rem; + border-radius: 6px; + margin-top: 1rem; +} + +.message.success { + background-color: #dcfce7; + color: #166534; + border: 1px solid #bbf7d0; +} + +.message.error { + background-color: #fee2e2; + color: #991b1b; + border: 1px solid #fecaca; +} + +.message.info { + background-color: #dbeafe; + color: #1e40af; + border: 1px solid #bfdbfe; +} + +.global-message { + margin-top: 1.5rem; + padding: 1.25rem; + font-size: 1.1rem; + text-align: center; + animation: fadeIn 0.5s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Grid */ +.courses-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 2rem; + margin-top: 2rem; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 3rem; + background-color: white; + border-radius: 10px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); +} + +/* Tabs */ +.tabs { + display: flex; + border-bottom: 2px solid #e5e7eb; + margin-bottom: 2rem; +} + +.tab-btn { + padding: 0.75rem 1.5rem; + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + color: #6b7280; + position: relative; +} + +.tab-btn.active { + color: #2563eb; + font-weight: 600; +} + +.tab-btn.active:after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 100%; + height: 2px; + background-color: #2563eb; +} + +/* Login page */ +.login-page { + min-height: calc(100vh - 200px); + display: flex; + align-items: center; + justify-content: center; +} + +.login-container { + background-color: white; + padding: 3rem; + border-radius: 10px; + box-shadow: 0 10px 25px rgba(0,0,0,0.1); + width: 100%; + max-width: 500px; +} + +/* Filters */ +.filters { + display: flex; + gap: 1rem; + margin-bottom: 2rem; +} + +.filter-btn { + padding: 0.5rem 1.5rem; + background-color: white; + border: 2px solid #e5e7eb; + border-radius: 20px; + text-decoration: none; + color: #4b5563; + transition: all 0.3s; +} + +.filter-btn:hover, .filter-btn.active { + background-color: #2563eb; + color: white; + border-color: #2563eb; +} + +/* Course Builder */ +.course-builder-page { + padding: 2rem 0; +} + +.course-builder-container { + background-color: white; + padding: 2rem; + border-radius: 10px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + margin: 0 2rem; +} + +.course-builder-container h2 { + color: #2563eb; + margin-bottom: 0.5rem; +} + +.subtitle { + color: #6b7280; + margin-bottom: 2rem; + font-size: 1.1rem; +} + +.editor-section { + margin: 2rem 0; + padding: 1.5rem; + background-color: #f9fafb; + border-radius: 8px; + border: 1px solid #e5e7eb; +} + +.editor-section h3 { + margin-bottom: 1rem; + color: #374151; +} + +#vditor-container { + margin-bottom: 1.5rem; +} + +.editor-help { + margin-top: 1.5rem; + padding: 1rem; + background-color: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 6px; +} + +.editor-help h4 { + margin-bottom: 0.5rem; + color: #0369a1; +} + +.editor-help ul { + margin-left: 1.5rem; + color: #075985; +} + +.editor-help li { + margin-bottom: 0.5rem; +} + +/* Vditor custom styles */ +.vditor { + border-radius: 6px; + border: 1px solid #e5e7eb; +} + +.vditor-toolbar { + border-bottom: 1px solid #e5e7eb; + background-color: #f8fafc; +} + +.vditor-toolbar__item { + color: #4b5563; +} + +.vditor-toolbar__item:hover { + background-color: #e5e7eb; + color: #111827; +} + +.vditor-toolbar__item--active { + background-color: #dbeafe; + color: #1d4ed8; +} + +.vditor-content { + border-top: none; +} + +.vditor-reset { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; +} + +.vditor-preview { + background-color: white; +} + +.latex-note { + margin-top: 1rem; + padding: 0.75rem; + background-color: #fef3c7; + border: 1px solid #fde68a; + border-radius: 6px; + color: #92400e; +} + +/* KaTeX styles */ +.katex-display { + margin: 1.5rem 0; + padding: 1rem; + background-color: #f8fafc; + border-radius: 6px; + overflow-x: auto; + overflow-y: hidden; +} + +.katex { + font-size: 1.1em; +} + +.katex .katex-mathml { + display: none; +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; +} + +textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1rem; + font-family: inherit; + resize: vertical; +} + +textarea:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +select { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1rem; + background-color: white; +} + +select:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +/* Course Structure Styles */ +.course-structure-section { + margin: 2rem 0; + padding: 1.5rem; + background-color: #f9fafb; + border-radius: 8px; + border: 1px solid #e5e7eb; +} + +.course-structure-section h3 { + margin-bottom: 0.5rem; + color: #374151; +} + +.section-description { + color: #6b7280; + margin-bottom: 1.5rem; + font-size: 0.95rem; +} + +.structure-actions { + margin-bottom: 1.5rem; +} + +.btn-icon { + margin-right: 0.5rem; + font-weight: bold; +} + +.course-structure { + min-height: 200px; + border: 1px dashed #d1d5db; + border-radius: 6px; + padding: 1.5rem; + background-color: white; +} + +.empty-structure { + text-align: center; + padding: 3rem; + color: #6b7280; +} + +.empty-structure p { + margin: 0; +} + +.module-item { + background-color: white; + border: 1px solid #e5e7eb; + border-radius: 6px; + margin-bottom: 1rem; + overflow: hidden; +} + +.module-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background-color: #f8fafc; + border-bottom: 1px solid #e5e7eb; + cursor: pointer; + transition: background-color 0.2s; +} + +.module-header:hover { + background-color: #f1f5f9; +} + +.module-title { + font-weight: 600; + color: #111827; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.module-icon { + color: #2563eb; +} + +.module-duration { + color: #6b7280; + font-size: 0.9rem; + margin-left: 1rem; +} + +.module-actions { + display: flex; + gap: 0.5rem; +} + +.module-action-btn { + padding: 0.25rem 0.75rem; + background: none; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; + color: #4b5563; + transition: all 0.2s; +} + +.module-action-btn:hover { + background-color: #f3f4f6; + border-color: #9ca3af; +} + +.module-action-btn.delete { + color: #dc2626; + border-color: #fecaca; +} + +.module-action-btn.delete:hover { + background-color: #fef2f2; +} + +.module-content { + padding: 1.5rem; +} + +.lessons-list { + margin-top: 1rem; +} + +.lesson-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background-color: white; + border: 1px solid #e5e7eb; + border-radius: 4px; + margin-bottom: 0.5rem; + transition: background-color 0.2s; +} + +.lesson-item:hover { + background-color: #f9fafb; +} + +.lesson-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.lesson-number { + background-color: #dbeafe; + color: #1d4ed8; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 600; +} + +.lesson-title { + font-weight: 500; + color: #374151; +} + +.lesson-actions { + display: flex; + gap: 0.5rem; +} + +/* Drag and Drop */ +.drag-handle { + cursor: grab; + color: #9ca3af; + font-weight: bold; + padding: 0 0.5rem; + user-select: none; + transition: color 0.2s; +} + +.drag-handle:hover { + color: #4b5563; +} + +.lesson-item.draggable:active .drag-handle { + cursor: grabbing; +} + +.lesson-item.dragging { + opacity: 0.5; + background-color: #e0f2fe; +} + +.lesson-item.drag-over { + border-color: #3b82f6; + background-color: #eeffff; +} + +.add-lesson-btn { + margin-top: 1rem; + padding: 0.5rem 1rem; + background-color: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 4px; + color: #0369a1; + cursor: pointer; + font-size: 0.9rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s; +} + +.add-lesson-btn:hover { + background-color: #e0f2fe; + border-color: #7dd3fc; +} + +/* Modal styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal { + background-color: white; + border-radius: 8px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); + width: 100%; + max-width: 1200px; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + padding: 1.5rem 1.5rem 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.modal-header h3 { + margin: 0; + color: #111827; +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid #e5e7eb; + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +/* Lesson Steps Styles */ +.lesson-steps-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; +} + +.lesson-steps-section h4 { + margin-bottom: 0.5rem; + color: #374151; +} + +.steps-actions { + margin-bottom: 1.5rem; +} + +.lesson-steps { + min-height: 100px; + border: 1px dashed #d1d5db; + border-radius: 6px; + padding: 1rem; + background-color: white; +} + +.empty-steps { + text-align: center; + padding: 2rem; + color: #6b7280; +} + +.step-item { + background-color: white; + border: 1px solid #e5e7eb; + border-radius: 6px; + margin-bottom: 0.75rem; + overflow: hidden; +} + +.step-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background-color: #f8fafc; + border-bottom: 1px solid #e5e7eb; +} + +.step-title { + display: flex; + align-items: center; + gap: 1rem; +} + +.step-number { + font-weight: 600; + color: #111827; + background-color: #dbeafe; + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.9rem; +} + +.step-type { + font-weight: 500; + color: #374151; + font-size: 0.95rem; +} + +.step-actions { + display: flex; + gap: 0.5rem; +} + +.content-note { + padding: 0.75rem; + background-color: #fef3c7; + border: 1px solid #fde68a; + border-radius: 6px; + color: #92400e; + margin-top: 0.5rem; + font-size: 0.9rem; +} + +/* Wide screen optimization for course builder */ +@media (min-width: 768px) and (max-width: 1023px) { + .course-builder-container { + margin: 0 3rem; + } +} + +@media (min-width: 1024px) { + .course-builder-container { + margin: 0 auto; + max-width: 95%; + } +} + +@media (min-width: 1440px) { + .course-builder-page { + padding: 3rem 0; + } + + .course-builder-container { + padding: 3rem; + margin: 0 auto; + max-width: 95%; + } +} + +@media (min-width: 1920px) { + .course-builder-container { + padding: 4rem; + margin: 0 auto; + max-width: 140rem; /* Ограничение для удобства чтения */ + } +} + +/* Vditor wide screen optimization */ +@media (min-width: 1440px) { + .vditor { + height: 600px; + } + + .vditor-toolbar { + padding: 0.75rem 1rem; + } + + .vditor-ir--panel { + width: 55%; + } +} + +@media (min-width: 1920px) { + .vditor { + height: 700px; + } +} + +/* Nav Section labels */ +.nav-section { + color: #9ca3af; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding-left: 0.5rem; +} + +/* Dev Tools Page */ +.dev-tools-page { + padding: 2rem 0; +} + +.dev-tools-container { + max-width: 900px; + margin: 0 auto; + padding: 0 1rem; +} + +.dev-tools-section { + margin-bottom: 3rem; + padding: 2rem; + background-color: white; + border-radius: 10px; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} + +.dev-tools-section h2 { + margin-bottom: 0.5rem; + color: #374151; +} + +.section-description { + margin-bottom: 1.5rem; + color: #6b7280; +} + +.tools-list { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.tool-item { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + border: 1px solid #e5e7eb; + border-radius: 8px; + background-color: #f9fafb; +} + +@media (min-width: 640px) { + .tool-item { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} + +.tool-info { + flex: 1; +} + +.tool-info h3 { + margin: 0 0 0.25rem 0; + color: #374151; + font-size: 1.1rem; +} + +.tool-desc { + margin: 0; + color: #6b7280; + font-size: 0.9rem; +} + +.btn-danger { + background-color: #dc2626; + color: white; +} + +.btn-danger:hover { + background-color: #b91c1c; +} + +.btn-warning { + background-color: #f59e0b; + color: white; +} + +.btn-warning:hover { + background-color: #d97706; +} + +/* Dropdown menu */ +.nav-dropdown { + position: relative; + display: inline-block; +} + +.nav-dropdown-btn { + background: none; + border: none; + padding: 0.5rem 0; + color: #4b5563; + cursor: pointer; + font-size: 0.95rem; + font-weight: 400; +} + +.nav-dropdown-btn:hover { + color: #2563eb; +} + +.nav-dropdown-content { + display: none; + position: absolute; + top: 100%; + left: -0.5rem; + background-color: white; + min-width: 150px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + border-radius: 6px; + z-index: 100; + padding: 0.5rem 0; +} + +.nav-dropdown:hover .nav-dropdown-content { + display: block; +} + +.nav-dropdown-content a { + display: block; + padding: 0.5rem 1rem; + color: #4b5563; + text-decoration: none; + white-space: nowrap; +} + +.nav-dropdown-content a:hover { + background-color: #f3f4f6; + color: #2563eb; +} + +/* Draft management */ +.badge { + display: inline-block; + background-color: var(--primary-color); + color: white; + padding: 0.1rem 0.4rem; + border-radius: 10px; + font-size: 0.7rem; + margin-left: 0.5rem; +} + +.drafts-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.no-drafts { + text-align: center; + color: #6b7280; + padding: 2rem; +} + +.draft-item { + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +@media (min-width: 640px) { + .draft-item { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} + +.draft-info { + flex: 1; +} + +.draft-title { + margin: 0; + color: #374151; + font-size: 1rem; +} + +.draft-meta { + font-size: 0.8rem; + color: #6b7280; + margin: 0.25rem 0; +} + +.draft-desc { + font-size: 0.85rem; + color: #6b7280; + margin: 0.5rem 0 0; +} + +.draft-actions { + display: flex; + gap: 0.5rem; +} + +.btn-sm { + padding: 0.3rem 0.6rem; + font-size: 0.85rem; +} \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..41bbd46 --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..f815332 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,28 @@ + + + + + + LearnMD — Умное корпоративное обучение + + + + + + + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/frontend/src/api/courses.js b/frontend/src/api/courses.js new file mode 100644 index 0000000..618d09e --- /dev/null +++ b/frontend/src/api/courses.js @@ -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 || []; + } +}; diff --git a/frontend/src/components/PdfViewer.js b/frontend/src/components/PdfViewer.js new file mode 100644 index 0000000..b448123 --- /dev/null +++ b/frontend/src/components/PdfViewer.js @@ -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 = ` +
+
+
+

Загрузка PDF... 0%

+
+ + +
+ `; + + 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 = ` +
+ + + ${this.currentPage} / ${this.totalPages} + + +
+
+ + ${Math.round(this.scale * 100)}% + +
+ ${this.options.fullscreenButton ? ` + + ` : ''} + `; + + 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 = ` +
+ + + + +

${message}

+
+ `; + } + } + + destroy() { + document.removeEventListener('keydown', this.handleKeydown); + if (this.pdfDoc) { + this.pdfDoc.destroy(); + } + } +} diff --git a/frontend/src/components/TestViewer.js b/frontend/src/components/TestViewer.js new file mode 100644 index 0000000..1db7a20 --- /dev/null +++ b/frontend/src/components/TestViewer.js @@ -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 = ` +
+
+
+ Вопрос ${this.currentQuestionIndex + 1} из ${this.questions.length} + ${this.settings.time_limit > 0 ? ` + + ${Math.floor(this.timeRemaining / 60)}:${(this.timeRemaining % 60).toString().padStart(2, '0')} + + ` : ''} +
+
+
+
+
+
+
+ +
+ ${this.renderQuestion(question)} +
+ +
+ + +
+ ${this.questions.map((q, idx) => ` + + `).join('')} +
+ + ${this.currentQuestionIndex < this.questions.length - 1 ? ` + + ` : ` + + `} +
+
+ `; + } + + renderQuestion(question) { + if (!question) return '

Вопрос не найден

'; + + return ` +
+
+ ${question.points} балл(ов) +
+
${question.question_text}
+ +
+ ${this.renderAnswers(question)} +
+
+ `; + } + + 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 '

Неизвестный тип вопроса

'; + } + } + + renderSingleChoice(question) { + return ` +
+ ${question.answers.map(answer => ` + + `).join('')} +
+ `; + } + + renderMultipleChoice(question) { + return ` +
+ ${question.answers.map(answer => ` + + `).join('')} +
+ `; + } + + renderTextAnswer(question) { + return ` +
+ +
+ `; + } + + renderMatch(question) { + // Shuffle right items for display + const rightItems = [...question.match_items].sort(() => Math.random() - 0.5); + + return ` +
+
+
+ ${question.match_items.map(item => ` +
+ ${item.left_text} + +
+ `).join('')} +
+
+
+ `; + } + + renderResults() { + const lastAttempt = this.attempts[0]; + if (!lastAttempt) return '

Результаты не найдены

'; + + const percentage = Math.round((lastAttempt.score / lastAttempt.max_score) * 100); + + return ` +
+
+

${lastAttempt.passed ? '✓ Тест пройден!' : '✗ Тест не пройден'}

+
+ ${percentage}% +
+
+ +
+

Набрано баллов: ${lastAttempt.score} из ${lastAttempt.max_score}

+

Проходной балл: ${this.settings.passing_score}%

+
+ + ${this.settings.max_attempts > 0 ? ` +

Попыток использовано: ${this.attempts.length} из ${this.settings.max_attempts}

+ ` : ''} + + ${!lastAttempt.passed && (this.settings.max_attempts === 0 || this.attempts.length < this.settings.max_attempts) ? ` + + ` : ''} +
+ `; + } + + showError(message) { + this.container.innerHTML = ` +
+

${message}

+
+ `; + } + + 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(); +}; diff --git a/frontend/src/components/auth.js b/frontend/src/components/auth.js new file mode 100644 index 0000000..f80081b --- /dev/null +++ b/frontend/src/components/auth.js @@ -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 = ` + Главная + Курсы + ${currentUser ? ` + ${currentUser.is_developer ? ` + + ` : ''} + Избранное + Мой прогресс + Конструктор курса + ` : ''} + `; + } 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'); + }; + } +} diff --git a/frontend/src/components/nav.js b/frontend/src/components/nav.js new file mode 100644 index 0000000..5e82c8a --- /dev/null +++ b/frontend/src/components/nav.js @@ -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 = ` + + `; + + document.body.insertBefore(nav, document.body.firstChild); +} + +export function updateNav() { + renderNav(); +} \ No newline at end of file diff --git a/frontend/src/course-builder/Constants.js b/frontend/src/course-builder/Constants.js new file mode 100644 index 0000000..b8ce1b6 --- /dev/null +++ b/frontend/src/course-builder/Constants.js @@ -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 +}; diff --git a/frontend/src/course-builder/CourseUI.js b/frontend/src/course-builder/CourseUI.js new file mode 100644 index 0000000..0cc186b --- /dev/null +++ b/frontend/src/course-builder/CourseUI.js @@ -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 = 'Опубликовать курс'; + } +} diff --git a/frontend/src/course-builder/DraftManager.js b/frontend/src/course-builder/DraftManager.js new file mode 100644 index 0000000..6295eb4 --- /dev/null +++ b/frontend/src/course-builder/DraftManager.js @@ -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 = ` + `; + 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 = '

У вас пока нет черновиков

'; + } 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; + } +} diff --git a/frontend/src/course-builder/LessonManager.js b/frontend/src/course-builder/LessonManager.js new file mode 100644 index 0000000..8f856d3 --- /dev/null +++ b/frontend/src/course-builder/LessonManager.js @@ -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 = ` + `; + + 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 = ` + `; + + 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'); } +} diff --git a/frontend/src/course-builder/ModuleManager.js b/frontend/src/course-builder/ModuleManager.js new file mode 100644 index 0000000..c9ff2d3 --- /dev/null +++ b/frontend/src/course-builder/ModuleManager.js @@ -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 = ` + `; + + 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 = ` + `; + + 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'); } +} diff --git a/frontend/src/course-builder/State.js b/frontend/src/course-builder/State.js new file mode 100644 index 0000000..e251192 --- /dev/null +++ b/frontend/src/course-builder/State.js @@ -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 }; diff --git a/frontend/src/course-builder/StepManager.js b/frontend/src/course-builder/StepManager.js new file mode 100644 index 0000000..1ad7c50 --- /dev/null +++ b/frontend/src/course-builder/StepManager.js @@ -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 = ` + `; + + 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 = ` + `; + + 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 = ` +
+
+ + +
+
+ +
+
+
`; + setTimeout(() => { if (typeof Vditor !== 'undefined') new Vditor('step-vditor-container', getStepVditorConfig()); }, 50); + } else if (stepType === 'presentation') { + contentFields.innerHTML = ` +
+
+ + +
+
+ +
+
+ + + + + + +

Перетащите PDF файл сюда или

+ + +
+ +
+

Поддерживаются файлы в формате PDF. Максимальный размер: 50 МБ.

+
+
`; + initPdfDropZone(); + } else if (stepType === 'test') { + contentFields.innerHTML = ` +
+
+ + +
+
+ +

После создания шага вы сможете добавить вопросы и настроить тест.

+
+
`; + } else { + contentFields.innerHTML = ` +
+
+ + +
+
+ +

Редактирование этого типа контента будет доступно в будущих версиях.

+
+
`; + } +} + +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'); + } +} diff --git a/frontend/src/course-builder/StructureEvents.js b/frontend/src/course-builder/StructureEvents.js new file mode 100644 index 0000000..fb31750 --- /dev/null +++ b/frontend/src/course-builder/StructureEvents.js @@ -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'); +} diff --git a/frontend/src/course-builder/StructureManager.js b/frontend/src/course-builder/StructureManager.js new file mode 100644 index 0000000..e28cb05 --- /dev/null +++ b/frontend/src/course-builder/StructureManager.js @@ -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 = '

Пока нет модулей. Добавьте первый модуль, чтобы начать.

'; + return; + } + + let html = ''; + courseStructure.modules.forEach((module, moduleIndex) => { + html += ` +
+
+
+ 📚 + ${module.title || 'Без названия'} + ${module.duration ? `(${module.duration})` : ''} +
+
+ + + +
+
+
+
+ ${module.lessons && module.lessons.length > 0 ? + module.lessons.map((lesson, lessonIndex) => ` +
+
+
⋮⋮
+
${lessonIndex + 1}
+
${lesson.title || 'Без названия'}
+
+
+ + +
+
+ `).join('') : + '

Пока нет уроков в этом модуле.

' + } +
+ +
+
`; + }); + + 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); } + } +} diff --git a/frontend/src/course-builder/TestEditor.js b/frontend/src/course-builder/TestEditor.js new file mode 100644 index 0000000..e072e25 --- /dev/null +++ b/frontend/src/course-builder/TestEditor.js @@ -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 ` +
+
+

Настройки теста

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
+

Вопросы теста (${this.questions.length})

+
+ + + + +
+
+ +
+ ${this.renderQuestionsList()} +
+
+ +
+ ${this.currentQuestionIndex >= 0 ? this.renderQuestionEditor() : '

Выберите вопрос для редактирования

'} +
+
+ `; + } + + 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 '

Нет вопросов. Нажмите кнопку выше, чтобы добавить первый вопрос.

'; + } + + return this.questions.map((q, index) => ` +
+ ${index + 1} + ${this.getQuestionTypeLabel(q.question_type)} + ${(q.question_text || 'Новый вопрос').substring(0, 50)}${(q.question_text || '').length > 50 ? '...' : ''} + ${q.points || 1} балл(ов) + +
+ `).join(''); + } + + renderQuestionEditor() { + const question = this.questions[this.currentQuestionIndex]; + if (!question) return ''; + + return ` +
+

Редактирование вопроса ${this.currentQuestionIndex + 1}

+ +
+ +
+ + +
+ +
+ + +
+ + ${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 ` +
+ +
+ ${(question.answers || []).map((a, idx) => ` +
+ + + +
+ `).join('')} +
+ +
+ `; + } + + renderMultipleChoiceEditor(question) { + return ` +
+ +
+ ${(question.answers || []).map((a, idx) => ` +
+ + + +
+ `).join('')} +
+ +
+ `; + } + + renderTextAnswerEditor(question) { + return ` +
+ +

Введите ключевые слова или фразы, которые должны содержаться в ответе студента

+
+ ${(question.correct_answers || []).map((answer, idx) => ` +
+ + +
+ `).join('')} +
+ +
+ `; + } + + renderMatchEditor(question) { + return ` +
+ +
+ ${(question.match_items || []).map((item, idx) => ` +
+ + + + +
+ `).join('')} +
+ +
+ `; + } + + 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); + } + } +} diff --git a/frontend/src/course-builder/index.js b/frontend/src/course-builder/index.js new file mode 100644 index 0000000..da9276a --- /dev/null +++ b/frontend/src/course-builder/index.js @@ -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'; diff --git a/frontend/src/editors/vditorConfig.js b/frontend/src/editors/vditorConfig.js new file mode 100644 index 0000000..99ff36f --- /dev/null +++ b/frontend/src/editors/vditorConfig.js @@ -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: 'Настроить эмодзи', + 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 +}); diff --git a/frontend/src/editors/vditorLoader.js b/frontend/src/editors/vditorLoader.js new file mode 100644 index 0000000..34fa465 --- /dev/null +++ b/frontend/src/editors/vditorLoader.js @@ -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 }; diff --git a/frontend/src/editors/vditorLocalization.js b/frontend/src/editors/vditorLocalization.js new file mode 100644 index 0000000..125ab99 --- /dev/null +++ b/frontend/src/editors/vditorLocalization.js @@ -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); +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..152f272 --- /dev/null +++ b/frontend/src/main.js @@ -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'); +}); diff --git a/frontend/src/router.js b/frontend/src/router.js new file mode 100644 index 0000000..c74b4ab --- /dev/null +++ b/frontend/src/router.js @@ -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 = '

404 - Page Not Found

'; + 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 = '

Error loading page

'; + } + } +}; diff --git a/frontend/src/styles/course-viewer.css b/frontend/src/styles/course-viewer.css new file mode 100644 index 0000000..2e40683 --- /dev/null +++ b/frontend/src/styles/course-viewer.css @@ -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; +} diff --git a/frontend/src/styles/landing.css b/frontend/src/styles/landing.css new file mode 100644 index 0000000..467c211 --- /dev/null +++ b/frontend/src/styles/landing.css @@ -0,0 +1,1154 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); + +.landing { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + color: #1a1a2e; + line-height: 1.6; + overflow-x: hidden; +} + +.container { + max-width: 1280px; + margin: 0 auto; + padding: 0 24px; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + border-radius: 8px; + font-weight: 500; + font-size: 15px; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + border: none; +} + +.btn-primary { + background: linear-gradient(135deg, #6366f1 0%, #10b981 100%); + color: #fff; + box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4); +} + +.btn-outline { + background: transparent; + color: #6366f1; + border: 2px solid #6366f1; +} + +.btn-outline:hover { + background: #6366f1; + color: #fff; +} + +.btn-lg { + padding: 14px 32px; + font-size: 16px; +} + +.btn-full { + width: 100%; +} + +.btn.success { + background: #10b981 !important; +} + +/* Section styles */ +.section { + padding: 100px 0; +} + +.section-header { + text-align: center; + max-width: 700px; + margin: 0 auto 60px; +} + +.section-badge { + display: inline-block; + padding: 6px 16px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(16, 185, 129, 0.1) 100%); + border-radius: 20px; + font-size: 13px; + font-weight: 600; + color: #6366f1; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 16px; +} + +.section-title { + font-size: 40px; + font-weight: 700; + margin: 0 0 16px; + line-height: 1.2; +} + +.section-subtitle { + font-size: 18px; + color: #666; + margin: 0; +} + +/* Hero Section */ +.hero { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + padding: 60px 0; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); +} + +.hero-bg { + position: absolute; + inset: 0; + overflow: hidden; +} + +#particles-canvas { + width: 100%; + height: 100%; +} + +.hero-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 60px; + align-items: center; + position: relative; + z-index: 1; +} + +.hero-title { + font-size: 52px; + font-weight: 800; + line-height: 1.1; + margin: 0 0 24px; +} + +.gradient-text { + background: linear-gradient(135deg, #6366f1 0%, #10b981 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-subtitle { + font-size: 18px; + color: #666; + margin: 0 0 32px; + max-width: 500px; +} + +.hero-buttons { + display: flex; + gap: 16px; + margin-bottom: 48px; +} + +.hero-stats { + display: flex; + gap: 40px; +} + +.stat { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 32px; + font-weight: 700; + color: #6366f1; +} + +.stat-label { + font-size: 13px; + color: #888; +} + +/* Editor Demo */ +.hero-visual { + perspective: 1000px; +} + +.editor-demo { + background: #1e1e2e; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15); + transform: rotateY(-5deg) rotateX(5deg); + transition: transform 0.5s; +} + +.editor-demo:hover { + transform: rotateY(0) rotateX(0); +} + +.editor-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: #2d2d3d; + border-bottom: 1px solid #3d3d4d; +} + +.editor-dots { + display: flex; + gap: 8px; +} + +.editor-dots span { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.editor-dots span:nth-child(1) { background: #ff5f57; } +.editor-dots span:nth-child(2) { background: #ffbd2e; } +.editor-dots span:nth-child(3) { background: #28ca41; } + +.editor-title { + color: #888; + font-size: 13px; + font-family: monospace; +} + +.editor-body { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 16px; + padding: 24px; + min-height: 300px; +} + +.editor-input pre { + margin: 0; + font-family: 'Fira Code', monospace; + font-size: 13px; + line-height: 1.8; +} + +.editor-input .line { + display: block; +} + +.md-hash { color: #6366f1; } +.md-heading { color: #fff; } +.md-text { color: #a0a0b0; } +.md-bold { color: #10b981; font-weight: bold; } +.md-code { color: #f59e0b; } +.md-latex { color: #ec4899; } +.code-keyword { color: #6366f1; } +.code-string { color: #10b981; } +.code-number { color: #f59e0b; } + +.editor-arrow { + display: flex; + align-items: center; + color: #6366f1; + animation: pulse-arrow 2s infinite; +} + +@keyframes pulse-arrow { + 0%, 100% { opacity: 0.5; transform: translateX(0); } + 50% { opacity: 1; transform: translateX(5px); } +} + +.editor-output { + background: #fff; + border-radius: 8px; + padding: 20px; +} + +.preview-content { + color: #1a1a2e; +} + +.preview-title { + font-size: 18px; + font-weight: 700; + margin: 0 0 8px; +} + +.preview-subtitle { + font-size: 14px; + font-weight: 600; + color: #6366f1; + margin: 0 0 12px; +} + +.preview-text { + font-size: 13px; + margin: 0 0 12px; +} + +.preview-code { + background: #f5f7fa; + padding: 12px; + border-radius: 6px; + font-family: 'Fira Code', monospace; + font-size: 12px; + margin-bottom: 12px; +} + +.preview-code code { + display: block; + margin-bottom: 4px; +} + +.preview-formula { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(16, 185, 129, 0.1) 100%); + padding: 8px 12px; + border-radius: 6px; + font-family: 'Times New Roman', serif; + font-style: italic; + text-align: center; +} + +.hero-scroll { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: #888; + font-size: 13px; +} + +.scroll-indicator { + width: 24px; + height: 40px; + border: 2px solid #888; + border-radius: 12px; + position: relative; +} + +.scroll-indicator::after { + content: ''; + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 8px; + background: #6366f1; + border-radius: 2px; + animation: scroll-bounce 2s infinite; +} + +@keyframes scroll-bounce { + 0%, 100% { top: 8px; opacity: 1; } + 50% { top: 20px; opacity: 0.5; } +} + +/* Simplicity Section */ +.simplicity { + background: #fff; +} + +.simplicity-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; + margin-bottom: 60px; +} + +.simplicity-card { + padding: 32px 24px; + background: #f8fafc; + border-radius: 16px; + text-align: center; + transition: all 0.3s; + opacity: 0; + transform: translateY(30px); +} + +.simplicity-card.animate-in { + opacity: 1; + transform: translateY(0); +} + +.simplicity-card:hover { + background: #fff; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08); + transform: translateY(-5px); +} + +.card-icon { + width: 80px; + height: 80px; + margin: 0 auto 20px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(16, 185, 129, 0.1) 100%); + border-radius: 20px; + color: #6366f1; +} + +.simplicity-card h3 { + font-size: 18px; + font-weight: 600; + margin: 0 0 12px; +} + +.simplicity-card p { + font-size: 14px; + color: #666; + margin: 0; +} + +.simplicity-demo { + background: linear-gradient(135deg, #f8fafc 0%, #e8f0fe 100%); + border-radius: 20px; + padding: 60px; +} + +.transform-visual { + display: flex; + align-items: center; + justify-content: center; + gap: 40px; +} + +.transform-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 30px 40px; + background: #fff; + border-radius: 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); +} + +.transform-icon { + font-size: 40px; +} + +.transform-item span:last-child { + font-weight: 600; + color: #1a1a2e; +} + +/* Integration Section */ +.integration { + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); +} + +.integration-flow { + position: relative; + height: 400px; + margin-bottom: 60px; +} + +.flow-center { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + z-index: 10; +} + +.platform-hub { + width: 140px; + height: 140px; + background: linear-gradient(135deg, #6366f1 0%, #10b981 100%); + border-radius: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #fff; + box-shadow: 0 10px 40px rgba(99, 102, 241, 0.3); +} + +.hub-icon { + margin-bottom: 8px; +} + +.hub-label { + font-size: 12px; + text-align: center; + font-weight: 500; +} + +.flow-left, .flow-right { + position: absolute; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 40px; +} + +.flow-left { + left: 0; + align-items: flex-start; +} + +.flow-right { + right: 0; + align-items: flex-end; +} + +.flow-item { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); + opacity: 0; + transform: translateX(-30px); + transition: all 0.5s; +} + +.flow-item.animate-in { + opacity: 1; + transform: translateX(0); +} + +.flow-right .flow-item { + transform: translateX(30px); +} + +.flow-right .flow-item.animate-in { + transform: translateX(0); +} + +.flow-icon { + font-size: 28px; +} + +.flow-content h4 { + font-size: 14px; + font-weight: 600; + margin: 0 0 4px; +} + +.flow-content p { + font-size: 12px; + color: #666; + margin: 0; +} + +.flow-1c { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); +} + +.c1-badge { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 32px; + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); +} + +.c1-logo { + font-size: 28px; + font-weight: 800; + color: #d32f2f; +} + +.c1-products { + font-size: 11px; + color: #888; + margin-top: 4px; +} + +.flow-lines { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.flow-line { + stroke-width: 2; + stroke-dasharray: 5 5; + animation: dash-flow 1s linear infinite; +} + +@keyframes dash-flow { + to { stroke-dashoffset: -10; } +} + +.integration-benefits { + display: flex; + justify-content: center; + gap: 80px; +} + +.benefit { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.benefit-value { + font-size: 48px; + font-weight: 700; + background: linear-gradient(135deg, #6366f1 0%, #10b981 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.benefit-label { + font-size: 14px; + color: #666; +} + +/* Technologies Section */ +.technologies { + background: #fff; +} + +.tech-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +.tech-card { + padding: 32px; + background: #f8fafc; + border-radius: 20px; + transition: all 0.3s; + opacity: 0; + transform: translateY(30px); +} + +.tech-card.animate-in { + opacity: 1; + transform: translateY(0); +} + +.tech-card:hover { + background: #fff; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08); +} + +.tech-card.featured { + grid-column: span 2; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; + align-items: center; +} + +.tech-visual { + display: flex; + align-items: center; + justify-content: center; + min-height: 150px; +} + +.adaptive-diagram svg { + width: 200px; + height: 150px; +} + +.adapt-path { + stroke-dasharray: 300; + stroke-dashoffset: 300; + animation: draw-path 3s ease-out forwards infinite; +} + +@keyframes draw-path { + 0% { stroke-dashoffset: 300; } + 50% { stroke-dashoffset: 0; } + 100% { stroke-dashoffset: -300; } +} + +.adapt-node { + fill: #6366f1; + animation: pulse-node 2s ease-in-out infinite; +} + +.adapt-node:nth-child(2) { animation-delay: 0.3s; } +.adapt-node:nth-child(3) { animation-delay: 0.6s; } + +@keyframes pulse-node { + 0%, 100% { r: 8; opacity: 1; } + 50% { r: 12; opacity: 0.7; } +} + +.multi-tenant-visual { + position: relative; + width: 180px; + height: 140px; +} + +.tenant { + position: absolute; + padding: 8px 16px; + background: #fff; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.tenant-1 { top: 0; left: 0; border-left: 3px solid #6366f1; } +.tenant-2 { top: 0; right: 0; border-left: 3px solid #10b981; } +.tenant-3 { top: 50px; left: 50%; transform: translateX(-50%); border-left: 3px solid #f59e0b; } + +.tenant-center { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + background: linear-gradient(135deg, #6366f1 0%, #10b981 100%); + color: #fff; + border-radius: 8px; + font-size: 12px; + font-weight: 600; +} + +.performance-meters { + display: flex; + flex-direction: column; + gap: 16px; + width: 150px; +} + +.meter { + display: flex; + align-items: center; + gap: 12px; +} + +.meter-fill { + flex: 1; + height: 8px; + background: #e1e5eb; + border-radius: 4px; + position: relative; + overflow: hidden; +} + +.meter-fill::after { + content: ''; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: var(--fill); + background: linear-gradient(90deg, #6366f1 0%, #10b981 100%); + border-radius: 4px; + animation: fill-meter 2s ease-out forwards; +} + +@keyframes fill-meter { + from { width: 0; } +} + +.meter span { + font-size: 12px; + font-weight: 500; + color: #666; + width: 50px; +} + +.tech-card h3 { + font-size: 20px; + font-weight: 600; + margin: 0 0 12px; +} + +.tech-card p { + font-size: 14px; + color: #666; + margin: 0; +} + +/* Security Section */ +.security { + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); +} + +.security-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 60px; + align-items: center; +} + +.security-features { + list-style: none; + padding: 0; + margin: 32px 0 0; +} + +.security-features li { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid #e1e5eb; +} + +.security-features li:last-child { + border-bottom: none; +} + +.security-visual { + display: flex; + justify-content: center; + align-items: center; +} + +.shield-container { + position: relative; + width: 240px; + height: 280px; +} + +.shield-svg { + width: 100%; + height: 100%; +} + +.shield-path { + stroke-dasharray: 600; + stroke-dashoffset: 600; + animation: draw-shield 2s ease-out forwards; +} + +.shield-check { + stroke-dasharray: 100; + stroke-dashoffset: 100; + animation: draw-check 0.5s ease-out 1.5s forwards; +} + +@keyframes draw-shield { + to { stroke-dashoffset: 0; } +} + +@keyframes draw-check { + to { stroke-dashoffset: 0; } +} + +.shield-glow { + position: absolute; + inset: 20%; + background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 70%); + filter: blur(20px); + animation: glow-pulse 3s ease-in-out infinite; +} + +@keyframes glow-pulse { + 0%, 100% { opacity: 0.5; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.1); } +} + +/* CTA Section */ +.cta { + background: linear-gradient(135deg, #6366f1 0%, #10b981 100%); + color: #fff; +} + +.cta-content { + max-width: 600px; + margin: 0 auto; + text-align: center; +} + +.cta-title { + font-size: 36px; + font-weight: 700; + margin: 0 0 16px; +} + +.cta-subtitle { + font-size: 18px; + opacity: 0.9; + margin: 0 0 40px; +} + +.cta-form { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border-radius: 16px; + padding: 32px; +} + +.cta-form .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} + +.cta-form input, +.cta-form textarea { + width: 100%; + padding: 14px 16px; + border: none; + border-radius: 8px; + font-size: 15px; + background: rgba(255, 255, 255, 0.9); + color: #1a1a2e; + font-family: inherit; +} + +.cta-form input::placeholder, +.cta-form textarea::placeholder { + color: #888; +} + +.cta-form textarea { + margin-bottom: 16px; + resize: none; +} + +.cta-form .btn { + background: #fff; + color: #6366f1; +} + +.cta-form .btn:hover { + background: #f8fafc; + transform: translateY(-2px); +} + +.cta-note { + font-size: 13px; + opacity: 0.8; + margin-top: 16px; +} + +.cta-note a { + color: #fff; + text-decoration: underline; +} + +/* Footer */ +.footer { + background: #1a1a2e; + color: #fff; + padding: 80px 0 40px; +} + +.footer-grid { + display: grid; + grid-template-columns: 2fr repeat(4, 1fr); + gap: 40px; + margin-bottom: 60px; +} + +.footer-logo { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.logo-icon { + font-size: 28px; +} + +.logo-text { + font-size: 24px; + font-weight: 700; +} + +.footer-brand p { + font-size: 14px; + color: #888; + margin: 0; +} + +.footer-links h4 { + font-size: 14px; + font-weight: 600; + margin: 0 0 16px; + color: #fff; +} + +.footer-links a { + display: block; + font-size: 14px; + color: #888; + text-decoration: none; + margin-bottom: 10px; + transition: color 0.2s; +} + +.footer-links a:hover { + color: #fff; +} + +.footer-bottom { + padding-top: 40px; + border-top: 1px solid #2d2d3d; + text-align: center; +} + +.footer-bottom p { + font-size: 14px; + color: #888; + margin: 0; +} + +/* Animations */ +.animate-in { + animation: fadeInUp 0.6s ease-out forwards; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive */ +@media (max-width: 1024px) { + .hero-content { + grid-template-columns: 1fr; + text-align: center; + } + + .hero-subtitle { + margin: 0 auto 32px; + } + + .hero-buttons { + justify-content: center; + } + + .hero-stats { + justify-content: center; + } + + .hero-visual { + max-width: 600px; + margin: 0 auto; + } + + .editor-demo { + transform: none; + } + + .simplicity-grid { + grid-template-columns: repeat(2, 1fr); + } + + .tech-card.featured { + grid-column: span 2; + grid-template-columns: 1fr; + } + + .tech-grid { + grid-template-columns: 1fr; + } + + .security-content { + grid-template-columns: 1fr; + text-align: center; + } + + .footer-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .section { + padding: 60px 0; + } + + .section-title { + font-size: 28px; + } + + .hero-title { + font-size: 36px; + } + + .hero-buttons { + flex-direction: column; + } + + .hero-stats { + flex-wrap: wrap; + gap: 24px; + } + + .simplicity-grid { + grid-template-columns: 1fr; + } + + .transform-visual { + flex-direction: column; + } + + .integration-flow { + height: auto; + padding: 40px 0; + } + + .flow-center, .flow-left, .flow-right, .flow-1c, .flow-lines { + position: static; + transform: none; + } + + .flow-left, .flow-right { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + margin-bottom: 20px; + } + + .flow-lines { + display: none; + } + + .integration-benefits { + flex-wrap: wrap; + gap: 40px; + } + + .cta-form .form-row { + grid-template-columns: 1fr; + } + + .footer-grid { + grid-template-columns: 1fr; + text-align: center; + } +} diff --git a/frontend/src/styles/lesson-editor.css b/frontend/src/styles/lesson-editor.css new file mode 100644 index 0000000..21f15f4 --- /dev/null +++ b/frontend/src/styles/lesson-editor.css @@ -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; + } +} diff --git a/frontend/src/styles/pdf-viewer.css b/frontend/src/styles/pdf-viewer.css new file mode 100644 index 0000000..cc04cd9 --- /dev/null +++ b/frontend/src/styles/pdf-viewer.css @@ -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; + } +} diff --git a/frontend/src/styles/test-editor.css b/frontend/src/styles/test-editor.css new file mode 100644 index 0000000..7d44886 --- /dev/null +++ b/frontend/src/styles/test-editor.css @@ -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; + } +} + diff --git a/frontend/src/styles/test-viewer.css b/frontend/src/styles/test-viewer.css new file mode 100644 index 0000000..d0f4ab8 --- /dev/null +++ b/frontend/src/styles/test-viewer.css @@ -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; +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..f298e0b --- /dev/null +++ b/frontend/src/utils/api.js @@ -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'); +} \ No newline at end of file diff --git a/frontend/src/utils/config.js b/frontend/src/utils/config.js new file mode 100644 index 0000000..48aba94 --- /dev/null +++ b/frontend/src/utils/config.js @@ -0,0 +1,2 @@ +export const API_BASE_URL = window.location.origin; +export const APP_NAME = 'Auth Learning App'; \ No newline at end of file diff --git a/frontend/src/views/courseBuilder.js b/frontend/src/views/courseBuilder.js new file mode 100644 index 0000000..e04cf65 --- /dev/null +++ b/frontend/src/views/courseBuilder.js @@ -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 ` +
+

Требуется авторизация

+

Для доступа к конструктору курса необходимо войти в систему.

+ Войти +
`; + } + + return ` +
+
+

Конструктор курса

+

Создайте новый курс с помощью редактора Markdown с поддержкой LaTeX

+
+
+ + +
+
+ + +
+
+ + +
+
+

Структура курса

+

Создайте модули и уроки для вашего курса

+
+ +
+
+

Пока нет модулей. Добавьте первый модуль, чтобы начать.

+
+
+
+

Содержание курса (Vditor редактор)

+
+
+

Возможности редактора:

+
    +
  • Редактирование Markdown в реальном времени
  • +
  • Поддержка LaTeX формул через KaTeX
  • +
  • Встроенный предпросмотр
  • +
  • Подсветка синтаксиса кода
  • +
  • Экспорт в HTML и PDF
  • +
+
+
+
+ + + + +
+
+
+
+
`; +} + +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); } + } +}); diff --git a/frontend/src/views/courseDetail.js b/frontend/src/views/courseDetail.js new file mode 100644 index 0000000..0137517 --- /dev/null +++ b/frontend/src/views/courseDetail.js @@ -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 `

Course not found

`; + } + + 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 ` +
+ ← Back to Courses + +
+

${course.title}

+
+ ${course.level} + ${course.duration} hours + Added ${new Date(course.created_at).toLocaleDateString()} + ${course.status} +
+
+ ${course.status === 'published' ? ` + + 🎓 Начать обучение + + ` : ''} + ${isAuthor ? ` + + ✏️ Редактировать курс + + ` : ''} +
+
+ +
+
+

Description

+

${course.description}

+
+ + ${currentUser ? ` +
+
+

Favorites

+ +
+ +
+

Progress Tracking

+ ${progress ? ` +
+
+
+
+

${progress.completed_lessons} of ${progress.total_lessons} lessons completed

+ +
+ ` : ` +

You haven't started this course yet.

+ + `} +
+
+ ` : ` +
+

Login to add this course to favorites and track your progress.

+
+ `} +
+
+ `; +} + +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); + } + } + }); + } +} \ No newline at end of file diff --git a/frontend/src/views/courseViewer.js b/frontend/src/views/courseViewer.js new file mode 100644 index 0000000..725aaab --- /dev/null +++ b/frontend/src/views/courseViewer.js @@ -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 `

Ошибка загрузки курса

${error.message}

← К курсам
`; + } + + if (!currentCourse || !currentCourse.modules || currentCourse.modules.length === 0) { + return `

Курс пуст

В курсе нет модулей.

← К описанию курса
`; + } + + loadedSteps.clear(); + + return ` +
+
+
+ ← К описанию +

${currentCourse.title}

+
+
+
+
+
+ +
+ + +
+
+
+
+

Загрузка контента...

+
+
+ +
+
+
+
+ `; +} + +function renderStructureTree() { + if (!currentCourse.modules) return '

Нет модулей

'; + + return currentCourse.modules.map((module, mIdx) => ` +
+
+ 📁 + ${module.title} + ${module.duration ? `${module.duration}` : ''} +
+
+ ${module.lessons && module.lessons.length > 0 ? module.lessons.map((lesson, lIdx) => ` +
+ 📄 + ${lesson.title} +
+ `).join('') : '
Нет уроков
'} +
+
+ `).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 ` +
+
+

Загрузка контента...

+
+ `; + } + + if (!step) { + return `

Шаг не найден

`; + } + + const stepTypeClass = `step-type-${step.step_type}`; + + return ` +
+
+ ${module.title} + + ${lesson.title} +
+
+ ${stepTypeIcons[step.step_type] || '📄'} + ${stepTypeLabels[step.step_type] || step.step_type} +
+
+ + ${step.title ? `

${step.title}

` : ''} + +
+ ${renderStepBodyContent(step)} +
+ `; +} + +function renderStepBodyContent(step) { + if (step.step_type === 'presentation' && step.file_url) { + return `
`; + } + if (step.step_type === 'test') { + return `
`; + } + return step.content_html ? step.content_html : `
${step.content || '

Нет содержимого

'}
`; +} + +function renderNoContent(message) { + return `

${message}

`; +} + +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 = ` +
+ ${steps.map((step, idx) => ` + + `).join('')} +
+ `; + + if (stepNav) stepNav.innerHTML = navHtml; + + const bottomNavHtml = ` + + `; + + 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 = `

${lesson.title}

В этом уроке нет шагов

`; + 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 = `

Ошибка загрузки контента: ${error.message}

`; + } + } + + 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); +} diff --git a/frontend/src/views/courses.js b/frontend/src/views/courses.js new file mode 100644 index 0000000..eb747fd --- /dev/null +++ b/frontend/src/views/courses.js @@ -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 ` +
+

Course Catalog

+ + + +
+ ${courses.map(course => ` +
+
+

${course.title}

+ ${course.level} +
+

${course.description}

+
+ ${course.duration} hours + ${new Date(course.created_at).toLocaleDateString()} +
+
+ View Details + ${currentUser ? ` + + ` : ''} +
+
+ `).join('')} +
+
+ `; +} + +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 = ' Added to Favorites'; + e.target.disabled = true; + e.target.classList.add('favorited'); + } catch (error) { + alert('Failed to add to favorites: ' + error.message); + } + }); + }); +} \ No newline at end of file diff --git a/frontend/src/views/devTools.js b/frontend/src/views/devTools.js new file mode 100644 index 0000000..9227a41 --- /dev/null +++ b/frontend/src/views/devTools.js @@ -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 ` +
+

Требуется авторизация

+

Для доступа к инструментам разработчика необходимо войти в систему.

+ Войти +
+ `; + } + + return ` +
+
+

Инструменты разработчика

+ +
+

Статус разработчика

+

Текущий статус: ${currentUser.is_developer ? '✅ Разработчик (виден раздел Инструменты)' : '❌ Не разработчик'}

+ + +
+ +
+

Управление хранилищем

+

Очистка локального хранилища приложения

+ +
+
+
+

Очистить localStorage

+

Удалить все данные из localStorage браузера

+
+ +
+ +
+
+

Очистить session storage

+

Удалить все данные из session storage

+
+ +
+ +
+
+

Скачать localStorage

+

Сохранить содержимое localStorage в файл

+
+ +
+
+
+ +
+
+
+ `; +} + +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(); +} \ No newline at end of file diff --git a/frontend/src/views/favorites.js b/frontend/src/views/favorites.js new file mode 100644 index 0000000..dcef46a --- /dev/null +++ b/frontend/src/views/favorites.js @@ -0,0 +1,86 @@ +import { coursesAPI } from '../api/courses.js'; +import { currentUser } from '../components/auth.js'; + +export async function render() { + if (!currentUser) { + return ` +
+

Authentication Required

+

Please login to view your favorite courses.

+
+ `; + } + + 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 ` +
+

My Favorite Courses

+ + ${favoriteCourses.length === 0 ? ` +
+

You haven't added any courses to favorites yet.

+ Browse Courses +
+ ` : ` +
+ ${favoriteCourses.map(course => ` +
+
+

${course.title}

+ ${course.level} +
+

${course.description}

+
+ ${course.duration} hours + ${new Date(course.created_at).toLocaleDateString()} +
+
+ View Details + +
+
+ `).join('')} +
+ `} +
+ `; +} + +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 = ` +
+

You haven't added any courses to favorites yet.

+ Browse Courses +
+ `; + } + } catch (error) { + alert('Failed to remove from favorites: ' + error.message); + } + } + }); + }); +} \ No newline at end of file diff --git a/frontend/src/views/home.js b/frontend/src/views/home.js new file mode 100644 index 0000000..7050ba5 --- /dev/null +++ b/frontend/src/views/home.js @@ -0,0 +1,618 @@ +export async function render() { + return ` +
+ +
+
+ +
+
+
+

+ Умное корпоративное обучение
+ в формате Markdown +

+

+ Создавайте курсы так же быстро, как пишете текст. + Автоматическая синхронизация прогресса и аналитики + напрямую с вашим корпоративным контуром 1С. +

+
+ + + Возможности платформы + +
+
+
+ 10x + быстрее создание курсов +
+
+ 99.9% + uptime +
+
+ 50+ + интеграций +
+
+
+
+
+
+
+ +
+ course.md +
+
+
+
# Введение в Python
+
+## Переменные и типы данных
+
+Python — язык с **динамической** типизацией.
+
+\`\`\`python
+name = "Alice"
+age = 30
+\`\`\`
+
+$x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$
+
+
+ + + +
+
+
+

Введение в Python

+

Переменные и типы данных

+

Python — язык с динамической типизацией.

+
+ name = "Alice" + age = 30 +
+
x = (-b ± √(b²-4ac)) / 2a
+
+
+
+
+
+
+
+ Листайте вниз +
+
+
+ + +
+
+
+ Простота +

Создавайте курсы за минуты, не часы

+

+ Забудьте о громоздких визуальных конструкторах. + Используйте привычный Markdown-синтаксис для создания интерактивного контента. +

+
+
+
+
+ + + + + + +
+

Markdown-first

+

Пишите контент в привычном формате. Платформа автоматически преобразует его в интерактивные модули.

+
+
+
+ + + + + + +
+

Автоструктура

+

Заголовки автоматически формируют модули и уроки. Не нужно настраивать навигацию вручную.

+
+
+
+ + + + + +
+

Мультимедиа

+

Встраивайте изображения, видео, код с подсветкой и математические формулы LaTeX.

+
+
+
+ + + +
+

Мгновенный предпросмотр

+

Видите результат в реальном времени. WYSIWYG-режим для тех, кто предпочитает визуальное редактирование.

+
+
+
+
+
+ 📝 + Плоский текст +
+
+ + + + + + + + + +
+
+ 🎓 + Интерактивный курс +
+
+
+
+
+ + +
+
+
+ Интеграция +

Глубокая интеграция с 1С

+

+ Автоматический обмен данными между платформой обучения + и корпоративными системами учёта. Никакого ручного переноса. +

+
+
+
+
+
+ + + + +
+ Платформа
обучения
+
+
+
+
+
📊
+
+

Результаты тестов

+

Автоматическая передача оценок и сертификатов

+
+
+
+
⏱️
+
+

Время обучения

+

Учёт рабочего времени в ЗУП

+
+
+
+
📈
+
+

Метрики вовлечённости

+

Аналитика для HR-отчётности

+
+
+
+
+
+
👥
+
+

Сотрудники

+

Синхронизация оргструктуры

+
+
+
+
🏢
+
+

Подразделения

+

Автоматическое назначение курсов

+
+
+
+
📋
+
+

Должности

+

Персональные траектории обучения

+
+
+
+
+
+ + ЗУП • ERP • CRM +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ 100% + автоматизация отчётности +
+
+ 0 + ручного ввода данных +
+
+ 24/7 + синхронизация в реальном времени +
+
+
+
+ + +
+
+
+ Технологии +

Умное обучение для современных команд

+
+
+ +
+
+
+
Филиал А
+
Филиал Б
+
HQ
+
Центр управления
+
+
+

Мультиарендность

+

Изолированные личные кабинеты для разных филиалов и отделов с единой точкой администрирования.

+
+
+
+
+
+
+ SSR +
+
+
+ Cache +
+
+
+ CDN +
+
+
+

Высокая производительность

+

Плавная работа на любых устройствах благодаря SSR, умному кэшированию и глобальной CDN-сети.

+
+
+
+
+ + +
+
+
+
+ Безопасность +

Защита корпоративных данных

+

+ Гибкая ролевая модель доступа и соответствие требованиям + информационной безопасности корпоративного сектора. +

+
    +
  • + + + + + RBAC — ролевая модель доступа +
  • +
  • + + + + + JWT-токены с ротацией +
  • +
  • + + + + + Шифрование данных в покое и в транзите +
  • +
  • + + + + + Audit Log — полный аудит действий +
  • +
  • + + + + + Возможность on-premise развертывания +
  • +
+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+ + +
+
+
+

Готовы оцифровать и автоматизировать обучение?

+

+ Получите персональную демонстрацию платформы и узнайте, + как оптимизировать корпоративное обучение в вашей компании. +

+
+
+ + +
+
+ + +
+ + +
+

+ Нажимая кнопку, вы соглашаетесь с + политикой конфиденциальности +

+
+
+
+ + + +
+ `; +} + +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); + }); +} diff --git a/frontend/src/views/lessonEditor.js b/frontend/src/views/lessonEditor.js new file mode 100644 index 0000000..437821c --- /dev/null +++ b/frontend/src/views/lessonEditor.js @@ -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 ` +
+

Требуется авторизация

+

Для редактирования урока необходимо войти в систему.

+ Войти +
`; + } + + const urlParams = new URLSearchParams(window.location.search); + moduleIndex = parseInt(urlParams.get('moduleIndex')); + lessonIndex = parseInt(urlParams.get('lessonIndex')); + + return ` +
+
+ +
+ Сохранено + + +
+
+ +
+ + +
+
+
Выберите шаг для редактирования
+
+
+
+
+ `; +} + +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 = '

Урок не найден

'; + 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 = `

Ошибка загрузки: ${error.message}

`; + } +} + +function renderStepsList() { + const container = document.getElementById('steps-list'); + + if (!currentLesson.steps || currentLesson.steps.length === 0) { + container.innerHTML = '

Нет шагов

'; + return; + } + + container.innerHTML = currentLesson.steps.map((step, index) => ` +
+
⋮⋮
+
+ ${stepTypeIcons[step.step_type] || '📄'} + ${index + 1} + ${step.title || stepTypeLabels[step.step_type]} +
+
+ +
+
+ `).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 = '
Выберите шаг для редактирования
'; + return; + } + + console.log('Rendering step editor for step type:', step.step_type); + + container.innerHTML = ` +
+
+ + +
+ +
+ + +
+ +
+ ${renderStepContentEditor(step)} +
+
+ `; + + 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 ` +
+ +
+
+ `; + case 'presentation': + return ` +
+ +
+ ${step.file_url ? ` +
+ ${step.file_url.split('/').pop()} + +
+ ` : ` +
+ + + + + + +

Перетащите PDF файл сюда или

+ + +
+ `} +
+
+ `; + case 'test': + return `
`; + default: + return ` +
+ + +
+ `; + } +} + +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 = '

Сохраните шаг, чтобы редактировать тест

'; + 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 = '

Ошибка загрузки редактора теста: ' + error.message + '

'; + } +} + +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 = '
Нет шагов
'; + document.getElementById('preview-content').innerHTML = '
Предпросмотр появится здесь
'; + } + + } 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 = `
${step.content || '

Нет содержимого

'}
`; + } + } else if (step.step_type === 'presentation' && step.file_url) { + content = ` +
+
+ 📊 + ${step.file_url.split('/').pop()} +
+

PDF файл будет отображён в режиме просмотра курса

+
+ `; + } else { + content = `
${step.content || '

Нет содержимого

'}
`; + } + + const modalHtml = ` + + `; + + 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); +} diff --git a/frontend/src/views/login.js b/frontend/src/views/login.js new file mode 100644 index 0000000..6e26c66 --- /dev/null +++ b/frontend/src/views/login.js @@ -0,0 +1,136 @@ +import { login, register } from '../components/auth.js'; +import { router } from '../router.js'; + +export async function render() { + return ` + + `; +} + +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'; + } + }); +} \ No newline at end of file diff --git a/frontend/src/views/progress.js b/frontend/src/views/progress.js new file mode 100644 index 0000000..04aeab0 --- /dev/null +++ b/frontend/src/views/progress.js @@ -0,0 +1,120 @@ +import { coursesAPI } from '../api/courses.js'; +import { currentUser } from '../components/auth.js'; + +export async function render() { + if (!currentUser) { + return ` +
+

Authentication Required

+

Please login to view your progress.

+
+ `; + } + + // 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 ` +
+

My Learning Progress

+ + ${coursesWithProgress.length === 0 ? ` +
+

You haven't started any courses yet.

+ Browse Courses +
+ ` : ` +
+ ${coursesWithProgress.map(({ course, progress }) => { + const percentage = progress.percentage || + (progress.completed_lessons / progress.total_lessons * 100); + return ` +
+
+

${course.title}

+ ${percentage.toFixed(1)}% +
+
+
+
+
+ ${progress.completed_lessons} of ${progress.total_lessons} lessons completed + +
+
+ `; + }).join('')} +
+ `} + + ${coursesWithoutProgress.length > 0 ? ` +
+

Available Courses

+
+ ${coursesWithoutProgress.slice(0, 3).map(({ course }) => ` +
+

${course.title}

+ ${course.level} + +
+ `).join('')} +
+
+ ` : ''} +
+ `; +} + +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); + } + } + }); + }); +} \ No newline at end of file diff --git a/plan130226.md b/plan130226.md new file mode 100644 index 0000000..2e50e6a --- /dev/null +++ b/plan130226.md @@ -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. **Нужно ли:** + - Версионирование редакций курса? + - Автосохранение с откатом? + - Публикация без модерации? + +Соблюдение одного этапа за один раз или сразу запускать полную разработку? \ No newline at end of file diff --git a/v2.md b/v2.md new file mode 100644 index 0000000..a2a41dd --- /dev/null +++ b/v2.md @@ -0,0 +1,1111 @@ +# Руководство по работе с Vditor +## 6. Отображение контента без редактора +### 6.1 Рендеринг HTML с поддержкой KaTeX +```javascript +// Функция для отображения контента без редактора +function renderContentWithoutEditor(content, containerId, options = {}) { + const container = document.getElementById(containerId); + if (!container) { + console.error(`Контейнер ${containerId} не найден`); + return; + } + + const config = { + enableMath: true, + enableSyntaxHighlight: true, + theme: 'light', + ...options + }; + + // Конвертация Markdown в HTML если нужно + let htmlContent; + if (content.markdown) { + htmlContent = convertMarkdownToHTML(content.markdown); + } else if (content.html) { + htmlContent = content.html; + } else { + htmlContent = convertMarkdownToHTML(content); + } + + // Создание структуры для отображения + container.innerHTML = ` +
+
+ ${htmlContent} +
+
+ `; + + // Инициализация KaTeX для рендеринга формул + if (config.enableMath && typeof katex !== 'undefined') { + renderLatexFormulas(container); + } + + // Подсветка синтаксиса + if (config.enableSyntaxHighlight && typeof hljs !== 'undefined') { + hljs.highlightAll(); + } + + // Добавление стилей если их нет + addContentStyles(); +} +// Рендеринг LaTeX формул в контейнере +function renderLatexFormulas(container) { + if (!container || typeof katex === 'undefined') return; + + // Рендеринг display формул ($$...$$) + container.querySelectorAll('.language-math.display, .katex-display').forEach(element => { + try { + const formula = element.textContent.trim(); + katex.render(formula, element, { + displayMode: true, + throwOnError: false, + errorColor: '#cc0000' + }); + } catch (error) { + console.error('Ошибка рендеринга display формулы:', error); + element.innerHTML = `
Ошибка формулы: ${element.textContent}
`; + } + }); + + // Рендеринг inline формул ($...$) + container.querySelectorAll('.language-math:not(.display), .katex').forEach(element => { + try { + const formula = element.textContent.trim(); + katex.render(formula, element, { + displayMode: false, + throwOnError: false, + errorColor: '#cc0000' + }); + } catch (error) { + console.error('Ошибка рендеринга inline формулы:', error); + element.innerHTML = `${element.textContent}`; + } + }); + + // Обработка формул в тексте (если они не обернуты в теги) + const textNodes = Array.from(container.childNodes).filter(node => + node.nodeType === Node.TEXT_NODE && + (node.textContent.includes('$') || node.textContent.includes('\\\\\\\\[')) + ); + + textNodes.forEach(node => { + const wrapper = document.createElement('span'); + wrapper.innerHTML = node.textContent + .replace(/\\\\$\\\\$(.*?)\\\\$\\\\$/g, (match, formula) => { + try { + return katex.renderToString(formula, { displayMode: true }); + } catch (e) { + return `
Ошибка: ${formula}
`; + } + }) + .replace(/\\\\$(.*?)\\\\$/g, (match, formula) => { + try { + return katex.renderToString(formula, { displayMode: false }); + } catch (e) { + return `${formula}`; + } + }); + + node.parentNode.replaceChild(wrapper, node); + }); +} +// Добавление необходимых стилей +function addContentStyles() { + if (document.getElementById('content-render-styles')) return; + + const styles = ` + + `; + + document.head.insertAdjacentHTML('beforeend', styles); +} +``` + +### 6.2 Пример использования +```javascript +// Пример 1: Отображение сохраненного контента +async function displaySavedContent(contentId) { + try { + // Загрузка контента из базы данных + const response = await fetch(`/api/content/${contentId}`); + const content = await response.json(); + + // Отображение в контейнере + renderContentWithoutEditor(content, 'content-container', { + theme: 'light', + enableMath: true, + enableSyntaxHighlight: true + }); + + } catch (error) { + console.error('Ошибка загрузки контента:', error); + document.getElementById('content-container').innerHTML = ` +
+

Ошибка загрузки контента

+

${error.message}

+
+ `; + } +} +// Пример 2: Предпросмотр перед сохранением +function previewContent() { + if (!vditorInstance) return; + + const content = { + markdown: vditorInstance.getValue(), + html: vditorInstance.getHTML() + }; + + // Открытие предпросмотра в модальном окне + const modal = document.createElement('div'); + modal.className = 'preview-modal'; + modal.innerHTML = ` +
+
+

Предпросмотр

+ +
+
+
+ `; + + document.body.appendChild(modal); + + // Отображение контента + renderContentWithoutEditor(content, 'preview-container', { + theme: 'light', + enableMath: true + }); + + // Закрытие модального окна + modal.querySelector('.close-btn').addEventListener('click', () => { + modal.remove(); + }); + + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.remove(); + } + }); +} +// Пример 3: Экспорт в PDF/печать +function exportToPDF(content, filename = 'document') { + const printWindow = window.open('', '_blank'); + + printWindow.document.write(` + + + + ${filename} + + + + + + + ${content.html || convertMarkdownToHTML(content.markdown || content)} + + + `); + + printWindow.document.close(); + + // Автоматическая печать после загрузки + printWindow.onload = function() { + printWindow.print(); + }; +} +``` +### 6.3 Оптимизация производительности +```javascript +// Ленивая загрузка KaTeX и Highlight.js +let katexLoaded = false; +let hljsLoaded = false; +async function loadMathRenderer() { + if (katexLoaded) return; + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js'; + script.onload = () => { + katexLoaded = true; + resolve(); + }; + script.onerror = reject; + document.head.appendChild(script); + + const css = document.createElement('link'); + css.rel = 'stylesheet'; + css.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css'; + document.head.appendChild(css); + }); +} +async function loadSyntaxHighlighter() { + if (hljsLoaded) return; + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js'; + script.onload = () => { + hljsLoaded = true; + resolve(); + }; + script.onerror = reject; + document.head.appendChild(script); + + const css = document.createElement('link'); + css.rel = 'stylesheet'; + css.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; + document.head.appendChild(css); + }); +} +// Оптимизированный рендеринг с ленивой загрузкой +async function renderContentOptimized(content, containerId) { + const container = document.getElementById(containerId); + + // Показываем спиннер загрузки + container.innerHTML = '
Загрузка...
'; + + try { + // Параллельная загрузка зависимостей + await Promise.all([ + loadMathRenderer(), + loadSyntaxHighlighter() + ]); + + // Рендеринг контента + renderContentWithoutEditor(content, containerId, { + enableMath: true, + enableSyntaxHighlight: true + }); + + } catch (error) { + console.error('Ошибка загрузки зависимостей:', error); + container.innerHTML = ` +
+

Ошибка отображения контента

+

${error.message}

+ +
+ `; + } +} +``` +## 7. Хранение в базе данных +### 7.1 Структура таблиц для хранения контента +```sql +-- Таблица для хранения курсов/статей +CREATE TABLE content ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + description TEXT, + markdown_content TEXT NOT NULL, + html_content TEXT, + metadata JSONB DEFAULT '{}', + author_id UUID REFERENCES users(id), + status VARCHAR(50) DEFAULT 'draft', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + published_at TIMESTAMP WITH TIME ZONE, + version INTEGER DEFAULT 1 +); +-- Индексы для оптимизации поиска +CREATE INDEX idx_content_author ON content(author_id); +CREATE INDEX idx_content_status ON content(status); +CREATE INDEX idx_content_created ON content(created_at); +CREATE INDEX idx_content_title ON content(title); +CREATE INDEX idx_content_metadata ON content USING gin(metadata); +-- Таблица для версий контента (история изменений) +CREATE TABLE content_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + content_id UUID REFERENCES content(id) ON DELETE CASCADE, + markdown_content TEXT NOT NULL, + html_content TEXT, + version INTEGER NOT NULL, + change_description TEXT, + author_id UUID REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +-- Индекс для быстрого поиска версий +CREATE INDEX idx_content_versions_content ON content_versions(content_id); +CREATE INDEX idx_content_versions_version ON content_versions(version); +-- Таблица для извлеченных формул (для поиска и индексации) +CREATE TABLE content_formulas ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + content_id UUID REFERENCES content(id) ON DELETE CASCADE, + formula_text TEXT NOT NULL, + formula_type VARCHAR(20), -- 'inline' или 'display' + position INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +-- Индекс для поиска формул +CREATE INDEX idx_formulas_content ON content_formulas(content_id); +CREATE INDEX idx_formulas_text ON content_formulas USING gin(formula_text gin_trgm_ops); +``` +### 7.2 Модели данных для Node.js/Express +```javascript +// models/Content.js +const { DataTypes } = require('sequelize'); +module.exports = (sequelize) => { + const Content = sequelize.define('Content', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: { + type: DataTypes.STRING(255), + allowNull: false, + validate: { + notEmpty: true + } + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + markdownContent: { + type: DataTypes.TEXT, + allowNull: false, + field: 'markdown_content' + }, + htmlContent: { + type: DataTypes.TEXT, + allowNull: true, + field: 'html_content' + }, + metadata: { + type: DataTypes.JSONB, + defaultValue: {}, + get() { + const rawValue = this.getDataValue('metadata'); + return rawValue ? rawValue : {}; + } + }, + status: { + type: DataTypes.ENUM('draft', 'published', 'archived'), + defaultValue: 'draft' + }, + version: { + type: DataTypes.INTEGER, + defaultValue: 1 + }, + publishedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'published_at' + } + }, { + tableName: 'content', + timestamps: true, + underscored: true, + hooks: { + beforeSave: async (content) => { + // Автоматическая генерация HTML из Markdown + if (content.changed('markdownContent') && content.markdownContent) { + content.htmlContent = await convertMarkdownToHTML(content.markdownContent); + + // Извлечение и сохранение формул + await extractAndSaveFormulas(content); + } + + // Обновление версии при изменении контента + if (content.changed('markdownContent')) { + content.version += 1; + } + + // Обновление времени публикации + if (content.changed('status') && content.status === 'published') { + content.publishedAt = new Date(); + } + }, + afterSave: async (content) => { + // Сохранение версии в историю + if (content.changed('markdownContent')) { + await sequelize.models.ContentVersion.create({ + contentId: content.id, + markdownContent: content.markdownContent, + htmlContent: content.htmlContent, + version: content.version, + authorId: content.authorId + }); + } + } + } + }); + Content.associate = (models) => { + Content.belongsTo(models.User, { + foreignKey: 'author_id', + as: 'author' + }); + + Content.hasMany(models.ContentVersion, { + foreignKey: 'content_id', + as: 'versions' + }); + + Content.hasMany(models.ContentFormula, { + foreignKey: 'content_id', + as: 'formulas' + }); + }; + return Content; +}; +// models/ContentVersion.js +module.exports = (sequelize) => { + const ContentVersion = sequelize.define('ContentVersion', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + markdownContent: { + type: DataTypes.TEXT, + allowNull: false, + field: 'markdown_content' + }, + htmlContent: { + type: DataTypes.TEXT, + allowNull: true, + field: 'html_content' + }, + version: { + type: DataTypes.INTEGER, + allowNull: false + }, + changeDescription: { + type: DataTypes.TEXT, + allowNull: true, + field: 'change_description' + } + }, { + tableName: 'content_versions', + timestamps: true, + underscored: true + }); + ContentVersion.associate = (models) => { + ContentVersion.belongsTo(models.Content, { + foreignKey: 'content_id', + as: 'content' + }); + + ContentVersion.belongsTo(models.User, { + foreignKey: 'author_id', + as: 'author' + }); + }; + return ContentVersion; +}; +// Вспомогательные функции +async function convertMarkdownToHTML(markdown) { + // Используем Lute или marked.js для конвертации + // с поддержкой KaTeX + // ... +} +async function extractAndSaveFormulas(content) { + const formulas = extractLatexFormulas(content.markdownContent); + + // Удаляем старые формулы + await sequelize.models.ContentFormula.destroy({ + where: { contentId: content.id } + }); + + // Сохраняем новые формулы + const formulaPromises = []; + + formulas.inline.forEach((formula, index) => { + formulaPromises.push( + sequelize.models.ContentFormula.create({ + contentId: content.id, + formulaText: formula.formula, + formulaType: 'inline', + position: formula.position + }) + ); + }); + + formulas.display.forEach((formula, index) => { + formulaPromises.push( + sequelize.models.ContentFormula.create({ + contentId: content.id, + formulaText: formula.formula, + formulaType: 'display', + position: formula.position + }) + ); + }); + + await Promise.all(formulaPromises); +} +``` +### 7.3 API endpoints для работы с контентом +```javascript +// routes/content.js +const express = require('express'); +const router = express.Router(); +const { Content, ContentVersion } = require('../models'); +const auth = require('../middleware/auth'); +// Получение списка контента +router.get('/', auth, async (req, res) => { + try { + const { page = 1, limit = 20, status, search } = req.query; + const offset = (page - 1) * limit; + + const where = {}; + + if (status) { + where.status = status; + } + + if (search) { + where[Op.or] = [ + { title: { [Op.iLike]: `%${search}%` } }, + { description: { [Op.iLike]: `%${search}%` } } + ]; + } + + const { count, rows } = await Content.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['created_at', 'DESC']], + include: [{ + model: User, + as: 'author', + attributes: ['id', 'name', 'email'] + }] + }); + + res.json({ + data: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + pages: Math.ceil(count / limit) + } + }); + + } catch (error) { + console.error('Ошибка получения контента:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); +// Создание нового контента +router.post('/', auth, async (req, res) => { + try { + const { title, description, markdownContent, metadata } = req.body; + + const content = await Content.create({ + title, + description, + markdownContent, + metadata: metadata || {}, + authorId: req.user.id + }); + + res.status(201).json({ + message: 'Контент создан', + data: content + }); + + } catch (error) { + console.error('Ошибка создания контента:', error); + res.status(400).json({ error: error.message }); + } +}); +// Получение контента по ID +router.get('/:id', async (req, res) => { + try { + const content = await Content.findByPk(req.params.id, { + include: [{ + model: User, + as: 'author', + attributes: ['id', 'name', 'email'] + }] + }); + + if (!content) { + return res.status(404).json({ error: 'Контент не найден' }); + } + + res.json({ + data: content + }); + + } catch (error) { + console.error('Ошибка получения контента:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); +// Обновление контента +router.put('/:id', auth, async (req, res) => { + try { + const { title, description, markdownContent, metadata } = req.body; + + const content = await Content.findByPk(req.params.id); + + if (!content) { + return res.status(404).json({ error: 'Контент не найден' }); + } + + // Проверка прав доступа + if (content.authorId !== req.user.id && !req.user.isAdmin) { + return res.status(403).json({ error: 'Доступ запрещен' }); + } + + // Обновление полей + await content.update({ + title: title || content.title, + description: description !== undefined ? description : content.description, + markdownContent: markdownContent || content.markdownContent, + metadata: metadata || content.metadata + }); + + res.json({ + message: 'Контент обновлен', + data: content + }); + + } catch (error) { + console.error('Ошибка обновления контента:', error); + res.status(400).json({ error: error.message }); + } +}); +// Публикация контента +router.post('/:id/publish', auth, async (req, res) => { + try { + const content = await Content.findByPk(req.params.id); + + if (!content) { + return res.status(404).json({ error: 'Контент не найден' }); + } + + if (content.authorId !== req.user.id && !req.user.isAdmin) { + return res.status(403).json({ error: 'Доступ запрещен' }); + } + + await content.update({ + status: 'published', + publishedAt: new Date() + }); + + res.json({ + message: 'Контент опубликован', + data: content + }); + + } catch (error) { + console.error('Ошибка публикации:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); +// Получение версий контента +router.get('/:id/versions', auth, async (req, res) => { + try { + const versions = await ContentVersion.findAll({ + where: { contentId: req.params.id }, + order: [['version', 'DESC']], + include: [{ + model: User, + as: 'author', + attributes: ['id', 'name', 'email'] + }] + }); + + res.json({ + data: versions + }); + + } catch (error) { + console.error('Ошибка получения версий:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); +// Восстановление версии +router.post('/:id/restore/:versionId', auth, async (req, res) => { + try { + const version = await ContentVersion.findByPk(req.params.versionId, { + include: [{ + model: Content, + as: 'content', + where: { id: req.params.id } + }] + }); + + if (!version) { + return res.status(404).json({ error: 'Версия не найдена' }); + } + + const content = version.content; + + if (content.authorId !== req.user.id && !req.user.isAdmin) { + return res.status(403).json({ error: 'Доступ запрещен' }); + } + + // Восстановление контента из версии + await content.update({ + markdownContent: version.markdownContent, + htmlContent: version.htmlContent, + version: content.version + 1 + }); + + res.json({ + message: 'Версия восстановлена', + data: content + }); + + } catch (error) { + console.error('Ошибка восстановления:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); +module.exports = router; +``` +### 7.4 Оптимизация хранения больших документов +```javascript +// Стратегии хранения больших документов +// 1. Компрессия контента +async function compressContent(content) { + // Используем gzip для сжатия больших текстов + const compressed = await new Promise((resolve, reject) => { + zlib.gzip(content, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); + + return compressed.toString('base64'); +} +async function decompressContent(compressed) { + const buffer = Buffer.from(compressed, 'base64'); + + return new Promise((resolve, reject) => { + zlib.gunzip(buffer, (err, result) => { + if (err) reject(err); + else resolve(result.toString()); + }); + }); +} +// 2. Разделение контента на части +async function splitLargeContent(content, maxChunkSize = 100000) { + if (content.length <= maxChunkSize) { + return [content]; + } + + const chunks = []; + let currentChunk = ''; + const lines = content.split('\\ +'); + + for (const line of lines) { + if ((currentChunk + line + '\\ +').length > maxChunkSize) { + chunks.push(currentChunk); + currentChunk = line + '\\ +'; + } else { + currentChunk += line + '\\ +'; + } + } + + if (currentChunk) { + chunks.push(currentChunk); + } + + return chunks; +} +// 3. Ленивая загрузка частей контента +class LazyContentLoader { + constructor(contentId) { + this.contentId = contentId; + this.chunks = []; + this.loadedChunks = new Set(); + } + + async getChunk(index) { + if (this.loadedChunks.has(index)) { + return this.chunks[index]; + } + + // Загрузка чанка из базы данных + const response = await fetch(`/api/content/${this.contentId}/chunks/${index}`); + const chunk = await response.json(); + + this.chunks[index] = chunk; + this.loadedChunks.add(index); + + return chunk; + } + + async *getContent() { + // Получаем метаданные о количестве чанков + const metadata = await fetch(`/api/content/${this.contentId}/metadata`); + const { totalChunks } = await metadata.json(); + + for (let i = 0; i < totalChunks; i++) { + yield await this.getChunk(i); + } + } +} +// 4. Кэширование в Redis +const redis = require('redis'); +const client = redis.createClient(); +async function getContentWithCache(contentId) { + const cacheKey = `content:${contentId}`; + + // Попытка получить из кэша + const cached = await client.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + // Загрузка из базы данных + const content = await Content.findByPk(contentId); + if (!content) { + return null; + } + + // Сохранение в кэш на 1 час + await client.setex(cacheKey, 3600, JSON.stringify(content)); + + return content; +} +// 5. Индексация для полнотекстового поиска +async function indexContentForSearch(content) { + // Извлечение текста без Markdown разметки + const plainText = content.markdownContent + .replace(/#+\\\\s*/g, '') // Удаляем заголовки + .replace(/\\\\*\\\\*?(.*?)\\\\*\\\\*?/g, '$1') // Удаляем жирный/курсив + .replace(/`(.*?)`/g, '$1') // Удаляем inline код + .replace(/\\\\[(.*?)\\\\]\\\\(.*?\\\\)/g, '$1') // Удаляем ссылки + .replace(/\\\\$\\\\$?(.*?)\\\\$?\\\\$/g, '') // Удаляем формулы + .replace(/```[\\\\s\\\\S]*?```/g, '') // Удаляем блоки кода + .replace(/[^\\\\w\\\\sа-яА-ЯёЁ]/g, ' ') // Оставляем только буквы и пробелы + .replace(/\\\\s+/g, ' ') // Убираем лишние пробелы + .trim(); + + // Сохранение в поисковый индекс (Elasticsearch или PostgreSQL tsvector) + await saveToSearchIndex(content.id, plainText, { + title: content.title, + description: content.description, + author: content.authorId, + status: content.status, + publishedAt: content.publishedAt + }); +} +``` +## 8. Примеры использования +### 8.1 Полный пример: Система управления курсами +```javascript +// course-system.js +class CourseSystem { + constructor() { + this.vditorInstance = null; + this.currentCourse = null; + this.draftManager = null; + } + + // Инициализация системы + async initialize() { + // Загрузка Vditor + await this.loadVditor(); + + // Инициализация менеджера черновиков + this.draftManager = new DraftManager(this.vditorInstance, 'course_draft'); + this.draftManager.enableAutoSave(30000); // Автосохранение каждые 30 секунд + + // Настройка обработчиков событий + this.setupEventHandlers(); + + // Загрузка сохраненного черновика + const draft = this.draftManager.loadDraft(); + if (draft) { + this.showDraftNotification(draft); + } + + console.log('Система управления курсами инициализирована'); + } + + // Загрузка Vditor + async loadVditor() { + return new Promise((resolve, reject) => { + if (typeof Vditor !== 'undefined') { + this.initVditorEditor(); + resolve(); + return; + } + + const script = document.createElement('script'); + script.src = 'https://unpkg.com/vditor@3.10.4/dist/index.min.js'; + script.onload = () => { + this.initVditorEditor(); + resolve(); + }; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + // Инициализация редактора + initVditorEditor() { + this.vditorInstance = new Vditor('vditor-container', { + height: 600, + placeholder: 'Начните создавать курс...', + theme: 'classic', + lang: 'ru_RU', + toolbar: [ + 'emoji', 'headings', 'bold', 'italic',. \ No newline at end of file diff --git a/veditor.md b/veditor.md new file mode 100644 index 0000000..f195439 --- /dev/null +++ b/veditor.md @@ -0,0 +1,1038 @@ +# Руководство по работе с Vditor +## Содержание +1. [Введение](#введение) +2. [Инициализация редактора](#инициализация-редактора) +3. [Работа с API редактора](#работа-с-api-редактора) +4. [Сохранение и загрузка контента](#сохранение-и-загрузка-контента) +5. [Работа с HTML и Markdown](#работа-с-html-и-markdown) +6. [Отображение контента без редактора](#отображение-контента-без-редактора) +7. [Хранение в базе данных](#хранение-в-базе-данных) +8. [Примеры использования](#примеры-использования) +## Введение +Vditor - это мощный WYSIWYG Markdown редактор с поддержкой LaTeX формул через KaTeX. Редактор предоставляет богатый API для работы с контентом. + +## Инициализация редактора + +### Базовый пример инициализации + +```javascript +let vditorInstance = null; +function initVditor() { + // Загрузка скрипта Vditor если не загружен + if (typeof Vditor === 'undefined') { + loadVditor().then(() => initVditorEditor()); + } else { + initVditorEditor(); + } +} +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); + }); +} +function initVditorEditor(initialContent = '') { + vditorInstance = new Vditor('vditor-container', { + 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' + ], + preview: { + delay: 500, + hljs: { + enable: true, + style: 'github', + lineNumber: true + }, + math: { + engine: 'KaTeX', + inlineDigit: true + }, + markdown: { + toc: true, + mark: true, + footnotes: true, + autoSpace: true + } + }, + value: initialContent, // Начальное содержимое + cache: { + enable: false, // Отключаем локальное кэширование + }, + after: () => { + console.log('Vditor инициализирован'); + } + }); +} +``` + +### Русская локализация + +```javascript +// Русская локализация для подсказок +const ruLang = { + hint: { + emoji: 'Эмодзи', + headings: 'Заголовки', + bold: 'Жирный', + italic: 'Курсив', + strike: 'Зачеркнутый', + link: 'Ссылка', + 'list': 'Маркированный список', + 'ordered-list': 'Нумерованный список', + check: 'Список задач', + outdent: 'Уменьшить отступ', + indent: 'Увеличить отступ', + quote: 'Цитата', + line: 'Разделитель', + code: 'Блок кода', + 'inline-code': 'Встроенный код', + 'insert-after': 'Вставить строку после', + 'insert-before': 'Вставить строку перед', + upload: 'Загрузить', + table: 'Таблица', + undo: 'Отменить', + redo: 'Повторить', + 'edit-mode': 'Режим редактирования', + both: 'Редактирование и просмотр', + preview: 'Только просмотр', + fullscreen: 'Полный экран', + outline: 'Оглавление', + 'code-theme': 'Тема кода', + 'content-theme': 'Тема контента', + export: 'Экспорт', + help: 'Помощь' + }, + toolbar: { + more: 'Еще' + } +}; +// Использование в конфигурации +vditorInstance = new Vditor('vditor-container', { + // ... другие настройки + lang: 'ru_RU', + // ... остальные настройки +}); +``` +## Работа с API редактора + +### Основные методы API + +```javascript +// Получение текущего содержимого редактора +function getEditorContent() { + if (!vditorInstance) return ''; + return vditorInstance.getValue(); +} +// Установка содержимого редактора +function setEditorContent(content) { + if (!vditorInstance) return; + vditorInstance.setValue(content); +} +// Получение HTML версии контента +function getEditorHTML() { + if (!vditorInstance) return ''; + return vditorInstance.getHTML(); +} +// Получение чистого Markdown (без HTML тегов) +function getEditorMarkdown() { + if (!vditorInstance) return ''; + return vditorInstance.getValue(); +} +// Переключение режима предпросмотра +function togglePreview() { + if (!vditorInstance) return; + vditorInstance.setPreviewMode(vditorInstance.vditor.preview.element.style.display === 'none'); +} +// Получение выделенного текста +function getSelectedText() { + if (!vditorInstance) return ''; + return vditorInstance.getSelection(); +} +// Вставка текста в текущую позицию курсора +function insertTextAtCursor(text) { + if (!vditorInstance) return; + vditorInstance.insertValue(text); +} +// Очистка редактора +function clearEditor() { + if (!vditorInstance) return; + vditorInstance.setValue(''); +} +// Получение информации о редакторе +function getEditorInfo() { + if (!vditorInstance) return null; + return { + wordCount: vditorInstance.vditor.undo.undo.length, + isPreview: vditorInstance.vditor.preview.element.style.display !== 'none', + isFullscreen: document.fullscreenElement !== null, + theme: vditorInstance.vditor.options.theme + }; +} +``` + +### Обработка событий + +```javascript +// Добавление обработчиков событий +function setupEditorEvents() { + if (!vditorInstance) return; + + // Событие изменения содержимого + vditorInstance.vditor.undo.addListener('stack', (stack) => { + console.log('Контент изменен:', stack); + // Автосохранение или валидация + autoSaveContent(); + }); + + // Событие загрузки файлов + vditorInstance.vditor.upload.element.addEventListener('change', (e) => { + console.log('Файлы выбраны:', e.target.files); + }); + + // Событие переключения режима + vditorInstance.vditor.preview.element.addEventListener('click', () => { + console.log('Режим предпросмотра изменен'); + }); +} +// Автосохранение контента +let autoSaveTimeout = null; +function autoSaveContent() { + if (autoSaveTimeout) { + clearTimeout(autoSaveTimeout); + } + + autoSaveTimeout = setTimeout(() => { + const content = getEditorContent(); + if (content.trim()) { + saveToLocalStorage(content); + console.log('Автосохранение выполнено'); + } + }, 2000); // Сохраняем каждые 2 секунды после последнего изменения +} +function saveToLocalStorage(content) { + const draft = { + content: content, + savedAt: new Date().toISOString(), + wordCount: content.split(/\\\\s+/).length + }; + localStorage.setItem('editor_draft', JSON.stringify(draft)); +} +``` + +## Сохранение и загрузка контента + +### Сохранение через API + +```javascript +// Пример сохранения контента на сервер +async function saveContentToServer() { + if (!vditorInstance) { + throw new Error('Редактор не инициализирован'); + } + + const content = { + markdown: vditorInstance.getValue(), // Markdown версия + html: vditorInstance.getHTML(), // HTML версия + title: document.getElementById('title-input').value, + metadata: { + wordCount: vditorInstance.getValue().split(/\\\\s+/).length, + created: new Date().toISOString(), + updated: new Date().toISOString() + } + }; + + try { + // Отправка на сервер + const response = await fetch('/api/content/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${getAuthToken()}` + }, + body: JSON.stringify(content) + }); + + if (!response.ok) { + throw new Error(`Ошибка сохранения: ${response.status}`); + } + + const result = await response.json(); + console.log('Контент сохранен:', result); + return result; + + } catch (error) { + console.error('Ошибка при сохранении:', error); + throw error; + } +} +// Сохранение с обработкой прогресса +async function saveWithProgress() { + const saveButton = document.getElementById('save-btn'); + const originalText = saveButton.textContent; + + try { + saveButton.disabled = true; + saveButton.textContent = 'Сохранение...'; + + const result = await saveContentToServer(); + + saveButton.textContent = 'Сохранено!'; + setTimeout(() => { + saveButton.textContent = originalText; + saveButton.disabled = false; + }, 2000); + + return result; + + } catch (error) { + saveButton.textContent = 'Ошибка!'; + saveButton.classList.add('error'); + + setTimeout(() => { + saveButton.textContent = originalText; + saveButton.classList.remove('error'); + saveButton.disabled = false; + }, 3000); + + throw error; + } +} +``` + +### Загрузка контента из базы данных + +```javascript +// Загрузка контента по ID +async function loadContentFromServer(contentId) { + try { + const response = await fetch(`/api/content/${contentId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${getAuthToken()}` + } + }); + + if (!response.ok) { + throw new Error(`Ошибка загрузки: ${response.status}`); + } + + const content = await response.json(); + + // Установка контента в редактор + if (vditorInstance) { + vditorInstance.setValue(content.markdown || content.content || ''); + + // Обновление дополнительных полей если есть + if (content.title) { + document.getElementById('title-input').value = content.title; + } + + console.log('Контент загружен:', content); + } + + return content; + + } catch (error) { + console.error('Ошибка при загрузке:', error); + + // Загрузка из локального хранилища при ошибке + const localDraft = localStorage.getItem(`content_draft_${contentId}`); + if (localDraft && vditorInstance) { + try { + const draft = JSON.parse(localDraft); + vditorInstance.setValue(draft.content || ''); + console.log('Загружен локальный черновик'); + } catch (e) { + console.error('Ошибка загрузки черновика:', e); + } + } + + throw error; + } +} + +// Загрузка с индикацией прогресса +async function loadWithProgress(contentId) { + const editorContainer = document.getElementById('vditor-container'); + const loadingIndicator = document.createElement('div'); + + loadingIndicator.className = 'loading-indicator'; + loadingIndicator.innerHTML = ` +
+

Загрузка контента...

+ `; + + editorContainer.appendChild(loadingIndicator); + + try { + const content = await loadContentFromServer(contentId); + loadingIndicator.remove(); + return content; + + } catch (error) { + loadingIndicator.innerHTML = ` +
⚠️
+

Ошибка загрузки. Используется локальная версия.

+ `; + + setTimeout(() => { + loadingIndicator.remove(); + }, 3000); + + throw error; + } +} +``` + +### Работа с черновиками + +```javascript +// Управление черновиками +class DraftManager { + constructor(editorInstance, draftKey = 'editor_draft') { + this.editor = editorInstance; + this.draftKey = draftKey; + this.autoSaveInterval = null; + this.isDirty = false; + } + + // Включение автосохранения + enableAutoSave(interval = 30000) { // 30 секунд + if (this.autoSaveInterval) { + clearInterval(this.autoSaveInterval); + } + + this.autoSaveInterval = setInterval(() => { + if (this.isDirty) { + this.saveDraft(); + this.isDirty = false; + } + }, interval); + + // Отслеживание изменений + if (this.editor) { + this.editor.vditor.undo.addListener('stack', () => { + this.isDirty = true; + }); + } + } + + // Отключение автосохранения + disableAutoSave() { + if (this.autoSaveInterval) { + clearInterval(this.autoSaveInterval); + this.autoSaveInterval = null; + } + } + + // Сохранение черновика + saveDraft(additionalData = {}) { + if (!this.editor) return null; + + const draft = { + content: this.editor.getValue(), + html: this.editor.getHTML(), + savedAt: new Date().toISOString(), + metadata: { + wordCount: this.editor.getValue().split(/\\\\s+/).length, + ...additionalData + } + }; + + localStorage.setItem(this.draftKey, JSON.stringify(draft)); + console.log('Черновик сохранен:', draft); + return draft; + } + + // Загрузка черновика + loadDraft() { + const draftJson = localStorage.getItem(this.draftKey); + if (!draftJson) return null; + + try { + const draft = JSON.parse(draftJson); + + if (this.editor && draft.content) { + this.editor.setValue(draft.content); + console.log('Черновик загружен:', draft); + } + + return draft; + + } catch (error) { + console.error('Ошибка загрузки черновика:', error); + return null; + } + } + + // Очистка черновика + clearDraft() { + localStorage.removeItem(this.draftKey); + this.isDirty = false; + console.log('Черновик очищен'); + } + + // Получение информации о черновике + getDraftInfo() { + const draftJson = localStorage.getItem(this.draftKey); + if (!draftJson) return null; + + try { + const draft = JSON.parse(draftJson); + return { + exists: true, + savedAt: new Date(draft.savedAt), + wordCount: draft.metadata?.wordCount || 0, + size: new Blob([draftJson]).size + }; + } catch (error) { + return null; + } + } +} +// Использование менеджера черновиков +const draftManager = new DraftManager(vditorInstance, 'course_content_draft'); +// Включение автосохранения при инициализации +draftManager.enableAutoSave(); +// Ручное сохранение +document.getElementById('save-draft-btn').addEventListener('click', () => { + const title = document.getElementById('course-title').value; + draftManager.saveDraft({ title: title }); +}); +``` + + +## Работа с HTML и Markdown + +### Конвертация между форматами + +```javascript +// Конвертация Markdown в HTML с поддержкой KaTeX +function convertMarkdownToHTML(markdown) { + if (!markdown) return ''; + + // Используем встроенные возможности Vditor для конвертации + if (vditorInstance && vditorInstance.vditor) { + try { + // Vditor использует Lute для парсинга Markdown + const html = vditorInstance.vditor.lute.Md2HTML(markdown); + return html; + } catch (error) { + console.warn('Ошибка конвертации через Lute:', error); + } + } + + // Fallback: используем marked.js если Vditor не доступен + if (typeof marked !== 'undefined') { + // Настройка marked для поддержки LaTeX + marked.setOptions({ + breaks: true, + gfm: true, + highlight: function(code, lang) { + if (lang && hljs.getLanguage(lang)) { + return hljs.highlight(code, { language: lang }).value; + } + return hljs.highlightAuto(code).value; + } + }); + + // Обработка LaTeX формул + const processedMarkdown = markdown.replace(/\\\\$\\\\$(.*?)\\\\$\\\\$/g, (match, formula) => { + try { + return katex.renderToString(formula, { displayMode: true }); + } catch (e) { + return `
Ошибка формулы: ${formula}
`; + } + }).replace(/\\\\$(.*?)\\\\$/g, (match, formula) => { + try { + return katex.renderToString(formula, { displayMode: false }); + } catch (e) { + return `${formula}`; + } + }); + + return marked(processedMarkdown); + } + + // Самый простой fallback + return markdown + .replace(/^# (.*$)/gim, '

$1

') + .replace(/^## (.*$)/gim, '

$1

') + .replace(/^### (.*$)/gim, '

$1

') + .replace(/\\\\*\\\\*(.*?)\\\\*\\\\*/g, '$1') + .replace(/\\\\*(.*?)\\\\*/g, '$1') + .replace(/`(.*?)`/g, '$1') + .replace(/\\ +/g, '
'); +} +// Конвертация HTML в Markdown (упрощенная) +function convertHTMLToMarkdown(html) { + if (!html) return ''; + + // Простая конвертация основных тегов + let markdown = html + .replace(/

(.*?)<\\\\/h1>/gi, '# $1\\ +\\ +') + .replace(/

(.*?)<\\\\/h2>/gi, '## $1\\ +\\ +') + .replace(/

(.*?)<\\\\/h3>/gi, '### $1\\ +\\ +') + .replace(/(.*?)<\\\\/strong>/gi, '**$1**') + .replace(/(.*?)<\\\\/b>/gi, '**$1**') + .replace(/(.*?)<\\\\/em>/gi, '*$1*') + .replace(/(.*?)<\\\\/i>/gi, '*$1*') + .replace(/(.*?)<\\\\/code>/gi, '\`$1\`') + .replace(/
(.*?)<\\\\/code><\\\\/pre>/gis, '\`\``\\
+$1\\
+```\\
+')
+        .replace(//gi, '\\
+')
+        .replace(/

(.*?)<\\\\/p>/gi, '$1\\ +\\ +') + .replace(/