Initial commit
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Игнорируем node_modules при копировании
|
||||||
|
play-life-web/node_modules
|
||||||
|
play-life-web/dist
|
||||||
|
play-life-web/.git
|
||||||
|
play-life-backend/.git
|
||||||
|
*.md
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
main
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
*.tar
|
||||||
81
BUILD_INSTRUCTIONS.md
Normal file
81
BUILD_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Инструкция по сборке единого Docker образа
|
||||||
|
|
||||||
|
Этот проект содержит единый Dockerfile для сборки frontend и backend в один образ.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- `Dockerfile` - единый Dockerfile для сборки frontend и backend
|
||||||
|
- `nginx-unified.conf` - конфигурация nginx для единого образа
|
||||||
|
- `supervisord.conf` - конфигурация supervisor для запуска nginx и backend
|
||||||
|
- `build-and-save.sh` - скрипт для сборки и сохранения в tar (Linux/Mac)
|
||||||
|
- `build-and-save.ps1` - скрипт для сборки и сохранения в tar (Windows PowerShell)
|
||||||
|
|
||||||
|
## Сборка образа
|
||||||
|
|
||||||
|
### Linux/Mac:
|
||||||
|
```bash
|
||||||
|
./build-and-save.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows PowerShell:
|
||||||
|
```powershell
|
||||||
|
.\build-and-save.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вручную:
|
||||||
|
```bash
|
||||||
|
# Сборка образа
|
||||||
|
docker build -t play-life-unified:latest .
|
||||||
|
|
||||||
|
# Сохранение в tar
|
||||||
|
docker save play-life-unified:latest -o play-life-unified.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
## Загрузка образа на другой машине
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker load -i play-life-unified.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск контейнера
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 80:80 \
|
||||||
|
--env-file .env \
|
||||||
|
--name play-life \
|
||||||
|
play-life-unified:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
Создайте файл `.env` на основе `env.example` с необходимыми переменными:
|
||||||
|
|
||||||
|
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||||
|
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||||
|
- `DB_USER` - пользователь БД
|
||||||
|
- `DB_PASSWORD` - пароль БД
|
||||||
|
- `DB_NAME` - имя БД
|
||||||
|
- `TELEGRAM_BOT_TOKEN` - токен Telegram бота (опционально)
|
||||||
|
- `TELEGRAM_CHAT_ID` - ID чата Telegram (опционально)
|
||||||
|
- `TELEGRAM_WEBHOOK_BASE_URL` - базовый URL для webhook (опционально)
|
||||||
|
- `TODOIST_WEBHOOK_SECRET` - секрет для Todoist webhook (опционально)
|
||||||
|
|
||||||
|
**Важно:** Backend внутри контейнера всегда работает на порту 8080. Nginx проксирует запросы с порта 80 на backend.
|
||||||
|
|
||||||
|
## Проверка работы
|
||||||
|
|
||||||
|
После запуска контейнера:
|
||||||
|
|
||||||
|
- Frontend доступен по адресу: `http://localhost`
|
||||||
|
- API доступен через nginx: `http://localhost/api/...`
|
||||||
|
- Admin панель: `http://localhost/admin.html`
|
||||||
|
|
||||||
|
## Логи
|
||||||
|
|
||||||
|
Логи доступны через supervisor:
|
||||||
|
```bash
|
||||||
|
docker exec play-life cat /var/log/supervisor/backend.out.log
|
||||||
|
docker exec play-life cat /var/log/supervisor/nginx.out.log
|
||||||
|
```
|
||||||
|
|
||||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Multi-stage build для единого образа frontend + backend
|
||||||
|
|
||||||
|
# Stage 1: Build Frontend
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY play-life-web/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
# Копируем исходники (node_modules исключены через .dockerignore)
|
||||||
|
COPY play-life-web/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build Backend
|
||||||
|
FROM golang:1.21-alpine AS backend-builder
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY play-life-backend/go.mod play-life-backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY play-life-backend/ .
|
||||||
|
RUN go mod tidy
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||||
|
|
||||||
|
# Stage 3: Final image
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Устанавливаем необходимые пакеты
|
||||||
|
RUN apk --no-cache add \
|
||||||
|
ca-certificates \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
curl
|
||||||
|
|
||||||
|
# Создаем директории
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем собранный frontend
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Копируем собранный backend
|
||||||
|
COPY --from=backend-builder /app/backend/main /app/backend/main
|
||||||
|
COPY play-life-backend/admin.html /app/backend/admin.html
|
||||||
|
|
||||||
|
# Копируем конфигурацию nginx
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY nginx-unified.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Копируем конфигурацию supervisor для запуска backend
|
||||||
|
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# Создаем директории для логов
|
||||||
|
RUN mkdir -p /var/log/supervisor && \
|
||||||
|
mkdir -p /var/log/nginx && \
|
||||||
|
mkdir -p /var/run
|
||||||
|
|
||||||
|
# Открываем порт 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Запускаем supervisor, который запустит nginx и backend
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|
||||||
297
ENV_SETUP.md
Normal file
297
ENV_SETUP.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# Настройка единого .env файла
|
||||||
|
|
||||||
|
Все приложения проекта используют единый файл `.env` в корне проекта.
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
1. Скопируйте файл `.env.example` в `.env`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Отредактируйте `.env` и укажите свои значения:
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
# или
|
||||||
|
vim .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **ВАЖНО**: Файл `.env` уже добавлен в `.gitignore` и не будет попадать в git.
|
||||||
|
|
||||||
|
## Структура переменных окружения
|
||||||
|
|
||||||
|
### Database Configuration
|
||||||
|
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||||
|
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||||
|
- `DB_USER` - пользователь БД (по умолчанию: playeng)
|
||||||
|
- `DB_PASSWORD` - пароль БД (по умолчанию: playeng)
|
||||||
|
- `DB_NAME` - имя БД (по умолчанию: playeng)
|
||||||
|
|
||||||
|
### Backend Server Configuration
|
||||||
|
- `PORT` - порт бэкенд сервера (по умолчанию: 8080)
|
||||||
|
- В production всегда используется порт 8080 внутри контейнера
|
||||||
|
- Nginx автоматически проксирует запросы к `http://backend:8080`
|
||||||
|
|
||||||
|
### Frontend Configuration (play-life-web)
|
||||||
|
- `VITE_PORT` - порт для dev-сервера Vite (по умолчанию: 3000)
|
||||||
|
- `WEB_PORT` - порт для production контейнера (по умолчанию: 3001)
|
||||||
|
|
||||||
|
**Примечание:** API запросы автоматически проксируются к бэкенду. В development режиме Vite проксирует запросы к `http://localhost:8080`. В production nginx проксирует запросы к бэкенд контейнеру. Не требуется настройка `VITE_API_BASE_URL`.
|
||||||
|
|
||||||
|
### Telegram Bot Configuration (опционально)
|
||||||
|
- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather
|
||||||
|
- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений
|
||||||
|
- `TELEGRAM_WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook. Webhook будет настроен автоматически при старте сервера на `<TELEGRAM_WEBHOOK_BASE_URL>/webhook/telegram`. Если не указан, webhook нужно настраивать вручную.
|
||||||
|
|
||||||
|
**Примеры значений:**
|
||||||
|
- Production с HTTPS: `https://your-domain.com` (порт не нужен для стандартных 80/443)
|
||||||
|
- Локальная разработка с ngrok: `https://abc123.ngrok.io` (порт не нужен)
|
||||||
|
- Прямой доступ на нестандартном порту: `http://your-server:8080` (порт обязателен)
|
||||||
|
|
||||||
|
### Todoist Webhook Configuration (опционально)
|
||||||
|
- `TODOIST_WEBHOOK_SECRET` - секрет для проверки подлинности webhook от Todoist (если задан, все запросы должны содержать заголовок `X-Todoist-Webhook-Secret` с этим значением)
|
||||||
|
|
||||||
|
## Настройка интеграции с Todoist
|
||||||
|
|
||||||
|
Интеграция с Todoist позволяет автоматически обрабатывать закрытые задачи и добавлять их в базу данных play-life.
|
||||||
|
|
||||||
|
### Как это работает
|
||||||
|
|
||||||
|
1. При закрытии задачи в Todoist отправляется webhook на ваш сервер
|
||||||
|
2. Сервер извлекает `title` (content) и `description` из закрытой задачи
|
||||||
|
3. Склеивает их в один текст: `title + "\n" + description`
|
||||||
|
4. Обрабатывает текст через существующую логику `processMessage`, которая:
|
||||||
|
- Парсит ноды в формате `**[Project][+/-][Score]**`
|
||||||
|
- Сохраняет данные в базу данных
|
||||||
|
- Отправляет уведомление в Telegram (если настроено)
|
||||||
|
|
||||||
|
### Настройка webhook в Todoist
|
||||||
|
|
||||||
|
1. Откройте настройки Todoist: https://todoist.com/app/settings/integrations
|
||||||
|
2. Перейдите в раздел "Webhooks" или "Integrations"
|
||||||
|
3. Создайте новый webhook:
|
||||||
|
- **URL**: `http://your-server:8080/webhook/todoist`
|
||||||
|
- Для локальной разработки: `http://localhost:8080/webhook/todoist`
|
||||||
|
- Для production: укажите публичный URL вашего сервера
|
||||||
|
- **Event**: выберите `item:completed` (закрытие задачи)
|
||||||
|
4. Сохраните webhook
|
||||||
|
|
||||||
|
### Безопасность (опционально)
|
||||||
|
|
||||||
|
Для защиты webhook от несанкционированного доступа:
|
||||||
|
|
||||||
|
1. Установите секрет в `.env`:
|
||||||
|
```bash
|
||||||
|
TODOIST_WEBHOOK_SECRET=your_secret_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Настройте Todoist для отправки секрета в заголовке:
|
||||||
|
- В настройках webhook добавьте заголовок: `X-Todoist-Webhook-Secret: your_secret_key_here`
|
||||||
|
- Или используйте встроенные механизмы безопасности Todoist, если они доступны
|
||||||
|
|
||||||
|
**Примечание**: Если `TODOIST_WEBHOOK_SECRET` не задан, проверка секрета не выполняется.
|
||||||
|
|
||||||
|
### Формат задач в Todoist
|
||||||
|
|
||||||
|
Для корректной обработки задачи должны содержать ноды в формате:
|
||||||
|
```
|
||||||
|
**[ProjectName][+/-][Score]**
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
- `**[Work]+5.5**` - добавить 5.5 баллов к проекту "Work"
|
||||||
|
- `**[Health]-2.0**` - вычесть 2.0 баллов из проекта "Health"
|
||||||
|
|
||||||
|
Ноды можно размещать как в `title` (content), так и в `description` задачи. Они будут обработаны при закрытии задачи.
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
Для тестирования интеграции:
|
||||||
|
|
||||||
|
1. Создайте задачу в Todoist с нодами, например:
|
||||||
|
- Title: `Test task`
|
||||||
|
- Description: `**[TestProject]+10.0**`
|
||||||
|
|
||||||
|
2. Закройте задачу в Todoist
|
||||||
|
|
||||||
|
3. Проверьте логи сервера - должно появиться сообщение:
|
||||||
|
```
|
||||||
|
Processing Todoist task: title='Test task', description='**[TestProject]+10.0**'
|
||||||
|
Successfully processed Todoist task, found 1 nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверьте базу данных или веб-интерфейс - данные должны быть добавлены
|
||||||
|
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Локальная разработка
|
||||||
|
|
||||||
|
Все приложения автоматически читают переменные из корневого `.env` файла:
|
||||||
|
|
||||||
|
- **play-life-backend**: читает из `../.env` и `.env` (локальный имеет приоритет)
|
||||||
|
- **play-life-web**: читает из `../.env` и `.env` (локальный имеет приоритет)
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Для запуска всех приложений в одном образе используйте корневой `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Все сервисы автоматически загружают переменные из корневого `.env` файла.
|
||||||
|
|
||||||
|
### Отдельные приложения
|
||||||
|
|
||||||
|
Если нужно запустить отдельные приложения, они также будут использовать корневой `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd play-life-backend
|
||||||
|
docker-compose up
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd play-life-web
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Приоритет переменных окружения
|
||||||
|
|
||||||
|
1. Переменные окружения системы (высший приоритет)
|
||||||
|
2. Локальный `.env` в директории приложения
|
||||||
|
3. Корневой `.env` файл
|
||||||
|
4. Значения по умолчанию в коде
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Изменение порта базы данных
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В .env
|
||||||
|
DB_PORT=5433
|
||||||
|
```
|
||||||
|
|
||||||
|
### Изменение порта бэкенда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В .env
|
||||||
|
PORT=9090
|
||||||
|
```
|
||||||
|
|
||||||
|
### Изменение порта фронтенда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В .env
|
||||||
|
VITE_PORT=4000 # для development
|
||||||
|
WEB_PORT=4001 # для production Docker контейнера
|
||||||
|
```
|
||||||
|
|
||||||
|
После изменения `.env` файла перезапустите соответствующие сервисы.
|
||||||
|
|
||||||
|
## Настройка интеграции с Telegram (webhook для сообщений пользователя)
|
||||||
|
|
||||||
|
Интеграция с Telegram позволяет автоматически обрабатывать сообщения, отправленные пользователем в чат бота, и добавлять их в базу данных play-life.
|
||||||
|
|
||||||
|
### Как это работает
|
||||||
|
|
||||||
|
1. Пользователь отправляет сообщение в чат с ботом в Telegram
|
||||||
|
2. Telegram отправляет webhook на ваш сервер с информацией о сообщении и entities (форматирование)
|
||||||
|
3. Сервер извлекает жирный текст из entities (type === 'bold')
|
||||||
|
4. Парсит жирный текст по формату `project+/-score` (без `**`)
|
||||||
|
5. Обрабатывает текст и сохраняет данные в базу данных
|
||||||
|
6. **НЕ отправляет сообщение обратно в Telegram** (в отличие от других интеграций)
|
||||||
|
|
||||||
|
### Отличия от других интеграций
|
||||||
|
|
||||||
|
- **Формат нод**: `project+/-score` (без `**`), например: `Work+5.5` или `Health-2.0`
|
||||||
|
- **Определение жирного текста**: через entities от Telegram, а не через markdown `**`
|
||||||
|
- **Без обратной отправки**: сообщение не отправляется обратно в Telegram
|
||||||
|
|
||||||
|
### Настройка webhook в Telegram
|
||||||
|
|
||||||
|
#### Автоматическая настройка (рекомендуется)
|
||||||
|
|
||||||
|
1. Создайте бота через [@BotFather](https://t.me/botfather) в Telegram
|
||||||
|
2. Получите токен бота и добавьте его в `.env`:
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
TELEGRAM_WEBHOOK_BASE_URL=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно о портах:**
|
||||||
|
- Если сервер доступен на стандартных портах (HTTP 80 или HTTPS 443), порт можно не указывать
|
||||||
|
- Если сервер работает на нестандартном порту и доступен напрямую, укажите порт: `http://your-server:8080`
|
||||||
|
- Если используется reverse proxy (nginx, etc.), указывайте внешний URL без порта: `https://your-domain.com`
|
||||||
|
|
||||||
|
3. Запустите сервер - webhook будет настроен автоматически при старте!
|
||||||
|
|
||||||
|
Для локальной разработки можно использовать ngrok или аналогичный сервис:
|
||||||
|
```bash
|
||||||
|
# Установите ngrok: https://ngrok.com/
|
||||||
|
ngrok http 8080
|
||||||
|
# Используйте полученный URL в TELEGRAM_WEBHOOK_BASE_URL (без порта)
|
||||||
|
# Например: TELEGRAM_WEBHOOK_BASE_URL=https://abc123.ngrok.io
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверьте логи сервера - должно появиться сообщение:
|
||||||
|
```
|
||||||
|
Telegram webhook configured successfully: https://abc123.ngrok.io/webhook/telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ручная настройка (если не указан TELEGRAM_WEBHOOK_BASE_URL)
|
||||||
|
|
||||||
|
Если вы не указали `TELEGRAM_WEBHOOK_BASE_URL`, webhook нужно настроить вручную:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"url": "http://your-server:8080/webhook/telegram"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверьте, что webhook установлен:
|
||||||
|
```bash
|
||||||
|
curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getWebhookInfo"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Формат сообщений в Telegram
|
||||||
|
|
||||||
|
Для корректной обработки сообщения должны содержать жирный текст в формате:
|
||||||
|
```
|
||||||
|
project+/-score
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
- `Work+5.5` (жирным) - добавить 5.5 баллов к проекту "Work"
|
||||||
|
- `Health-2.0` (жирным) - вычесть 2.0 баллов из проекта "Health"
|
||||||
|
|
||||||
|
**Важно**: Текст должен быть выделен жирным шрифтом в Telegram (через форматирование сообщения, не через `**`).
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
Для тестирования интеграции:
|
||||||
|
|
||||||
|
1. Откройте чат с вашим ботом в Telegram
|
||||||
|
2. Отправьте сообщение с жирным текстом в формате `project+/-score`, например:
|
||||||
|
- Напишите: `Test message`
|
||||||
|
- Выделите `Work+10.0` жирным шрифтом (через форматирование)
|
||||||
|
- Отправьте сообщение
|
||||||
|
|
||||||
|
3. Проверьте логи сервера - должно появиться сообщение:
|
||||||
|
```
|
||||||
|
Processing Telegram message: text='Test message', entities count=1
|
||||||
|
Successfully processed Telegram message, found 1 nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверьте базу данных или веб-интерфейс - данные должны быть добавлены
|
||||||
|
|
||||||
|
### Примечания
|
||||||
|
|
||||||
|
- Webhook должен быть доступен из интернета (для production используйте публичный URL)
|
||||||
|
- Для локальной разработки используйте ngrok или аналогичный сервис для туннелирования
|
||||||
|
- Сообщения обрабатываются только если содержат жирный текст в правильном формате
|
||||||
|
- Сообщения **не отправляются обратно** в Telegram (в отличие от других интеграций)
|
||||||
|
|
||||||
25
build-and-save.ps1
Normal file
25
build-and-save.ps1
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# PowerShell скрипт для сборки единого Docker образа и сохранения в tar
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$IMAGE_NAME = "play-life-unified"
|
||||||
|
$IMAGE_TAG = if ($env:IMAGE_TAG) { $env:IMAGE_TAG } else { "latest" }
|
||||||
|
$TAR_FILE = if ($env:TAR_FILE) { $env:TAR_FILE } else { "play-life-unified.tar" }
|
||||||
|
|
||||||
|
Write-Host "🔨 Сборка единого Docker образа..." -ForegroundColor Cyan
|
||||||
|
docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" .
|
||||||
|
|
||||||
|
Write-Host "💾 Сохранение образа в tar файл..." -ForegroundColor Cyan
|
||||||
|
docker save "${IMAGE_NAME}:${IMAGE_TAG}" -o "${TAR_FILE}"
|
||||||
|
|
||||||
|
$fileSize = (Get-Item "${TAR_FILE}").Length / 1MB
|
||||||
|
Write-Host "✅ Образ успешно сохранен в ${TAR_FILE}" -ForegroundColor Green
|
||||||
|
Write-Host "📦 Размер файла: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Для загрузки образа на другой машине используйте:" -ForegroundColor Yellow
|
||||||
|
Write-Host " docker load -i ${TAR_FILE}" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Для запуска контейнера используйте:" -ForegroundColor Yellow
|
||||||
|
Write-Host " docker run -d -p 80:80 --env-file .env ${IMAGE_NAME}:${IMAGE_TAG}" -ForegroundColor White
|
||||||
|
|
||||||
26
build-and-save.sh
Normal file
26
build-and-save.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для сборки единого Docker образа и сохранения в tar
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE_NAME="play-life-unified"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||||
|
TAR_FILE="${TAR_FILE:-play-life-unified.tar}"
|
||||||
|
|
||||||
|
echo "🔨 Сборка единого Docker образа..."
|
||||||
|
docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" .
|
||||||
|
|
||||||
|
echo "💾 Сохранение образа в tar файл..."
|
||||||
|
docker save "${IMAGE_NAME}:${IMAGE_TAG}" -o "${TAR_FILE}"
|
||||||
|
|
||||||
|
echo "✅ Образ успешно сохранен в ${TAR_FILE}"
|
||||||
|
echo "📦 Размер файла: $(du -h ${TAR_FILE} | cut -f1)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Для загрузки образа на другой машине используйте:"
|
||||||
|
echo " docker load -i ${TAR_FILE}"
|
||||||
|
echo ""
|
||||||
|
echo "Для запуска контейнера используйте:"
|
||||||
|
echo " docker run -d -p 80:80 --env-file .env ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
|
||||||
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Единый docker-compose для всех приложений в одном образе
|
||||||
|
# Использует корневой .env файл
|
||||||
|
|
||||||
|
services:
|
||||||
|
# База данных PostgreSQL
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-playeng}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
# Backend сервер (Go)
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./play-life-backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USER: ${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME: ${DB_NAME:-playeng}
|
||||||
|
PORT: ${PORT:-8080}
|
||||||
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||||
|
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./play-life-backend/migrations:/migrations
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
# Frontend приложение play-life-web
|
||||||
|
play-life-web:
|
||||||
|
build:
|
||||||
|
context: ./play-life-web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: play-life-web
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3001}:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: play-life-network
|
||||||
|
|
||||||
61
env.example
Normal file
61
env.example
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ============================================
|
||||||
|
# Единый файл конфигурации для всех проектов
|
||||||
|
# Backend и Play-Life-Web
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Database Configuration
|
||||||
|
# ============================================
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=playeng
|
||||||
|
DB_PASSWORD=playeng
|
||||||
|
DB_NAME=playeng
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Backend Server Configuration
|
||||||
|
# ============================================
|
||||||
|
# Порт для backend сервера (по умолчанию: 8080)
|
||||||
|
# В production всегда используется порт 8080 внутри контейнера
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Play Life Web Configuration
|
||||||
|
# ============================================
|
||||||
|
# Порт для frontend приложения play-life-web
|
||||||
|
WEB_PORT=3001
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Telegram Bot Configuration (optional)
|
||||||
|
# ============================================
|
||||||
|
# Get token from @BotFather in Telegram: https://t.me/botfather
|
||||||
|
# To get chat ID: send a message to your bot, then visit: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
|
||||||
|
# Look for "chat":{"id":123456789} - that number is your chat ID
|
||||||
|
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
# Base URL для автоматической настройки webhook
|
||||||
|
# Примеры:
|
||||||
|
# - Для production с HTTPS: https://your-domain.com
|
||||||
|
# - Для локальной разработки с ngrok: https://abc123.ngrok.io
|
||||||
|
# - Для прямого доступа на нестандартном порту: http://your-server:8080
|
||||||
|
# Webhook будет настроен автоматически при старте сервера на: <TELEGRAM_WEBHOOK_BASE_URL>/webhook/telegram
|
||||||
|
# Если не указан, webhook нужно настраивать вручную
|
||||||
|
TELEGRAM_WEBHOOK_BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Todoist Webhook Configuration (optional)
|
||||||
|
# ============================================
|
||||||
|
# Секрет для проверки подлинности webhook от Todoist
|
||||||
|
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
|
||||||
|
# Оставьте пустым, если не хотите использовать проверку секрета
|
||||||
|
TODOIST_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Scheduler Configuration
|
||||||
|
# ============================================
|
||||||
|
# Часовой пояс для планировщика задач (например: Europe/Moscow, America/New_York, UTC)
|
||||||
|
# Используется для автоматической фиксации целей на неделю каждый понедельник в 6:00
|
||||||
|
# По умолчанию: UTC
|
||||||
|
# Список доступных часовых поясов: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||||
|
TIMEZONE=UTC
|
||||||
|
|
||||||
76
nginx-unified.conf
Normal file
76
nginx-unified.conf
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||||
|
|
||||||
|
# Proxy API requests to backend (localhost внутри контейнера)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy webhook endpoints to backend
|
||||||
|
location /webhook/ {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy daily-report endpoints to backend
|
||||||
|
location /daily-report/ {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy other API endpoints to backend
|
||||||
|
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|message/post|weekly_goals/setup|admin|admin\.html)$ {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle React Router (SPA)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# Include server configurations
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
|
|
||||||
34
play-life-backend/.gitignore
vendored
Normal file
34
play-life-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Environment variables with secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Go build artifacts
|
||||||
|
main
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
57
play-life-backend/Dockerfile
Normal file
57
play-life-backend/Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Multi-stage build для единого образа frontend + backend
|
||||||
|
|
||||||
|
# Stage 1: Build Frontend
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY play-life-web/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
# Копируем исходники (node_modules исключены через .dockerignore)
|
||||||
|
COPY play-life-web/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build Backend
|
||||||
|
FROM golang:1.21-alpine AS backend-builder
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY play-life-backend/go.mod play-life-backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY play-life-backend/ .
|
||||||
|
RUN go mod tidy
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||||
|
|
||||||
|
# Stage 3: Final image
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Устанавливаем необходимые пакеты
|
||||||
|
RUN apk --no-cache add \
|
||||||
|
ca-certificates \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
curl
|
||||||
|
|
||||||
|
# Создаем директории
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем собранный frontend
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Копируем собранный backend
|
||||||
|
COPY --from=backend-builder /app/backend/main /app/backend/main
|
||||||
|
COPY play-life-backend/admin.html /app/backend/admin.html
|
||||||
|
|
||||||
|
# Копируем конфигурацию nginx
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY nginx-unified.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Копируем конфигурацию supervisor для запуска backend
|
||||||
|
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# Создаем директории для логов
|
||||||
|
RUN mkdir -p /var/log/supervisor && \
|
||||||
|
mkdir -p /var/log/nginx && \
|
||||||
|
mkdir -p /var/run
|
||||||
|
|
||||||
|
# Открываем порт 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Запускаем supervisor, который запустит nginx и backend
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
76
play-life-backend/ENV_SETUP.md
Normal file
76
play-life-backend/ENV_SETUP.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Настройка переменных окружения
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
1. Скопируйте файл `env.example` в `.env`:
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Откройте `.env` и заполните реальные значения:
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
# или
|
||||||
|
vim .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **ВАЖНО**: Файл `.env` уже добавлен в `.gitignore` и не будет попадать в git.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
### Обязательные (для работы приложения)
|
||||||
|
|
||||||
|
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||||
|
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||||
|
- `DB_USER` - пользователь БД (по умолчанию: playeng)
|
||||||
|
- `DB_PASSWORD` - пароль БД (по умолчанию: playeng)
|
||||||
|
- `DB_NAME` - имя БД (по умолчанию: playeng)
|
||||||
|
- `PORT` - порт сервера (по умолчанию: 8080)
|
||||||
|
|
||||||
|
### Опциональные (для Telegram интеграции)
|
||||||
|
|
||||||
|
- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather
|
||||||
|
- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений
|
||||||
|
|
||||||
|
## Использование в коде
|
||||||
|
|
||||||
|
Приложение автоматически читает переменные окружения через `os.Getenv()`.
|
||||||
|
|
||||||
|
Для загрузки `.env` файла в локальной разработке можно использовать:
|
||||||
|
|
||||||
|
### Вариант 1: Установить переменные вручную
|
||||||
|
```bash
|
||||||
|
export DB_PASSWORD=your_password
|
||||||
|
export TELEGRAM_BOT_TOKEN=your_token
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Использовать библиотеку godotenv (рекомендуется)
|
||||||
|
|
||||||
|
1. Установить библиотеку:
|
||||||
|
```bash
|
||||||
|
go get github.com/joho/godotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Добавить в начало `main()`:
|
||||||
|
```go
|
||||||
|
import "github.com/joho/godotenv"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Загрузить .env файл
|
||||||
|
godotenv.Load()
|
||||||
|
// ... остальной код
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 3: Использовать docker-compose
|
||||||
|
|
||||||
|
В `docker-compose.yml` уже настроена передача переменных окружения из `.env` файла.
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- ✅ Файл `.env` добавлен в `.gitignore`
|
||||||
|
- ✅ Файл `env.example` содержит только шаблоны без реальных значений
|
||||||
|
- ✅ Никогда не коммитьте `.env` в git
|
||||||
|
- ✅ Используйте разные токены для dev/prod окружений
|
||||||
|
|
||||||
358
play-life-backend/admin.html
Normal file
358
play-life-backend/admin.html
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Play Life Backend - Admin Panel</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 2.5em;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.3em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 150px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: monospace;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card button {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result h3 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result pre {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #f8f8f2;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.success {
|
||||||
|
border-left-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.error {
|
||||||
|
border-left-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.loading {
|
||||||
|
border-left-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.loading {
|
||||||
|
background: #ff9800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🎯 Play Life Backend - Admin Panel</h1>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Message Post Card -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
📨 Message Post
|
||||||
|
<span class="status" id="messageStatus" style="display: none;"></span>
|
||||||
|
</h2>
|
||||||
|
<textarea id="messageText" placeholder="Введите сообщение с паттернами **Project+10.5** или **Project-5.0**...
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
Сегодня работал над проектами:
|
||||||
|
**Frontend+15.5**
|
||||||
|
**Backend+8.0**
|
||||||
|
**Design-2.5**"></textarea>
|
||||||
|
<button onclick="sendMessage()">Отправить сообщение</button>
|
||||||
|
<div id="messageResult"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily Report Trigger Card -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
📈 Daily Report Trigger
|
||||||
|
<span class="status" id="dailyReportStatus" style="display: none;"></span>
|
||||||
|
</h2>
|
||||||
|
<p style="margin-bottom: 15px; color: #666;">
|
||||||
|
Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 11:59).
|
||||||
|
</p>
|
||||||
|
<button onclick="triggerDailyReport()">Отправить отчёт</button>
|
||||||
|
<div id="dailyReportResult"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weekly Goals Setup Card -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
🎯 Weekly Goals Setup
|
||||||
|
<span class="status" id="goalsStatus" style="display: none;"></span>
|
||||||
|
</h2>
|
||||||
|
<p style="margin-bottom: 15px; color: #666;">
|
||||||
|
Нажмите кнопку для установки целей на текущую неделю на основе медианы за последние 3 месяца (с отправкой в чат). Обычно срабатывает автоматически в начале недели.
|
||||||
|
</p>
|
||||||
|
<button onclick="setupWeeklyGoals()">Обновить цели</button>
|
||||||
|
<div id="goalsResult"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getApiUrl() {
|
||||||
|
// Автоматически определяем URL текущего хоста
|
||||||
|
// Админка обслуживается тем же бекендом, поэтому используем текущий origin
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(elementId, status, text) {
|
||||||
|
const statusEl = document.getElementById(elementId);
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = `status ${status}`;
|
||||||
|
statusEl.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStatus(elementId) {
|
||||||
|
document.getElementById(elementId).style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(elementId, data, isError = false, isLoading = false) {
|
||||||
|
const resultEl = document.getElementById(elementId);
|
||||||
|
resultEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
resultEl.innerHTML = '<div class="result loading"><h3>⏳ Загрузка...</h3></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `result ${isError ? 'error' : 'success'}`;
|
||||||
|
|
||||||
|
const h3 = document.createElement('h3');
|
||||||
|
h3.textContent = isError ? '❌ Ошибка' : '✅ Успешно';
|
||||||
|
div.appendChild(h3);
|
||||||
|
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.textContent = JSON.stringify(data, null, 2);
|
||||||
|
div.appendChild(pre);
|
||||||
|
|
||||||
|
resultEl.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const text = document.getElementById('messageText').value.trim();
|
||||||
|
if (!text) {
|
||||||
|
alert('Пожалуйста, введите сообщение');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus('messageStatus', 'loading', 'Отправка...');
|
||||||
|
showResult('messageResult', null, false, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiUrl()}/message/post`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
body: {
|
||||||
|
text: text
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showStatus('messageStatus', 'success', 'Успешно');
|
||||||
|
showResult('messageResult', data, false);
|
||||||
|
} else {
|
||||||
|
showStatus('messageStatus', 'error', 'Ошибка');
|
||||||
|
showResult('messageResult', data, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('messageStatus', 'error', 'Ошибка');
|
||||||
|
showResult('messageResult', { error: error.message }, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupWeeklyGoals() {
|
||||||
|
showStatus('goalsStatus', 'loading', 'Обновление...');
|
||||||
|
showResult('goalsResult', null, false, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiUrl()}/weekly_goals/setup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showStatus('goalsStatus', 'success', 'Успешно');
|
||||||
|
showResult('goalsResult', data, false);
|
||||||
|
} else {
|
||||||
|
showStatus('goalsStatus', 'error', 'Ошибка');
|
||||||
|
showResult('goalsResult', data, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('goalsStatus', 'error', 'Ошибка');
|
||||||
|
showResult('goalsResult', { error: error.message }, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerDailyReport() {
|
||||||
|
showStatus('dailyReportStatus', 'loading', 'Отправка...');
|
||||||
|
showResult('dailyReportResult', null, false, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiUrl()}/daily-report/trigger`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showStatus('dailyReportStatus', 'success', 'Успешно');
|
||||||
|
showResult('dailyReportResult', data, false);
|
||||||
|
} else {
|
||||||
|
showStatus('dailyReportStatus', 'error', 'Ошибка');
|
||||||
|
showResult('dailyReportResult', data, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('dailyReportStatus', 'error', 'Ошибка');
|
||||||
|
showResult('dailyReportResult', { error: error.message }, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разрешаем отправку формы по Enter (Ctrl+Enter для textarea)
|
||||||
|
document.getElementById('messageText').addEventListener('keydown', function(e) {
|
||||||
|
if (e.ctrlKey && e.key === 'Enter') {
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
43
play-life-backend/docker-compose.yml
Normal file
43
play-life-backend/docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-playeng}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
- .env # Локальный .env имеет приоритет
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USER: ${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME: ${DB_NAME:-playeng}
|
||||||
|
PORT: ${PORT:-8080}
|
||||||
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||||
|
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./migrations:/migrations
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
- .env # Локальный .env имеет приоритет
|
||||||
|
|
||||||
17
play-life-backend/env.example
Normal file
17
play-life-backend/env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=playeng
|
||||||
|
DB_PASSWORD=playeng
|
||||||
|
DB_NAME=playeng
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Telegram Bot Configuration (optional - for direct Telegram integration)
|
||||||
|
# Get token from @BotFather in Telegram: https://t.me/botfather
|
||||||
|
# To get chat ID: send a message to your bot, then visit: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
|
||||||
|
# Look for "chat":{"id":123456789} - that number is your chat ID
|
||||||
|
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
|
||||||
14
play-life-backend/go.mod
Normal file
14
play-life-backend/go.mod
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module play-eng-backend
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
)
|
||||||
8
play-life-backend/go.sum
Normal file
8
play-life-backend/go.sum
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
3672
play-life-backend/main.go
Normal file
3672
play-life-backend/main.go
Normal file
File diff suppressed because it is too large
Load Diff
105
play-life-backend/migrations/001_create_schema.sql
Normal file
105
play-life-backend/migrations/001_create_schema.sql
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
-- Migration: Create database schema for play-life project
|
||||||
|
-- This script creates all tables and materialized views needed for the project
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: projects
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
priority SMALLINT,
|
||||||
|
CONSTRAINT unique_project_name UNIQUE (name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: entries
|
||||||
|
-- ============================================
|
||||||
|
-- This table stores entries with creation dates
|
||||||
|
-- Used in weekly_report_mv for grouping by week
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: nodes
|
||||||
|
-- ============================================
|
||||||
|
-- This table stores nodes linked to projects and entries
|
||||||
|
-- Contains score information used in weekly reports
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
score NUMERIC(8,4)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index on project_id for better join performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_project_id ON nodes(project_id);
|
||||||
|
-- Create index on entry_id for better join performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_entry_id ON nodes(entry_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: weekly_goals
|
||||||
|
-- ============================================
|
||||||
|
-- This table stores weekly goals for projects
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_goals (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
goal_year INTEGER NOT NULL,
|
||||||
|
goal_week INTEGER NOT NULL,
|
||||||
|
min_goal_score NUMERIC(10,4) NOT NULL DEFAULT 0,
|
||||||
|
max_goal_score NUMERIC(10,4),
|
||||||
|
actual_score NUMERIC(10,4) DEFAULT 0,
|
||||||
|
priority SMALLINT,
|
||||||
|
CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index on project_id for better join performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_goals_project_id ON weekly_goals(project_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Materialized View: weekly_report_mv
|
||||||
|
-- ============================================
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS weekly_report_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
agg.report_year,
|
||||||
|
agg.report_week,
|
||||||
|
-- Используем COALESCE для установки total_score в 0.0000, если нет данных (NULL)
|
||||||
|
COALESCE(agg.total_score, 0.0000) AS total_score
|
||||||
|
FROM
|
||||||
|
projects p
|
||||||
|
LEFT JOIN
|
||||||
|
(
|
||||||
|
-- 1. Предварительная агрегация: суммируем score по неделям
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
EXTRACT(YEAR FROM e.created_date)::INTEGER AS report_year,
|
||||||
|
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||||
|
SUM(n.score) AS total_score
|
||||||
|
FROM
|
||||||
|
nodes n
|
||||||
|
JOIN
|
||||||
|
entries e ON n.entry_id = e.id
|
||||||
|
GROUP BY
|
||||||
|
1, 2, 3
|
||||||
|
) agg
|
||||||
|
-- 2. Присоединяем агрегированные данные ко ВСЕМ проектам
|
||||||
|
ON p.id = agg.project_id
|
||||||
|
ORDER BY
|
||||||
|
p.id, agg.report_year, agg.report_week;
|
||||||
|
|
||||||
|
-- Create index on materialized view for better query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||||||
|
ON weekly_report_mv(project_id, report_year, report_week);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE projects IS 'Projects table storing project information with priority';
|
||||||
|
COMMENT ON TABLE entries IS 'Entries table storing entry creation timestamps';
|
||||||
|
COMMENT ON TABLE nodes IS 'Nodes table linking projects, entries and storing scores';
|
||||||
|
COMMENT ON TABLE weekly_goals IS 'Weekly goals for projects';
|
||||||
|
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project';
|
||||||
|
|
||||||
53
play-life-backend/migrations/002_add_dictionaries.sql
Normal file
53
play-life-backend/migrations/002_add_dictionaries.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- Migration: Add dictionaries table and dictionary_id to words
|
||||||
|
-- This script creates the dictionaries table and adds dictionary_id field to words table
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: dictionaries
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS dictionaries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default dictionary "Все слова" with id = 0
|
||||||
|
-- Note: PostgreSQL SERIAL starts from 1, so we need to use a workaround
|
||||||
|
-- First, set the sequence to allow inserting 0, then insert, then reset sequence
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Set sequence to -1 so next value will be 0
|
||||||
|
PERFORM setval('dictionaries_id_seq', -1, false);
|
||||||
|
|
||||||
|
-- Insert the default dictionary with id = 0
|
||||||
|
INSERT INTO dictionaries (id, name)
|
||||||
|
VALUES (0, 'Все слова')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Set the sequence to start from 1 (so next auto-increment will be 1)
|
||||||
|
PERFORM setval('dictionaries_id_seq', 1, false);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Alter words table: Add dictionary_id column
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE words
|
||||||
|
ADD COLUMN IF NOT EXISTS dictionary_id INTEGER DEFAULT 0 REFERENCES dictionaries(id);
|
||||||
|
|
||||||
|
-- Update all existing words to have dictionary_id = 0
|
||||||
|
UPDATE words
|
||||||
|
SET dictionary_id = 0
|
||||||
|
WHERE dictionary_id IS NULL;
|
||||||
|
|
||||||
|
-- Make dictionary_id NOT NULL after setting default values
|
||||||
|
ALTER TABLE words
|
||||||
|
ALTER COLUMN dictionary_id SET NOT NULL,
|
||||||
|
ALTER COLUMN dictionary_id SET DEFAULT 0;
|
||||||
|
|
||||||
|
-- Create index on dictionary_id for better join performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_words_dictionary_id ON words(dictionary_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE dictionaries IS 'Dictionaries table storing dictionary information';
|
||||||
|
COMMENT ON COLUMN words.dictionary_id IS 'Reference to dictionary. Default is 0 (Все слова)';
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Migration: Remove UNIQUE constraint from words.name
|
||||||
|
-- This script removes the unique constraint on the name column in the words table
|
||||||
|
|
||||||
|
-- Drop the unique constraint on words.name if it exists
|
||||||
|
ALTER TABLE words
|
||||||
|
DROP CONSTRAINT IF EXISTS words_name_key;
|
||||||
|
|
||||||
|
-- Also try to drop constraint if it was created with different name
|
||||||
|
ALTER TABLE words
|
||||||
|
DROP CONSTRAINT IF EXISTS words_name_unique;
|
||||||
|
|
||||||
21
play-life-backend/migrations/004_add_config_dictionaries.sql
Normal file
21
play-life-backend/migrations/004_add_config_dictionaries.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- Migration: Add config_dictionaries table (many-to-many relationship)
|
||||||
|
-- This script creates the config_dictionaries table linking configs and dictionaries
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: config_dictionaries
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS config_dictionaries (
|
||||||
|
config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
||||||
|
dictionary_id INTEGER NOT NULL REFERENCES dictionaries(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (config_id, dictionary_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_config_id ON config_dictionaries(config_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE config_dictionaries IS 'Many-to-many relationship table linking configs and dictionaries. If no dictionaries are selected for a config, all dictionaries will be used.';
|
||||||
|
|
||||||
29
play-life-backend/migrations/005_fix_weekly_report_mv.sql
Normal file
29
play-life-backend/migrations/005_fix_weekly_report_mv.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- Migration: Fix weekly_report_mv to use ISOYEAR instead of YEAR
|
||||||
|
-- This fixes incorrect week calculations at year boundaries
|
||||||
|
-- Date: 2024
|
||||||
|
|
||||||
|
-- Drop existing materialized view
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
|
||||||
|
|
||||||
|
-- Recreate materialized view with ISOYEAR
|
||||||
|
CREATE MATERIALIZED VIEW weekly_report_mv AS
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
-- 🔑 ГЛАВНОЕ ИСПРАВЛЕНИЕ: Используем ISOYEAR
|
||||||
|
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
|
||||||
|
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||||
|
SUM(n.score) AS total_score
|
||||||
|
FROM
|
||||||
|
nodes n
|
||||||
|
JOIN
|
||||||
|
entries e ON n.entry_id = e.id
|
||||||
|
GROUP BY
|
||||||
|
1, 2, 3
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
-- Recreate index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||||||
|
ON weekly_report_mv(project_id, report_year, report_week);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations';
|
||||||
|
|
||||||
81
play-life-backend/migrations/README.md
Normal file
81
play-life-backend/migrations/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
Этот каталог содержит SQL миграции для создания структуры базы данных проекта play-life.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Создание базы данных с нуля
|
||||||
|
|
||||||
|
Выполните миграцию для создания всех таблиц и представлений:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U your_user -d your_database -f 001_create_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через docker-compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура базы данных
|
||||||
|
|
||||||
|
### Таблицы
|
||||||
|
|
||||||
|
1. **projects** - Проекты
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `name` (VARCHAR(255) NOT NULL, UNIQUE)
|
||||||
|
- `priority` (SMALLINT)
|
||||||
|
|
||||||
|
2. **entries** - Записи с текстом и датами создания
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `text` (TEXT NOT NULL)
|
||||||
|
- `created_date` (TIMESTAMP WITH TIME ZONE NOT NULL, DEFAULT CURRENT_TIMESTAMP)
|
||||||
|
|
||||||
|
3. **nodes** - Узлы, связывающие проекты и записи
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `project_id` (INTEGER NOT NULL, FK -> projects.id ON DELETE CASCADE)
|
||||||
|
- `entry_id` (INTEGER NOT NULL, FK -> entries.id ON DELETE CASCADE)
|
||||||
|
- `score` (NUMERIC(8,4))
|
||||||
|
|
||||||
|
4. **weekly_goals** - Недельные цели для проектов
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `project_id` (INTEGER NOT NULL, FK -> projects.id ON DELETE CASCADE)
|
||||||
|
- `goal_year` (INTEGER NOT NULL)
|
||||||
|
- `goal_week` (INTEGER NOT NULL)
|
||||||
|
- `min_goal_score` (NUMERIC(10,4) NOT NULL, DEFAULT 0)
|
||||||
|
- `max_goal_score` (NUMERIC(10,4))
|
||||||
|
- `actual_score` (NUMERIC(10,4), DEFAULT 0)
|
||||||
|
- `priority` (SMALLINT)
|
||||||
|
- UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)`
|
||||||
|
|
||||||
|
### Materialized View
|
||||||
|
|
||||||
|
- **weekly_report_mv** - Агрегированные данные по неделям для каждого проекта
|
||||||
|
- `project_id` (INTEGER)
|
||||||
|
- `report_year` (INTEGER)
|
||||||
|
- `report_week` (INTEGER)
|
||||||
|
- `total_score` (NUMERIC)
|
||||||
|
|
||||||
|
## Обновление Materialized View
|
||||||
|
|
||||||
|
После изменения данных в таблицах `nodes` или `entries`, необходимо обновить materialized view:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
REFRESH MATERIALIZED VIEW weekly_report_mv;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Связи между таблицами
|
||||||
|
|
||||||
|
- `nodes.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||||
|
- `nodes.entry_id` → `entries.id` (ON DELETE CASCADE)
|
||||||
|
- `weekly_goals.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||||
|
|
||||||
|
## Индексы
|
||||||
|
|
||||||
|
Созданы индексы для оптимизации запросов:
|
||||||
|
- `idx_nodes_project_id` на `nodes(project_id)`
|
||||||
|
- `idx_nodes_entry_id` на `nodes(entry_id)`
|
||||||
|
- `idx_weekly_goals_project_id` на `weekly_goals(project_id)`
|
||||||
|
- `idx_weekly_report_mv_project_year_week` на `weekly_report_mv(project_id, report_year, report_week)`
|
||||||
|
|
||||||
20
play-life-backend/start_backend.sh
Normal file
20
play-life-backend/start_backend.sh
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Настройки подключения к БД (можно изменить через переменные окружения)
|
||||||
|
export DB_HOST=${DB_HOST:-localhost}
|
||||||
|
export DB_PORT=${DB_PORT:-5432}
|
||||||
|
export DB_USER=${DB_USER:-postgres}
|
||||||
|
export DB_PASSWORD=${DB_PASSWORD:-postgres}
|
||||||
|
export DB_NAME=${DB_NAME:-playlife}
|
||||||
|
export PORT=${PORT:-8080}
|
||||||
|
|
||||||
|
echo "Starting backend server..."
|
||||||
|
echo "DB_HOST: $DB_HOST"
|
||||||
|
echo "DB_PORT: $DB_PORT"
|
||||||
|
echo "DB_USER: $DB_USER"
|
||||||
|
echo "DB_NAME: $DB_NAME"
|
||||||
|
echo "PORT: $PORT"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
go run main.go
|
||||||
12
play-life-web/.dockerignore
Normal file
12
play-life-web/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
30
play-life-web/.gitignore
vendored
Normal file
30
play-life-web/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
30
play-life-web/Dockerfile
Normal file
30
play-life-web/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files from builder
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
105
play-life-web/README.md
Normal file
105
play-life-web/README.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# PlayLifeWeb
|
||||||
|
|
||||||
|
Веб-приложение для отображения статистики проектов.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Текущая неделя**: Отображение статистики на текущий момент с ProgressBar для каждого проекта
|
||||||
|
- **Полная статистика**: График нарастающей статистики по всем проектам
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
- React 18
|
||||||
|
- Vite
|
||||||
|
- Chart.js (react-chartjs-2)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Docker
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### Локальная разработка
|
||||||
|
|
||||||
|
1. Установите зависимости:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Запустите dev-сервер:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу `http://localhost:3000`
|
||||||
|
|
||||||
|
### Сборка для production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск через Docker
|
||||||
|
|
||||||
|
1. Создайте файл `.env` в корне проекта (можно скопировать из `env.example`):
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Соберите образ:
|
||||||
|
```bash
|
||||||
|
docker-compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Запустите контейнер:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу `http://localhost:3000`
|
||||||
|
|
||||||
|
**Примечание:** API запросы автоматически проксируются к бэкенду через nginx. Не требуется настройка URL API.
|
||||||
|
|
||||||
|
### Остановка Docker контейнера
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
play-life-web/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── CurrentWeek.jsx # Компонент текущей недели
|
||||||
|
│ │ ├── FullStatistics.jsx # Компонент полной статистики
|
||||||
|
│ │ └── ProjectProgressBar.jsx # Компонент ProgressBar
|
||||||
|
│ ├── App.jsx # Главный компонент приложения
|
||||||
|
│ ├── main.jsx # Точка входа
|
||||||
|
│ └── index.css # Глобальные стили
|
||||||
|
├── Dockerfile # Docker образ
|
||||||
|
├── docker-compose.yml # Docker Compose конфигурация
|
||||||
|
├── nginx.conf # Nginx конфигурация
|
||||||
|
└── package.json # Зависимости проекта
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
Приложение использует относительные пути для API запросов. Проксирование настроено автоматически:
|
||||||
|
|
||||||
|
- **Development**: Vite dev server проксирует запросы к `http://localhost:8080`
|
||||||
|
- **Production**: Nginx проксирует запросы к бэкенд контейнеру
|
||||||
|
|
||||||
|
Endpoints, которые используются:
|
||||||
|
- `/playlife-feed` - данные текущей недели
|
||||||
|
- `/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b` - полная статистика
|
||||||
|
- `/projects` - список проектов
|
||||||
|
- `/project/priority` - обновление приоритетов проектов
|
||||||
|
- `/api/*` - остальные API endpoints (слова, конфигурации, тесты)
|
||||||
|
|
||||||
|
## Особенности реализации
|
||||||
|
|
||||||
|
- ProgressBar отображает текущее значение (`total_score`) и выделяет диапазон целей (`min_goal_score` - `max_goal_score`)
|
||||||
|
- График полной статистики показывает нарастающую сумму баллов по неделям
|
||||||
|
- Все проекты отображаются на одном графике с разными цветами
|
||||||
|
- Адаптивный дизайн для различных размеров экранов
|
||||||
|
|
||||||
42
play-life-web/build-and-save.sh
Normal file
42
play-life-web/build-and-save.sh
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Добавляем Docker в PATH
|
||||||
|
export PATH="/Applications/Docker.app/Contents/Resources/bin:$PATH"
|
||||||
|
|
||||||
|
echo "Ожидание запуска Docker daemon..."
|
||||||
|
# Ждем до 60 секунд, пока Docker daemon запустится
|
||||||
|
for i in {1..60}; do
|
||||||
|
if docker ps >/dev/null 2>&1; then
|
||||||
|
echo "Docker daemon запущен!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ $i -eq 60 ]; then
|
||||||
|
echo "Ошибка: Docker daemon не запустился. Пожалуйста, запустите Docker Desktop вручную."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Сборка Docker образа..."
|
||||||
|
docker build \
|
||||||
|
-t play-life-web:latest .
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Образ успешно собран!"
|
||||||
|
echo "Сохранение образа в play-life-web.tar..."
|
||||||
|
docker save play-life-web:latest -o play-life-web.tar
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Образ успешно сохранен в play-life-web.tar"
|
||||||
|
ls -lh play-life-web.tar
|
||||||
|
else
|
||||||
|
echo "Ошибка при сохранении образа"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Ошибка при сборке образа"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
29
play-life-web/build-docker-image.sh
Normal file
29
play-life-web/build-docker-image.sh
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для сборки Docker образа и сохранения в .tar файл
|
||||||
|
|
||||||
|
IMAGE_NAME="play-life-web"
|
||||||
|
IMAGE_TAG="latest"
|
||||||
|
TAR_FILE="play-life-web.tar"
|
||||||
|
|
||||||
|
echo "Сборка Docker образа..."
|
||||||
|
docker build \
|
||||||
|
-t "$IMAGE_NAME:$IMAGE_TAG" .
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Образ успешно собран!"
|
||||||
|
echo "Сохранение образа в $TAR_FILE..."
|
||||||
|
docker save "$IMAGE_NAME:$IMAGE_TAG" -o "$TAR_FILE"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Образ успешно сохранен в $TAR_FILE"
|
||||||
|
ls -lh "$TAR_FILE"
|
||||||
|
else
|
||||||
|
echo "Ошибка при сохранении образа"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Ошибка при сборке образа"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
21
play-life-web/docker-compose.yml
Normal file
21
play-life-web/docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
play-life-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: play-life-web
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3000}:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- play-life-network
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
- .env # Локальный .env имеет приоритет
|
||||||
|
|
||||||
|
networks:
|
||||||
|
play-life-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
6
play-life-web/env.example
Normal file
6
play-life-web/env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# API URLs для PlayLifeWeb
|
||||||
|
# Скопируйте этот файл в .env и укажите свои значения
|
||||||
|
|
||||||
|
# Play Life Web Port (по умолчанию: 3000)
|
||||||
|
WEB_PORT=3000
|
||||||
|
|
||||||
14
play-life-web/index.html
Normal file
14
play-life-web/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<title>PlayLife - Статистика</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
50
play-life-web/nginx.conf
Normal file
50
play-life-web/nginx.conf
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy other API endpoints to backend
|
||||||
|
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|message/post|webhook/|weekly_goals/setup|admin|admin\.html)$ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle React Router (SPA)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
2706
play-life-web/package-lock.json
generated
Normal file
2706
play-life-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
play-life-web/package.json
Normal file
28
play-life-web/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "play-life-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
play-life-web/postcss.config.js
Normal file
7
play-life-web/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
531
play-life-web/src/App.jsx
Normal file
531
play-life-web/src/App.jsx
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import CurrentWeek from './components/CurrentWeek'
|
||||||
|
import FullStatistics from './components/FullStatistics'
|
||||||
|
import ProjectPriorityManager from './components/ProjectPriorityManager'
|
||||||
|
import WordList from './components/WordList'
|
||||||
|
import AddWords from './components/AddWords'
|
||||||
|
import TestConfigSelection from './components/TestConfigSelection'
|
||||||
|
import AddConfig from './components/AddConfig'
|
||||||
|
import TestWords from './components/TestWords'
|
||||||
|
|
||||||
|
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||||
|
const CURRENT_WEEK_API_URL = '/playlife-feed'
|
||||||
|
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState('current')
|
||||||
|
const [selectedProject, setSelectedProject] = useState(null)
|
||||||
|
const [loadedTabs, setLoadedTabs] = useState({
|
||||||
|
current: false,
|
||||||
|
priorities: false,
|
||||||
|
full: false,
|
||||||
|
words: false,
|
||||||
|
'add-words': false,
|
||||||
|
'test-config': false,
|
||||||
|
'add-config': false,
|
||||||
|
test: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||||||
|
const [tabsInitialized, setTabsInitialized] = useState({
|
||||||
|
current: false,
|
||||||
|
priorities: false,
|
||||||
|
full: false,
|
||||||
|
words: false,
|
||||||
|
'add-words': false,
|
||||||
|
'test-config': false,
|
||||||
|
'add-config': false,
|
||||||
|
test: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Параметры для навигации между вкладками
|
||||||
|
const [tabParams, setTabParams] = useState({})
|
||||||
|
|
||||||
|
// Кеширование данных
|
||||||
|
const [currentWeekData, setCurrentWeekData] = useState(null)
|
||||||
|
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
||||||
|
|
||||||
|
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
|
||||||
|
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
|
||||||
|
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
|
||||||
|
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
|
||||||
|
|
||||||
|
// Состояния фоновой загрузки (не показываются визуально)
|
||||||
|
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
|
||||||
|
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
|
||||||
|
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
|
||||||
|
|
||||||
|
// Ошибки
|
||||||
|
const [currentWeekError, setCurrentWeekError] = useState(null)
|
||||||
|
const [fullStatisticsError, setFullStatisticsError] = useState(null)
|
||||||
|
const [prioritiesError, setPrioritiesError] = useState(null)
|
||||||
|
|
||||||
|
// Состояние для кнопки Refresh (если она есть)
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
|
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
||||||
|
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
|
||||||
|
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
|
// Восстанавливаем последний выбранный таб после перезагрузки
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialized) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedTab = window.localStorage?.getItem('activeTab')
|
||||||
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test']
|
||||||
|
if (savedTab && validTabs.includes(savedTab)) {
|
||||||
|
setActiveTab(savedTab)
|
||||||
|
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
|
||||||
|
setIsInitialized(true)
|
||||||
|
} else {
|
||||||
|
setIsInitialized(true)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Не удалось прочитать активный таб из localStorage', err)
|
||||||
|
setIsInitialized(true)
|
||||||
|
}
|
||||||
|
}, [isInitialized])
|
||||||
|
|
||||||
|
const markTabAsLoaded = useCallback((tab) => {
|
||||||
|
setLoadedTabs(prev => (prev[tab] ? prev : { ...prev, [tab]: true }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchCurrentWeekData = useCallback(async (isBackground = false) => {
|
||||||
|
try {
|
||||||
|
if (isBackground) {
|
||||||
|
setCurrentWeekBackgroundLoading(true)
|
||||||
|
} else {
|
||||||
|
setCurrentWeekLoading(true)
|
||||||
|
}
|
||||||
|
setCurrentWeekError(null)
|
||||||
|
console.log('Fetching current week data from:', CURRENT_WEEK_API_URL)
|
||||||
|
const response = await fetch(CURRENT_WEEK_API_URL)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки данных')
|
||||||
|
}
|
||||||
|
const jsonData = await response.json()
|
||||||
|
// Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}]
|
||||||
|
let projects = []
|
||||||
|
let total = null
|
||||||
|
|
||||||
|
if (Array.isArray(jsonData) && jsonData.length > 0) {
|
||||||
|
// Если ответ - массив, проверяем первый элемент
|
||||||
|
const firstItem = jsonData[0]
|
||||||
|
if (firstItem && typeof firstItem === 'object') {
|
||||||
|
// Если первый элемент - объект с полями total и projects
|
||||||
|
if (firstItem.projects && Array.isArray(firstItem.projects)) {
|
||||||
|
projects = firstItem.projects
|
||||||
|
total = firstItem.total !== undefined ? firstItem.total : null
|
||||||
|
} else {
|
||||||
|
// Если это просто массив проектов
|
||||||
|
projects = jsonData
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если это массив проектов напрямую
|
||||||
|
projects = jsonData
|
||||||
|
}
|
||||||
|
} else if (jsonData && typeof jsonData === 'object' && !Array.isArray(jsonData)) {
|
||||||
|
// Если ответ - объект напрямую
|
||||||
|
projects = jsonData.projects || jsonData.data || []
|
||||||
|
total = jsonData.total !== undefined ? jsonData.total : null
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentWeekData({
|
||||||
|
projects: Array.isArray(projects) ? projects : [],
|
||||||
|
total: total
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setCurrentWeekError(err.message)
|
||||||
|
console.error('Ошибка загрузки данных текущей недели:', err)
|
||||||
|
} finally {
|
||||||
|
if (isBackground) {
|
||||||
|
setCurrentWeekBackgroundLoading(false)
|
||||||
|
} else {
|
||||||
|
setCurrentWeekLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
|
||||||
|
try {
|
||||||
|
if (isBackground) {
|
||||||
|
setFullStatisticsBackgroundLoading(true)
|
||||||
|
} else {
|
||||||
|
setFullStatisticsLoading(true)
|
||||||
|
}
|
||||||
|
setFullStatisticsError(null)
|
||||||
|
const response = await fetch(FULL_STATISTICS_API_URL)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки данных')
|
||||||
|
}
|
||||||
|
const jsonData = await response.json()
|
||||||
|
setFullStatisticsData(jsonData)
|
||||||
|
} catch (err) {
|
||||||
|
setFullStatisticsError(err.message)
|
||||||
|
console.error('Ошибка загрузки данных полной статистики:', err)
|
||||||
|
} finally {
|
||||||
|
if (isBackground) {
|
||||||
|
setFullStatisticsBackgroundLoading(false)
|
||||||
|
} else {
|
||||||
|
setFullStatisticsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
||||||
|
const tabsInitializedRef = useRef({
|
||||||
|
current: false,
|
||||||
|
priorities: false,
|
||||||
|
full: false,
|
||||||
|
words: false,
|
||||||
|
'add-words': false,
|
||||||
|
'test-config': false,
|
||||||
|
'add-config': false,
|
||||||
|
test: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
||||||
|
const cacheRef = useRef({
|
||||||
|
current: null,
|
||||||
|
full: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обновляем ref при изменении данных
|
||||||
|
useEffect(() => {
|
||||||
|
cacheRef.current.current = currentWeekData
|
||||||
|
}, [currentWeekData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cacheRef.current.full = fullStatisticsData
|
||||||
|
}, [fullStatisticsData])
|
||||||
|
|
||||||
|
// Функция для загрузки данных таба
|
||||||
|
const loadTabData = useCallback((tab, isBackground = false) => {
|
||||||
|
if (tab === 'current') {
|
||||||
|
const hasCache = cacheRef.current.current !== null
|
||||||
|
const isInitialized = tabsInitializedRef.current.current
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
// Первая загрузка таба - загружаем с индикатором
|
||||||
|
fetchCurrentWeekData(false)
|
||||||
|
tabsInitializedRef.current.current = true
|
||||||
|
setTabsInitialized(prev => ({ ...prev, current: true }))
|
||||||
|
} else if (hasCache && isBackground) {
|
||||||
|
// Возврат на таб с кешем - фоновая загрузка
|
||||||
|
fetchCurrentWeekData(true)
|
||||||
|
}
|
||||||
|
// Если нет кеша и это не первая загрузка - ничего не делаем (данные уже загружаются)
|
||||||
|
} else if (tab === 'full') {
|
||||||
|
const hasCache = cacheRef.current.full !== null
|
||||||
|
const isInitialized = tabsInitializedRef.current.full
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
// Первая загрузка таба - загружаем с индикатором
|
||||||
|
fetchFullStatisticsData(false)
|
||||||
|
tabsInitializedRef.current.full = true
|
||||||
|
setTabsInitialized(prev => ({ ...prev, full: true }))
|
||||||
|
} else if (hasCache && isBackground) {
|
||||||
|
// Возврат на таб с кешем - фоновая загрузка
|
||||||
|
fetchFullStatisticsData(true)
|
||||||
|
}
|
||||||
|
} else if (tab === 'priorities') {
|
||||||
|
const isInitialized = tabsInitializedRef.current.priorities
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
// Первая загрузка таба
|
||||||
|
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||||
|
tabsInitializedRef.current.priorities = true
|
||||||
|
setTabsInitialized(prev => ({ ...prev, priorities: true }))
|
||||||
|
} else if (isBackground) {
|
||||||
|
// Возврат на таб - фоновая загрузка
|
||||||
|
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||||
|
}
|
||||||
|
} else if (tab === 'test-config') {
|
||||||
|
const isInitialized = tabsInitializedRef.current['test-config']
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
// Первая загрузка таба
|
||||||
|
setTestConfigRefreshTrigger(prev => prev + 1)
|
||||||
|
tabsInitializedRef.current['test-config'] = true
|
||||||
|
setTabsInitialized(prev => ({ ...prev, 'test-config': true }))
|
||||||
|
} else if (isBackground) {
|
||||||
|
// Возврат на таб - фоновая загрузка
|
||||||
|
setTestConfigRefreshTrigger(prev => prev + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fetchCurrentWeekData, fetchFullStatisticsData])
|
||||||
|
|
||||||
|
// Функция для обновления всех данных (для кнопки Refresh, если она есть)
|
||||||
|
const refreshAllData = useCallback(async () => {
|
||||||
|
setIsRefreshing(true)
|
||||||
|
setPrioritiesError(null)
|
||||||
|
setCurrentWeekError(null)
|
||||||
|
setFullStatisticsError(null)
|
||||||
|
|
||||||
|
// Триггерим обновление приоритетов
|
||||||
|
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||||
|
|
||||||
|
// Загружаем все данные параллельно (не фоново)
|
||||||
|
await Promise.all([
|
||||||
|
fetchCurrentWeekData(false),
|
||||||
|
fetchFullStatisticsData(false),
|
||||||
|
])
|
||||||
|
|
||||||
|
setIsRefreshing(false)
|
||||||
|
}, [fetchCurrentWeekData, fetchFullStatisticsData])
|
||||||
|
|
||||||
|
// Обновляем данные при возвращении экрана в фокус (фоново)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
// Загружаем данные активного таба фоново
|
||||||
|
loadTabData(activeTab, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('focus', handleFocus)
|
||||||
|
document.addEventListener('visibilitychange', handleFocus)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('focus', handleFocus)
|
||||||
|
document.removeEventListener('visibilitychange', handleFocus)
|
||||||
|
}
|
||||||
|
}, [activeTab, loadTabData])
|
||||||
|
|
||||||
|
const handleProjectClick = (projectName) => {
|
||||||
|
setSelectedProject(projectName)
|
||||||
|
markTabAsLoaded('full')
|
||||||
|
setActiveTab('full')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTabChange = (tab, params = {}) => {
|
||||||
|
if (tab === 'full' && activeTab === 'full') {
|
||||||
|
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
|
||||||
|
setSelectedProject(null)
|
||||||
|
} else if (tab !== activeTab) {
|
||||||
|
markTabAsLoaded(tab)
|
||||||
|
// Сбрасываем tabParams при переходе с add-config на другой таб
|
||||||
|
if (activeTab === 'add-config' && tab !== 'add-config') {
|
||||||
|
setTabParams({})
|
||||||
|
} else {
|
||||||
|
setTabParams(params)
|
||||||
|
}
|
||||||
|
setActiveTab(tab)
|
||||||
|
if (tab === 'current') {
|
||||||
|
setSelectedProject(null)
|
||||||
|
}
|
||||||
|
// Обновляем список слов при возврате из экрана добавления слов
|
||||||
|
if (activeTab === 'add-words' && tab === 'words') {
|
||||||
|
setWordsRefreshTrigger(prev => prev + 1)
|
||||||
|
}
|
||||||
|
// Загрузка данных произойдет в useEffect при изменении activeTab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик навигации для компонентов
|
||||||
|
const handleNavigate = (tab, params = {}) => {
|
||||||
|
handleTabChange(tab, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем данные при открытии таба (когда таб становится активным)
|
||||||
|
const prevActiveTabRef = useRef(null)
|
||||||
|
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeTab || !loadedTabs[activeTab]) return
|
||||||
|
|
||||||
|
const isFirstLoad = !tabsInitializedRef.current[activeTab]
|
||||||
|
const isReturningToTab = prevActiveTabRef.current !== null && prevActiveTabRef.current !== activeTab
|
||||||
|
|
||||||
|
// Проверяем, не загружали ли мы уже этот таб в этом рендере
|
||||||
|
const tabKey = `${activeTab}-${isFirstLoad ? 'first' : 'return'}`
|
||||||
|
if (lastLoadedTabRef.current === tabKey) {
|
||||||
|
return // Уже загружали
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFirstLoad) {
|
||||||
|
// Первая загрузка таба
|
||||||
|
lastLoadedTabRef.current = tabKey
|
||||||
|
loadTabData(activeTab, false)
|
||||||
|
} else if (isReturningToTab) {
|
||||||
|
// Возврат на таб - фоновая загрузка
|
||||||
|
lastLoadedTabRef.current = tabKey
|
||||||
|
loadTabData(activeTab, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
prevActiveTabRef.current = activeTab
|
||||||
|
}, [activeTab, loadedTabs, loadTabData])
|
||||||
|
|
||||||
|
// Определяем общее состояние загрузки и ошибок для кнопки Refresh
|
||||||
|
const isAnyLoading = currentWeekLoading || fullStatisticsLoading || prioritiesLoading || isRefreshing
|
||||||
|
const hasAnyError = currentWeekError || fullStatisticsError || prioritiesError
|
||||||
|
|
||||||
|
// Сохраняем выбранный таб, чтобы восстановить его после перезагрузки
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage?.setItem('activeTab', activeTab)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Не удалось сохранить активный таб в localStorage', err)
|
||||||
|
}
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
||||||
|
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen min-h-dvh">
|
||||||
|
<div className={`flex-1 ${isFullscreenTab ? 'pb-0' : 'pb-20'}`}>
|
||||||
|
<div className={`max-w-7xl mx-auto ${isFullscreenTab ? 'p-0' : 'p-4 md:p-6'}`}>
|
||||||
|
{loadedTabs.current && (
|
||||||
|
<div className={activeTab === 'current' ? 'block' : 'hidden'}>
|
||||||
|
<CurrentWeek
|
||||||
|
onProjectClick={handleProjectClick}
|
||||||
|
data={currentWeekData}
|
||||||
|
loading={currentWeekLoading}
|
||||||
|
error={currentWeekError}
|
||||||
|
onRetry={fetchCurrentWeekData}
|
||||||
|
allProjectsData={fullStatisticsData}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs.priorities && (
|
||||||
|
<div className={activeTab === 'priorities' ? 'block' : 'hidden'}>
|
||||||
|
<ProjectPriorityManager
|
||||||
|
allProjectsData={fullStatisticsData}
|
||||||
|
currentWeekData={currentWeekData}
|
||||||
|
shouldLoad={activeTab === 'priorities' && loadedTabs.priorities}
|
||||||
|
onLoadingChange={setPrioritiesLoading}
|
||||||
|
onErrorChange={setPrioritiesError}
|
||||||
|
refreshTrigger={prioritiesRefreshTrigger}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs.full && (
|
||||||
|
<div className={activeTab === 'full' ? 'block' : 'hidden'}>
|
||||||
|
<FullStatistics
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
onClearSelection={() => setSelectedProject(null)}
|
||||||
|
data={fullStatisticsData}
|
||||||
|
loading={fullStatisticsLoading}
|
||||||
|
error={fullStatisticsError}
|
||||||
|
onRetry={fetchFullStatisticsData}
|
||||||
|
currentWeekData={currentWeekData}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs.words && (
|
||||||
|
<div className={activeTab === 'words' ? 'block' : 'hidden'}>
|
||||||
|
<WordList
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
dictionaryId={tabParams.dictionaryId}
|
||||||
|
isNewDictionary={tabParams.isNewDictionary}
|
||||||
|
refreshTrigger={wordsRefreshTrigger}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['add-words'] && (
|
||||||
|
<div className={activeTab === 'add-words' ? 'block' : 'hidden'}>
|
||||||
|
<AddWords
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
dictionaryId={tabParams.dictionaryId}
|
||||||
|
dictionaryName={tabParams.dictionaryName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['test-config'] && (
|
||||||
|
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}>
|
||||||
|
<TestConfigSelection
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
refreshTrigger={testConfigRefreshTrigger}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['add-config'] && (
|
||||||
|
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
|
||||||
|
<AddConfig
|
||||||
|
key={tabParams.config?.id || 'new'}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
editingConfig={tabParams.config}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs.test && (
|
||||||
|
<div className={activeTab === 'test' ? 'block' : 'hidden'}>
|
||||||
|
<TestWords
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
wordCount={tabParams.wordCount}
|
||||||
|
configId={tabParams.configId}
|
||||||
|
maxCards={tabParams.maxCards}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isFullscreenTab && (
|
||||||
|
<div className="sticky bottom-0 flex bg-white/90 backdrop-blur-md border-t border-white/20 flex-shrink-0 overflow-x-auto shadow-lg z-10 justify-center items-center relative w-full" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||||
|
<div className="flex">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('current')}
|
||||||
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||||
|
activeTab === 'current'
|
||||||
|
? 'text-indigo-700 bg-white/50'
|
||||||
|
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||||
|
}`}
|
||||||
|
title="Неделя"
|
||||||
|
>
|
||||||
|
<span className="relative z-10 flex items-center justify-center">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{activeTab === 'current' && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange('test-config')}
|
||||||
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||||
|
activeTab === 'test-config' || activeTab === 'test'
|
||||||
|
? 'text-indigo-700 bg-white/50'
|
||||||
|
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||||
|
}`}
|
||||||
|
title="Тест"
|
||||||
|
>
|
||||||
|
<span className="relative z-10 flex items-center justify-center">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||||
|
<path d="M8 7h6"></path>
|
||||||
|
<path d="M8 11h4"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{(activeTab === 'test-config' || activeTab === 'test') && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
||||||
|
|
||||||
222
play-life-web/src/components/AddConfig.css
Normal file
222
play-life-web/src/components/AddConfig.css
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
.add-config {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.add-config {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-config h2 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover:not(:disabled) {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:disabled {
|
||||||
|
background-color: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-button {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, transform 0.1s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-button:hover:not(:disabled) {
|
||||||
|
background-color: #2980b9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-button:disabled {
|
||||||
|
background-color: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-x-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
z-index: 1600;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-x-button:hover {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionaries-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionaries-checkbox-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-checkbox-label:hover {
|
||||||
|
background-color: #e8f4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-checkbox-label input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
min-height: 18px;
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #3498db;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-checkbox-label span {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 18px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
344
play-life-web/src/components/AddConfig.jsx
Normal file
344
play-life-web/src/components/AddConfig.jsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import './AddConfig.css'
|
||||||
|
|
||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
|
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [tryMessage, setTryMessage] = useState('')
|
||||||
|
const [wordsCount, setWordsCount] = useState('10')
|
||||||
|
const [maxCards, setMaxCards] = useState('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [dictionaries, setDictionaries] = useState([])
|
||||||
|
const [selectedDictionaryIds, setSelectedDictionaryIds] = useState([])
|
||||||
|
const [loadingDictionaries, setLoadingDictionaries] = useState(false)
|
||||||
|
|
||||||
|
// Load dictionaries
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDictionaries = async () => {
|
||||||
|
setLoadingDictionaries(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке словарей')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load dictionaries:', err)
|
||||||
|
} finally {
|
||||||
|
setLoadingDictionaries(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadDictionaries()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load selected dictionaries when editing
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSelectedDictionaries = async () => {
|
||||||
|
if (initialEditingConfig?.id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load selected dictionaries:', err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedDictionaryIds([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadSelectedDictionaries()
|
||||||
|
}, [initialEditingConfig])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialEditingConfig) {
|
||||||
|
setName(initialEditingConfig.name)
|
||||||
|
setTryMessage(initialEditingConfig.try_message)
|
||||||
|
setWordsCount(String(initialEditingConfig.words_count))
|
||||||
|
setMaxCards(initialEditingConfig.max_cards ? String(initialEditingConfig.max_cards) : '')
|
||||||
|
} else {
|
||||||
|
// Сбрасываем состояние при открытии в режиме добавления
|
||||||
|
setName('')
|
||||||
|
setTryMessage('')
|
||||||
|
setWordsCount('10')
|
||||||
|
setMaxCards('')
|
||||||
|
setMessage('')
|
||||||
|
setSelectedDictionaryIds([])
|
||||||
|
}
|
||||||
|
}, [initialEditingConfig])
|
||||||
|
|
||||||
|
// Сбрасываем состояние при размонтировании компонента
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setName('')
|
||||||
|
setTryMessage('')
|
||||||
|
setWordsCount('10')
|
||||||
|
setMaxCards('')
|
||||||
|
setMessage('')
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setMessage('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setMessage('Имя обязательно для заполнения.')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = initialEditingConfig
|
||||||
|
? `${API_URL}/configs/${initialEditingConfig.id}`
|
||||||
|
: `${API_URL}/configs`
|
||||||
|
const method = initialEditingConfig ? 'PUT' : 'POST'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
try_message: tryMessage.trim() || '',
|
||||||
|
words_count: wordsCount === '' ? 0 : parseInt(wordsCount) || 0,
|
||||||
|
max_cards: maxCards === '' ? null : parseInt(maxCards) || null,
|
||||||
|
dictionary_ids: selectedDictionaryIds.length > 0 ? selectedDictionaryIds : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
const errorMessage = errorData.message || response.statusText || `Ошибка при ${initialEditingConfig ? 'обновлении' : 'создании'} конфигурации`
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialEditingConfig) {
|
||||||
|
setName('')
|
||||||
|
setTryMessage('')
|
||||||
|
setWordsCount('10')
|
||||||
|
setMaxCards('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate back immediately
|
||||||
|
onNavigate?.('test-config')
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(`Ошибка: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNumericValue = () => {
|
||||||
|
return wordsCount === '' ? 0 : parseInt(wordsCount) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMaxCardsNumericValue = () => {
|
||||||
|
return maxCards === '' ? 0 : parseInt(maxCards) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDecrease = () => {
|
||||||
|
const numValue = getNumericValue()
|
||||||
|
if (numValue > 0) {
|
||||||
|
setWordsCount(String(numValue - 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIncrease = () => {
|
||||||
|
const numValue = getNumericValue()
|
||||||
|
setWordsCount(String(numValue + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMaxCardsDecrease = () => {
|
||||||
|
const numValue = getMaxCardsNumericValue()
|
||||||
|
if (numValue > 0) {
|
||||||
|
setMaxCards(String(numValue - 1))
|
||||||
|
} else {
|
||||||
|
setMaxCards('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMaxCardsIncrease = () => {
|
||||||
|
const numValue = getMaxCardsNumericValue()
|
||||||
|
const newValue = numValue + 1
|
||||||
|
setMaxCards(String(newValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
// Сбрасываем состояние при закрытии
|
||||||
|
setName('')
|
||||||
|
setTryMessage('')
|
||||||
|
setWordsCount('10')
|
||||||
|
setMaxCards('')
|
||||||
|
setMessage('')
|
||||||
|
onNavigate?.('test-config')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="add-config">
|
||||||
|
<button className="close-x-button" onClick={handleClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<h2>Конфигурация теста</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="name">Имя</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Название конфига"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="tryMessage">Сообщение (необязательно)</label>
|
||||||
|
<textarea
|
||||||
|
id="tryMessage"
|
||||||
|
className="form-textarea"
|
||||||
|
value={tryMessage}
|
||||||
|
onChange={(e) => setTryMessage(e.target.value)}
|
||||||
|
placeholder="Сообщение которое будет отправлено в play-life при прохождении теста"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="wordsCount">Кол-во слов</label>
|
||||||
|
<div className="stepper-container">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stepper-button"
|
||||||
|
onClick={handleDecrease}
|
||||||
|
disabled={getNumericValue() <= 0}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
id="wordsCount"
|
||||||
|
type="number"
|
||||||
|
className="stepper-input"
|
||||||
|
value={wordsCount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const inputValue = e.target.value
|
||||||
|
if (inputValue === '') {
|
||||||
|
setWordsCount('')
|
||||||
|
} else {
|
||||||
|
const numValue = parseInt(inputValue)
|
||||||
|
if (!isNaN(numValue) && numValue >= 0) {
|
||||||
|
setWordsCount(inputValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stepper-button"
|
||||||
|
onClick={handleIncrease}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="maxCards">Макс. кол-во карточек (необязательно)</label>
|
||||||
|
<div className="stepper-container">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stepper-button"
|
||||||
|
onClick={handleMaxCardsDecrease}
|
||||||
|
disabled={getMaxCardsNumericValue() <= 0}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
id="maxCards"
|
||||||
|
type="number"
|
||||||
|
className="stepper-input"
|
||||||
|
value={maxCards}
|
||||||
|
onChange={(e) => {
|
||||||
|
const inputValue = e.target.value
|
||||||
|
if (inputValue === '') {
|
||||||
|
setMaxCards('')
|
||||||
|
} else {
|
||||||
|
const numValue = parseInt(inputValue)
|
||||||
|
if (!isNaN(numValue) && numValue >= 0) {
|
||||||
|
setMaxCards(inputValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stepper-button"
|
||||||
|
onClick={handleMaxCardsIncrease}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="dictionaries">Словари (необязательно)</label>
|
||||||
|
<div className="dictionaries-hint">
|
||||||
|
Если не выбрано ни одного словаря, будут использоваться все словари
|
||||||
|
</div>
|
||||||
|
{loadingDictionaries ? (
|
||||||
|
<div>Загрузка словарей...</div>
|
||||||
|
) : (
|
||||||
|
<div className="dictionaries-checkbox-list">
|
||||||
|
{dictionaries.map((dict) => (
|
||||||
|
<label key={dict.id} className="dictionary-checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedDictionaryIds.includes(dict.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedDictionaryIds([...selectedDictionaryIds, dict.id])
|
||||||
|
} else {
|
||||||
|
setSelectedDictionaryIds(selectedDictionaryIds.filter(id => id !== dict.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{dict.name} ({dict.wordsCount})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="submit-button"
|
||||||
|
disabled={loading || !name.trim() || getNumericValue() === 0}
|
||||||
|
>
|
||||||
|
{loading ? (initialEditingConfig ? 'Обновление...' : 'Создание...') : (initialEditingConfig ? 'Обновить конфигурацию' : 'Создать конфигурацию')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddConfig
|
||||||
|
|
||||||
106
play-life-web/src/components/AddWords.css
Normal file
106
play-life-web/src/components/AddWords.css
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
.add-words {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.add-words {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-words h2 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover:not(:disabled) {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:disabled {
|
||||||
|
background-color: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-x-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
z-index: 1600;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-x-button:hover {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
163
play-life-web/src/components/AddWords.jsx
Normal file
163
play-life-web/src/components/AddWords.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import './AddWords.css'
|
||||||
|
|
||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
|
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
||||||
|
const [markdownText, setMarkdownText] = useState('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// Hide add button if dictionary name is not set
|
||||||
|
const canAddWords = dictionaryName && dictionaryName.trim() !== ''
|
||||||
|
|
||||||
|
const parseMarkdownTable = (text) => {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
const words = []
|
||||||
|
let foundTable = false
|
||||||
|
let headerFound = false
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim()
|
||||||
|
|
||||||
|
// Skip empty lines
|
||||||
|
if (!line) continue
|
||||||
|
|
||||||
|
// Look for table start (markdown table with |)
|
||||||
|
if (line.includes('|') && line.includes('Слово')) {
|
||||||
|
foundTable = true
|
||||||
|
headerFound = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip separator line (|---|---|)
|
||||||
|
if (foundTable && line.match(/^\|[\s\-|:]+\|$/)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse table rows
|
||||||
|
if (foundTable && headerFound && line.includes('|')) {
|
||||||
|
const cells = line
|
||||||
|
.split('|')
|
||||||
|
.map(cell => (cell || '').trim())
|
||||||
|
.filter(cell => cell && cell.length > 0)
|
||||||
|
|
||||||
|
if (cells.length >= 2) {
|
||||||
|
// Remove markdown formatting (**bold**, etc.)
|
||||||
|
const word = cells[0].replace(/\*\*/g, '').trim()
|
||||||
|
const translation = cells[1].replace(/\*\*/g, '').trim()
|
||||||
|
|
||||||
|
if (word && translation) {
|
||||||
|
words.push({
|
||||||
|
name: word,
|
||||||
|
translation: translation,
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setMessage('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const words = parseMarkdownTable(markdownText)
|
||||||
|
|
||||||
|
if (words.length === 0) {
|
||||||
|
setMessage('Не удалось найти слова в таблице. Убедитесь, что таблица содержит колонки "Слово" и "Перевод".')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dictionary_id to each word if dictionaryId is provided
|
||||||
|
const wordsWithDictionary = words.map(word => ({
|
||||||
|
...word,
|
||||||
|
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
|
||||||
|
}))
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/words`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ words: wordsWithDictionary }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при добавлении слов')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const addedCount = data?.added || 0
|
||||||
|
setMessage(`Успешно добавлено ${addedCount} слов(а)!`)
|
||||||
|
setMarkdownText('')
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(`Ошибка: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onNavigate?.('words', dictionaryId !== undefined && dictionaryId !== null ? { dictionaryId } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="add-words">
|
||||||
|
<button className="close-x-button" onClick={handleClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<h2>Добавить слова</h2>
|
||||||
|
<p className="description">
|
||||||
|
Вставьте текст в формате Markdown с таблицей, содержащей колонки "Слово" и "Перевод"
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<textarea
|
||||||
|
className="markdown-input"
|
||||||
|
value={markdownText}
|
||||||
|
onChange={(e) => setMarkdownText(e.target.value)}
|
||||||
|
placeholder={`Вот таблица, содержащая только слова и их перевод:
|
||||||
|
|
||||||
|
| Слово (Word) | Перевод |
|
||||||
|
| --- | --- |
|
||||||
|
| **Adventure** | Приключение |
|
||||||
|
| **Challenge** | Вызов, сложная задача |
|
||||||
|
| **Decision** | Решение |`}
|
||||||
|
rows={15}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{canAddWords && (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="submit-button"
|
||||||
|
disabled={loading || !markdownText.trim()}
|
||||||
|
>
|
||||||
|
{loading ? 'Добавление...' : 'Добавить слова'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canAddWords && (
|
||||||
|
<div className="message error">
|
||||||
|
Сначала установите название словаря на экране списка слов
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddWords
|
||||||
|
|
||||||
200
play-life-web/src/components/CurrentWeek.jsx
Normal file
200
play-life-web/src/components/CurrentWeek.jsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import ProjectProgressBar from './ProjectProgressBar'
|
||||||
|
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||||
|
|
||||||
|
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
|
||||||
|
// Обрабатываем данные: может быть объект с projects и total, или просто массив
|
||||||
|
const projectsData = data?.projects || (Array.isArray(data) ? data : [])
|
||||||
|
|
||||||
|
// Показываем loading только если данных нет и идет загрузка
|
||||||
|
if (loading && (!data || projectsData.length === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||||
|
<div className="text-gray-600 font-medium">Загрузка данных...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && (!data || projectsData.length === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
|
||||||
|
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
|
||||||
|
<div className="text-red-600 text-sm">{error}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Процент выполнения берем только из данных API
|
||||||
|
const overallProgress = (() => {
|
||||||
|
// Проверяем различные возможные названия поля
|
||||||
|
const rawValue = data?.total ?? data?.progress ?? data?.percentage ?? data?.completion ?? data?.goal_progress
|
||||||
|
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
|
||||||
|
|
||||||
|
if (Number.isFinite(parsedValue) && parsedValue >= 0) {
|
||||||
|
return Math.max(0, parsedValue) // Убрали ограничение на 100, так как может быть больше
|
||||||
|
}
|
||||||
|
|
||||||
|
return null // null означает, что данные не пришли
|
||||||
|
})()
|
||||||
|
|
||||||
|
const hasProgressData = overallProgress !== null
|
||||||
|
|
||||||
|
// Логирование для отладки
|
||||||
|
console.log('CurrentWeek data:', {
|
||||||
|
data,
|
||||||
|
dataTotal: data?.total,
|
||||||
|
dataProgress: data?.progress,
|
||||||
|
dataPercentage: data?.percentage,
|
||||||
|
overallProgress,
|
||||||
|
hasProgressData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!projectsData || projectsData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем отсортированный список всех проектов для синхронизации цветов
|
||||||
|
const allProjects = getAllProjectsSorted(allProjectsData, projectsData)
|
||||||
|
|
||||||
|
const normalizePriority = (value) => {
|
||||||
|
if (value === null || value === undefined) return Infinity
|
||||||
|
const numeric = Number(value)
|
||||||
|
return Number.isFinite(numeric) ? numeric : Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем: сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
|
||||||
|
const sortedData = [...projectsData].sort((a, b) => {
|
||||||
|
const priorityA = normalizePriority(a.priority)
|
||||||
|
const priorityB = normalizePriority(b.priority)
|
||||||
|
|
||||||
|
if (priorityA !== priorityB) {
|
||||||
|
return priorityA - priorityB
|
||||||
|
}
|
||||||
|
|
||||||
|
const minGoalA = parseFloat(a.min_goal_score) || 0
|
||||||
|
const minGoalB = parseFloat(b.min_goal_score) || 0
|
||||||
|
return minGoalB - minGoalA
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Информация об общем проценте выполнения целей */}
|
||||||
|
<div className="mb-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-200">
|
||||||
|
<div className="flex items-stretch justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1 flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm sm:text-base text-gray-600 mb-1">Выполнение целей</div>
|
||||||
|
<div className="text-2xl sm:text-3xl lg:text-4xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||||
|
{hasProgressData ? `${overallProgress.toFixed(1)}%` : 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasProgressData && (
|
||||||
|
<div className="w-12 h-12 sm:w-16 sm:h-16 relative flex-shrink-0">
|
||||||
|
<svg className="transform -rotate-90" viewBox="0 0 64 64">
|
||||||
|
<circle
|
||||||
|
cx="32"
|
||||||
|
cy="32"
|
||||||
|
r="28"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="6"
|
||||||
|
fill="none"
|
||||||
|
className="text-gray-200"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="32"
|
||||||
|
cy="32"
|
||||||
|
r="28"
|
||||||
|
stroke="url(#gradient)"
|
||||||
|
strokeWidth="6"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={`${Math.min(overallProgress / 100, 1) * 175.93} 175.93`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{overallProgress >= 100 && (
|
||||||
|
<g className="transform rotate-90" style={{ transformOrigin: '32px 32px' }}>
|
||||||
|
<path
|
||||||
|
d="M 20 32 L 28 40 L 44 24"
|
||||||
|
stroke="url(#gradient)"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#4f46e5" />
|
||||||
|
<stop offset="100%" stopColor="#9333ea" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onNavigate && (
|
||||||
|
<div className="flex flex-col flex-shrink-0 gap-1" style={{ minHeight: '64px', height: '100%' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('full')}
|
||||||
|
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
title="Статистика"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10"></line>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4"></line>
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('priorities')}
|
||||||
|
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
title="Приоритеты"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{sortedData.map((project, index) => {
|
||||||
|
const projectColor = getProjectColor(project.project_name, allProjects)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index}>
|
||||||
|
<ProjectProgressBar
|
||||||
|
projectName={project.project_name}
|
||||||
|
totalScore={parseFloat(project.total_score)}
|
||||||
|
minGoalScore={parseFloat(project.min_goal_score)}
|
||||||
|
maxGoalScore={parseFloat(project.max_goal_score)}
|
||||||
|
onProjectClick={onProjectClick}
|
||||||
|
projectColor={projectColor}
|
||||||
|
priority={project.priority}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CurrentWeek
|
||||||
|
|
||||||
289
play-life-web/src/components/FullStatistics.jsx
Normal file
289
play-life-web/src/components/FullStatistics.jsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js'
|
||||||
|
import { Line } from 'react-chartjs-2'
|
||||||
|
import WeekProgressChart from './WeekProgressChart'
|
||||||
|
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
|
||||||
|
|
||||||
|
// Экспортируем для обратной совместимости (если используется в других местах)
|
||||||
|
export { getProjectColorByIndex } from '../utils/projectUtils'
|
||||||
|
|
||||||
|
const formatWeekKey = ({ year, week }) => `${year}-W${week.toString().padStart(2, '0')}`
|
||||||
|
|
||||||
|
const parseWeekKey = (weekKey) => {
|
||||||
|
const [yearStr, weekStr] = weekKey.split('-W')
|
||||||
|
return { year: Number(yearStr), week: Number(weekStr) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareWeekKeys = (a, b) => {
|
||||||
|
const [yearA, weekA] = a.split('-W').map(Number)
|
||||||
|
const [yearB, weekB] = b.split('-W').map(Number)
|
||||||
|
|
||||||
|
if (yearA !== yearB) {
|
||||||
|
return yearA - yearB
|
||||||
|
}
|
||||||
|
return weekA - weekB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращает понедельник ISO-недели
|
||||||
|
const getDateOfISOWeek = (week, year) => {
|
||||||
|
const simple = new Date(year, 0, 1 + (week - 1) * 7)
|
||||||
|
const dayOfWeek = simple.getDay() || 7 // Sunday -> 7
|
||||||
|
if (dayOfWeek !== 1) {
|
||||||
|
simple.setDate(simple.getDate() + (1 - dayOfWeek))
|
||||||
|
}
|
||||||
|
simple.setHours(0, 0, 0, 0)
|
||||||
|
return simple
|
||||||
|
}
|
||||||
|
|
||||||
|
const getISOWeekInfo = (date) => {
|
||||||
|
const target = new Date(date.getTime())
|
||||||
|
target.setHours(0, 0, 0, 0)
|
||||||
|
target.setDate(target.getDate() + 4 - (target.getDay() || 7))
|
||||||
|
const year = target.getFullYear()
|
||||||
|
const yearStart = new Date(year, 0, 1)
|
||||||
|
const week = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
|
||||||
|
return { year, week }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPrevWeekKey = (weekKey) => {
|
||||||
|
const { year, week } = parseWeekKey(weekKey)
|
||||||
|
const currentWeekDate = getDateOfISOWeek(week, year)
|
||||||
|
const prevWeekDate = new Date(currentWeekDate.getTime())
|
||||||
|
prevWeekDate.setDate(prevWeekDate.getDate() - 7)
|
||||||
|
const prevWeekInfo = getISOWeekInfo(prevWeekDate)
|
||||||
|
return formatWeekKey(prevWeekInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
)
|
||||||
|
|
||||||
|
function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate }) {
|
||||||
|
|
||||||
|
const processData = () => {
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
|
||||||
|
// Группируем данные по проектам
|
||||||
|
const projectsMap = {}
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
const projectName = item.project_name
|
||||||
|
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
|
||||||
|
|
||||||
|
if (!projectsMap[projectName]) {
|
||||||
|
projectsMap[projectName] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectsMap[projectName][weekKey] = parseFloat(item.total_score)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Добавляем дополнительную неделю со значением 0,
|
||||||
|
// если первая неделя проекта имеет ненулевое значение
|
||||||
|
Object.values(projectsMap).forEach((weeks) => {
|
||||||
|
const projectWeeks = Object.keys(weeks)
|
||||||
|
if (!projectWeeks.length) return
|
||||||
|
|
||||||
|
const sortedProjectWeeks = projectWeeks.sort(compareWeekKeys)
|
||||||
|
const firstWeekKey = sortedProjectWeeks[0]
|
||||||
|
const firstScore = weeks[firstWeekKey]
|
||||||
|
|
||||||
|
if (firstScore !== 0) {
|
||||||
|
const zeroWeekKey = getPrevWeekKey(firstWeekKey)
|
||||||
|
weeks[zeroWeekKey] = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Собираем все уникальные недели и сортируем их по году и неделе
|
||||||
|
const allWeeks = new Set()
|
||||||
|
Object.values(projectsMap).forEach(weeks => {
|
||||||
|
Object.keys(weeks).forEach(week => allWeeks.add(week))
|
||||||
|
})
|
||||||
|
const sortedWeeks = Array.from(allWeeks).sort(compareWeekKeys)
|
||||||
|
|
||||||
|
// Получаем отсортированный список всех проектов для синхронизации цветов
|
||||||
|
const allProjectNames = getAllProjectsSorted(data)
|
||||||
|
|
||||||
|
// Фильтруем по выбранному проекту, если он указан
|
||||||
|
let projectNames = allProjectNames.filter(name => projectsMap[name])
|
||||||
|
|
||||||
|
// Сортируем проекты так же, как на экране списка проектов (по priority и min_goal_score)
|
||||||
|
if (currentWeekData) {
|
||||||
|
projectNames = sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedProject) {
|
||||||
|
projectNames = projectNames.filter(name => name === selectedProject)
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasets = projectNames.map((projectName) => {
|
||||||
|
let cumulativeSum = 0
|
||||||
|
const values = sortedWeeks.map(week => {
|
||||||
|
const score = projectsMap[projectName]?.[week] || 0
|
||||||
|
cumulativeSum += score
|
||||||
|
return cumulativeSum
|
||||||
|
})
|
||||||
|
|
||||||
|
// Генерируем цвет для линии на основе индекса в полном списке проектов
|
||||||
|
const color = getProjectColor(projectName, allProjectNames)
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: projectName,
|
||||||
|
data: values,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color.replace('50%)', '20%)'),
|
||||||
|
fill: false,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: sortedWeeks,
|
||||||
|
datasets: datasets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = processData()
|
||||||
|
|
||||||
|
// Показываем loading только если данных нет и идет загрузка
|
||||||
|
if (loading && !chartData) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||||
|
<div className="text-gray-600 font-medium">Загрузка данных...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !chartData) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
|
||||||
|
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
|
||||||
|
<div className="text-red-600 text-sm">{error}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 15,
|
||||||
|
font: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chartData) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{onNavigate && (
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('current')}
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
title="Закрыть"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ height: '550px' }}>
|
||||||
|
<Line data={chartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FullStatistics
|
||||||
|
|
||||||
724
play-life-web/src/components/ProjectPriorityManager.jsx
Normal file
724
play-life-web/src/components/ProjectPriorityManager.jsx
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragOverlay,
|
||||||
|
useDroppable,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||||
|
|
||||||
|
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||||
|
const PROJECTS_API_URL = '/projects'
|
||||||
|
const PRIORITY_UPDATE_API_URL = '/project/priority'
|
||||||
|
|
||||||
|
// Компонент для сортируемого элемента проекта
|
||||||
|
function SortableProjectItem({ project, index, allProjects, onRemove }) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: project.name })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectColor = getProjectColor(project.name, allProjects)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
data-id={project.name}
|
||||||
|
style={{ ...style, touchAction: 'none' }}
|
||||||
|
className={`bg-white rounded-lg p-3 border-2 border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 ${
|
||||||
|
isDragging ? 'border-indigo-400' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<div
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600"
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<circle cx="7" cy="7" r="1.5" />
|
||||||
|
<circle cx="13" cy="7" r="1.5" />
|
||||||
|
<circle cx="7" cy="13" r="1.5" />
|
||||||
|
<circle cx="13" cy="13" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: projectColor }}
|
||||||
|
></div>
|
||||||
|
<span className="font-semibold text-gray-800">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(project.name)}
|
||||||
|
className="ml-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||||
|
title="Убрать из этого слота"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент для пустого слота (droppable)
|
||||||
|
function DroppableSlot({ containerId, isEmpty, maxItems, currentCount }) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id: containerId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`bg-gray-50 border-2 border-dashed rounded-lg p-4 text-center text-sm transition-colors ${
|
||||||
|
isOver
|
||||||
|
? 'border-indigo-400 bg-indigo-50 text-indigo-600'
|
||||||
|
: 'border-gray-300 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEmpty
|
||||||
|
? 'Перетащите проект сюда'
|
||||||
|
: `Можно добавить еще ${maxItems - currentCount} проект${maxItems - currentCount > 1 ? 'а' : ''}`}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент для слота приоритета
|
||||||
|
function PrioritySlot({ title, projects, allProjects, onRemove, maxItems = null, containerId }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-sm font-semibold text-gray-600 mb-2">{title}</div>
|
||||||
|
<div className="space-y-2 min-h-[60px]">
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<DroppableSlot containerId={containerId} isEmpty={true} maxItems={maxItems} currentCount={0} />
|
||||||
|
)}
|
||||||
|
{projects.map((project, index) => (
|
||||||
|
<SortableProjectItem
|
||||||
|
key={project.name}
|
||||||
|
project={project}
|
||||||
|
index={index}
|
||||||
|
allProjects={allProjects}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
|
||||||
|
const [projectsLoading, setProjectsLoading] = useState(false)
|
||||||
|
const [projectsError, setProjectsError] = useState(null)
|
||||||
|
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
||||||
|
|
||||||
|
// Уведомляем родительский компонент об изменении состояния загрузки
|
||||||
|
useEffect(() => {
|
||||||
|
if (onLoadingChange) {
|
||||||
|
onLoadingChange(projectsLoading)
|
||||||
|
}
|
||||||
|
}, [projectsLoading, onLoadingChange])
|
||||||
|
|
||||||
|
// Уведомляем родительский компонент об изменении ошибок
|
||||||
|
useEffect(() => {
|
||||||
|
if (onErrorChange) {
|
||||||
|
onErrorChange(projectsError)
|
||||||
|
}
|
||||||
|
}, [projectsError, onErrorChange])
|
||||||
|
const [allProjects, setAllProjects] = useState([])
|
||||||
|
const [maxPriority, setMaxPriority] = useState([])
|
||||||
|
const [mediumPriority, setMediumPriority] = useState([])
|
||||||
|
const [lowPriority, setLowPriority] = useState([])
|
||||||
|
const [activeId, setActiveId] = useState(null)
|
||||||
|
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef(null)
|
||||||
|
const hasFetchedRef = useRef(false)
|
||||||
|
const skipNextEffectRef = useRef(false)
|
||||||
|
const lastRefreshTriggerRef = useRef(0) // Отслеживаем последний обработанный refreshTrigger
|
||||||
|
const isLoadingRef = useRef(false) // Отслеживаем, идет ли сейчас загрузка
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 10, // Активация только после перемещения на 10px
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Получаем резервный список проектов из уже загруженных данных,
|
||||||
|
// если API для приоритетов недоступен.
|
||||||
|
const getFallbackProjects = useCallback(() => {
|
||||||
|
return getAllProjectsSorted(allProjectsData, currentWeekData)
|
||||||
|
}, [allProjectsData, currentWeekData])
|
||||||
|
|
||||||
|
const normalizeProjects = useCallback((projectsArray) => {
|
||||||
|
const normalizedProjects = projectsArray
|
||||||
|
.map(item => {
|
||||||
|
const name = item?.name || item?.project || item?.project_name || item?.title
|
||||||
|
if (!name) return null
|
||||||
|
|
||||||
|
const id = item?.project_id ?? item?.id ?? item?.projectId ?? null
|
||||||
|
const priorityValue = item?.priority ?? item?.priority_value ?? item?.priority_level ?? null
|
||||||
|
return { id, name, priority: priorityValue }
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const uniqueProjects = []
|
||||||
|
const seenNames = new Set()
|
||||||
|
|
||||||
|
normalizedProjects.forEach(item => {
|
||||||
|
const key = item.id ?? item.name
|
||||||
|
if (!seenNames.has(key)) {
|
||||||
|
seenNames.add(key)
|
||||||
|
uniqueProjects.push(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const max = []
|
||||||
|
const medium = []
|
||||||
|
const low = []
|
||||||
|
|
||||||
|
uniqueProjects.forEach(item => {
|
||||||
|
const projectEntry = { name: item.name, id: item.id }
|
||||||
|
if (item.priority === 1) {
|
||||||
|
max.push(projectEntry)
|
||||||
|
} else if (item.priority === 2) {
|
||||||
|
medium.push(projectEntry)
|
||||||
|
} else {
|
||||||
|
low.push(projectEntry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
uniqueProjects,
|
||||||
|
max,
|
||||||
|
medium,
|
||||||
|
low,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const applyProjects = useCallback((projectsArray) => {
|
||||||
|
const { uniqueProjects, max, medium, low } = normalizeProjects(projectsArray)
|
||||||
|
setAllProjects(uniqueProjects.map(item => item.name))
|
||||||
|
setMaxPriority(max)
|
||||||
|
setMediumPriority(medium)
|
||||||
|
setLowPriority(low)
|
||||||
|
}, [normalizeProjects])
|
||||||
|
|
||||||
|
const fetchProjects = useCallback(async (isBackground = false) => {
|
||||||
|
// Предотвращаем параллельные загрузки
|
||||||
|
if (isLoadingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoadingRef.current = true
|
||||||
|
|
||||||
|
// Показываем загрузку только если это не фоновая загрузка
|
||||||
|
if (!isBackground) {
|
||||||
|
setProjectsLoading(true)
|
||||||
|
}
|
||||||
|
setProjectsError(null)
|
||||||
|
|
||||||
|
const response = await fetch(PROJECTS_API_URL)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось загрузить проекты')
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = await response.json()
|
||||||
|
const projectsArray = Array.isArray(jsonData)
|
||||||
|
? jsonData
|
||||||
|
: Array.isArray(jsonData?.projects)
|
||||||
|
? jsonData.projects
|
||||||
|
: Array.isArray(jsonData?.data)
|
||||||
|
? jsonData.data
|
||||||
|
: []
|
||||||
|
|
||||||
|
applyProjects(projectsArray)
|
||||||
|
setHasDataCache(true) // Отмечаем, что данные загружены
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки проектов:', error)
|
||||||
|
setProjectsError(error.message || 'Ошибка загрузки проектов')
|
||||||
|
const fallbackProjects = getFallbackProjects()
|
||||||
|
if (fallbackProjects.length > 0) {
|
||||||
|
setAllProjects(fallbackProjects)
|
||||||
|
setMaxPriority([])
|
||||||
|
setMediumPriority([])
|
||||||
|
setLowPriority(fallbackProjects.map(name => ({ name })))
|
||||||
|
setHasDataCache(true) // Отмечаем, что есть fallback данные
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoadingRef.current = false
|
||||||
|
setProjectsLoading(false)
|
||||||
|
}
|
||||||
|
}, [applyProjects, getFallbackProjects])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Если таб не должен загружаться, не делаем ничего
|
||||||
|
if (!shouldLoad) {
|
||||||
|
// Сбрасываем флаг загрузки, если таб стал неактивным
|
||||||
|
if (hasFetchedRef.current) {
|
||||||
|
hasFetchedRef.current = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если загрузка уже идет, не запускаем еще одну
|
||||||
|
if (isLoadingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если refreshTrigger равен 0 и мы еще не загружали - ждем, пока триггер не будет установлен
|
||||||
|
// Это предотвращает загрузку при первом монтировании, когда shouldLoad становится true,
|
||||||
|
// но refreshTrigger еще не установлен
|
||||||
|
if (refreshTrigger === 0 && !hasFetchedRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, был ли этот refreshTrigger уже обработан
|
||||||
|
if (refreshTrigger === lastRefreshTriggerRef.current && hasFetchedRef.current) {
|
||||||
|
return // Уже обработали этот триггер
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если уже загружали и нет нового триггера обновления - не загружаем снова
|
||||||
|
if (hasFetchedRef.current && refreshTrigger === lastRefreshTriggerRef.current) return
|
||||||
|
|
||||||
|
// Определяем, есть ли кеш (данные уже загружены)
|
||||||
|
const hasCache = hasDataCache && (maxPriority.length > 0 || mediumPriority.length > 0 || lowPriority.length > 0)
|
||||||
|
|
||||||
|
// Отмечаем, что обрабатываем этот триггер ПЕРЕД загрузкой
|
||||||
|
lastRefreshTriggerRef.current = refreshTrigger
|
||||||
|
|
||||||
|
if (refreshTrigger > 0) {
|
||||||
|
// Если есть триггер обновления, сбрасываем флаг загрузки
|
||||||
|
hasFetchedRef.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем флаг загрузки перед вызовом
|
||||||
|
hasFetchedRef.current = true
|
||||||
|
|
||||||
|
// Загружаем: если есть кеш - фоново, если нет - с индикатором
|
||||||
|
fetchProjects(hasCache)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fetchProjects, shouldLoad, refreshTrigger]) // hasDataCache и длины массивов проверяются внутри эффекта, не добавляем в зависимости
|
||||||
|
|
||||||
|
const buildAssignments = useCallback(() => {
|
||||||
|
const map = new Map()
|
||||||
|
maxPriority.forEach(p => {
|
||||||
|
map.set(p.id ?? p.name, { id: p.id, priority: 1 })
|
||||||
|
})
|
||||||
|
mediumPriority.forEach(p => {
|
||||||
|
map.set(p.id ?? p.name, { id: p.id, priority: 2 })
|
||||||
|
})
|
||||||
|
lowPriority.forEach(p => {
|
||||||
|
map.set(p.id ?? p.name, { id: p.id, priority: null })
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [lowPriority, maxPriority, mediumPriority])
|
||||||
|
|
||||||
|
const prevAssignmentsRef = useRef(new Map())
|
||||||
|
const initializedAssignmentsRef = useRef(false)
|
||||||
|
|
||||||
|
const sendPriorityChanges = useCallback(async (changes) => {
|
||||||
|
if (!changes.length) return
|
||||||
|
try {
|
||||||
|
await fetch(PRIORITY_UPDATE_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка отправки изменений приоритета', e)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const current = buildAssignments()
|
||||||
|
|
||||||
|
if (!initializedAssignmentsRef.current) {
|
||||||
|
prevAssignmentsRef.current = current
|
||||||
|
initializedAssignmentsRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipNextEffectRef.current) {
|
||||||
|
skipNextEffectRef.current = false
|
||||||
|
prevAssignmentsRef.current = current
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = prevAssignmentsRef.current
|
||||||
|
const allKeys = new Set([...prev.keys(), ...current.keys()])
|
||||||
|
const changes = []
|
||||||
|
|
||||||
|
allKeys.forEach(key => {
|
||||||
|
const prevItem = prev.get(key)
|
||||||
|
const currItem = current.get(key)
|
||||||
|
const prevPriority = prevItem?.priority ?? null
|
||||||
|
const currPriority = currItem?.priority ?? null
|
||||||
|
const id = currItem?.id ?? prevItem?.id
|
||||||
|
|
||||||
|
if (!id) return
|
||||||
|
if (prevPriority !== currPriority) {
|
||||||
|
changes.push({ id, priority: currPriority })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (changes.length) {
|
||||||
|
sendPriorityChanges(changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
prevAssignmentsRef.current = current
|
||||||
|
}, [buildAssignments, sendPriorityChanges])
|
||||||
|
|
||||||
|
const findProjectContainer = (projectName) => {
|
||||||
|
if (maxPriority.find(p => p.name === projectName)) return 'max'
|
||||||
|
if (mediumPriority.find(p => p.name === projectName)) return 'medium'
|
||||||
|
if (lowPriority.find(p => p.name === projectName)) return 'low'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidContainer = (containerId) => {
|
||||||
|
return containerId === 'max' || containerId === 'medium' || containerId === 'low'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragStart = (event) => {
|
||||||
|
setActiveId(event.active.id)
|
||||||
|
// Находим скроллируемый контейнер и отключаем его скролл
|
||||||
|
if (!scrollContainerRef.current) {
|
||||||
|
// Ищем родительский скроллируемый контейнер через DOM
|
||||||
|
const activeElement = document.querySelector(`[data-id="${event.active.id}"]`)
|
||||||
|
if (activeElement) {
|
||||||
|
const container = activeElement.closest('.overflow-y-auto')
|
||||||
|
if (container) {
|
||||||
|
scrollContainerRef.current = container
|
||||||
|
container.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollContainerRef.current.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragCancel = () => {
|
||||||
|
setActiveId(null)
|
||||||
|
// Включаем скролл обратно
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.style.overflow = 'auto'
|
||||||
|
scrollContainerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = (event) => {
|
||||||
|
const { active, over } = event
|
||||||
|
setActiveId(null)
|
||||||
|
// Включаем скролл обратно
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.style.overflow = 'auto'
|
||||||
|
scrollContainerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!over) return
|
||||||
|
|
||||||
|
const activeId = active.id
|
||||||
|
const overId = over.id
|
||||||
|
|
||||||
|
const activeContainer = findProjectContainer(activeId)
|
||||||
|
// Проверяем, является ли overId контейнером или проектом
|
||||||
|
let overContainer = findProjectContainer(overId)
|
||||||
|
if (!overContainer && isValidContainer(overId)) {
|
||||||
|
overContainer = overId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeContainer) return
|
||||||
|
if (!overContainer) return
|
||||||
|
|
||||||
|
// Если перетаскиваем в тот же контейнер
|
||||||
|
if (activeContainer === overContainer) {
|
||||||
|
let items
|
||||||
|
let setItems
|
||||||
|
|
||||||
|
if (activeContainer === 'max') {
|
||||||
|
items = maxPriority
|
||||||
|
setItems = setMaxPriority
|
||||||
|
} else if (activeContainer === 'medium') {
|
||||||
|
items = mediumPriority
|
||||||
|
setItems = setMediumPriority
|
||||||
|
} else {
|
||||||
|
items = lowPriority
|
||||||
|
setItems = setLowPriority
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldIndex = items.findIndex(p => p.name === activeId)
|
||||||
|
const newIndex = items.findIndex(p => p.name === overId)
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
setItems(arrayMove(items, oldIndex, newIndex))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перемещаем между контейнерами
|
||||||
|
const activeProject = [
|
||||||
|
...maxPriority,
|
||||||
|
...mediumPriority,
|
||||||
|
...lowPriority,
|
||||||
|
].find(p => p.name === activeId)
|
||||||
|
|
||||||
|
if (!activeProject) return
|
||||||
|
|
||||||
|
// Если контейнеры одинаковые, ничего не делаем (уже обработано выше)
|
||||||
|
if (activeContainer === overContainer) return
|
||||||
|
|
||||||
|
// Удаляем из старого контейнера
|
||||||
|
if (activeContainer === 'max') {
|
||||||
|
setMaxPriority(prev => prev.filter(p => p.name !== activeId))
|
||||||
|
} else if (activeContainer === 'medium') {
|
||||||
|
setMediumPriority(prev => prev.filter(p => p.name !== activeId))
|
||||||
|
} else {
|
||||||
|
setLowPriority(prev => prev.filter(p => p.name !== activeId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем в новый контейнер
|
||||||
|
if (overContainer === 'max') {
|
||||||
|
// Если контейнер max уже заполнен, заменяем первый элемент
|
||||||
|
if (maxPriority.length >= 1) {
|
||||||
|
const oldProject = maxPriority[0]
|
||||||
|
setMaxPriority([activeProject])
|
||||||
|
// Старый проект перемещаем в low
|
||||||
|
setLowPriority(prev => [...prev, oldProject])
|
||||||
|
} else {
|
||||||
|
// Контейнер пустой, просто добавляем
|
||||||
|
setMaxPriority([activeProject])
|
||||||
|
}
|
||||||
|
} else if (overContainer === 'medium') {
|
||||||
|
// Если контейнер medium уже заполнен (2 элемента)
|
||||||
|
if (mediumPriority.length >= 2) {
|
||||||
|
// Если перетаскиваем на конкретный проект, заменяем его
|
||||||
|
const overIndex = mediumPriority.findIndex(p => p.name === overId)
|
||||||
|
if (overIndex !== -1) {
|
||||||
|
const oldProject = mediumPriority[overIndex]
|
||||||
|
const newItems = [...mediumPriority]
|
||||||
|
newItems[overIndex] = activeProject
|
||||||
|
setMediumPriority(newItems)
|
||||||
|
setLowPriority(prev => [...prev, oldProject])
|
||||||
|
} else {
|
||||||
|
// Перетаскиваем на пустой слот, заменяем последний
|
||||||
|
const oldProject = mediumPriority[mediumPriority.length - 1]
|
||||||
|
setMediumPriority([mediumPriority[0], activeProject])
|
||||||
|
setLowPriority(prev => [...prev, oldProject])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Есть место, добавляем в нужную позицию или в конец
|
||||||
|
const overIndex = mediumPriority.findIndex(p => p.name === overId)
|
||||||
|
if (overIndex !== -1) {
|
||||||
|
const newItems = [...mediumPriority]
|
||||||
|
newItems.splice(overIndex, 0, activeProject)
|
||||||
|
setMediumPriority(newItems)
|
||||||
|
} else {
|
||||||
|
// Перетаскиваем на пустой слот, добавляем в конец
|
||||||
|
setMediumPriority([...mediumPriority, activeProject])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Для low priority просто добавляем
|
||||||
|
const overIndex = lowPriority.findIndex(p => p.name === overId)
|
||||||
|
if (overIndex !== -1) {
|
||||||
|
// Перетаскиваем на конкретный проект
|
||||||
|
const newItems = [...lowPriority]
|
||||||
|
newItems.splice(overIndex, 0, activeProject)
|
||||||
|
setLowPriority(newItems)
|
||||||
|
} else {
|
||||||
|
// Перетаскиваем на пустой слот, добавляем в конец
|
||||||
|
setLowPriority([...lowPriority, activeProject])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProjectKey = (project) => project?.id ?? project?.name
|
||||||
|
|
||||||
|
const moveProjectToLow = (project) => {
|
||||||
|
const projectKey = getProjectKey(project)
|
||||||
|
if (!projectKey) return
|
||||||
|
|
||||||
|
setLowPriority(prev => {
|
||||||
|
const filtered = prev.filter(p => getProjectKey(p) !== projectKey)
|
||||||
|
return [...filtered, project]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = (projectName, container) => {
|
||||||
|
const sourceList =
|
||||||
|
container === 'max'
|
||||||
|
? maxPriority
|
||||||
|
: container === 'medium'
|
||||||
|
? mediumPriority
|
||||||
|
: lowPriority
|
||||||
|
|
||||||
|
const project = sourceList.find(p => p.name === projectName)
|
||||||
|
if (!project) return
|
||||||
|
|
||||||
|
const projectId = project.id ?? project.name
|
||||||
|
if (projectId) {
|
||||||
|
skipNextEffectRef.current = true
|
||||||
|
sendPriorityChanges([{ id: projectId, priority: null }])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container === 'max') {
|
||||||
|
setMaxPriority(prev => prev.filter(p => p.name !== projectName))
|
||||||
|
} else if (container === 'medium') {
|
||||||
|
setMediumPriority(prev => prev.filter(p => p.name !== projectName))
|
||||||
|
}
|
||||||
|
|
||||||
|
moveProjectToLow(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allItems = [
|
||||||
|
...maxPriority.map(p => ({ ...p, container: 'max' })),
|
||||||
|
...mediumPriority.map(p => ({ ...p, container: 'medium' })),
|
||||||
|
...lowPriority.map(p => ({ ...p, container: 'low' })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeProject = allItems.find(item => item.name === activeId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{onNavigate && (
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate('current')}
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
title="Закрыть"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-sm">
|
||||||
|
<div className="font-semibold">Не удалось загрузить проекты</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<span className="text-red-600">{projectsError}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchProjects()}
|
||||||
|
className="rounded-md bg-red-600 px-3 py-1 text-white shadow hover:bg-red-700 transition"
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projectsLoading && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) ? (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-4 text-center text-gray-600 shadow-sm">
|
||||||
|
Загружаем проекты...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SortableContext items={maxPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
||||||
|
<PrioritySlot
|
||||||
|
title="Максимальный приоритет (1 проект)"
|
||||||
|
projects={maxPriority}
|
||||||
|
allProjects={allProjects}
|
||||||
|
onRemove={(name) => handleRemove(name, 'max')}
|
||||||
|
maxItems={1}
|
||||||
|
containerId="max"
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<SortableContext items={mediumPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
||||||
|
<PrioritySlot
|
||||||
|
title="Средний приоритет (2 проекта)"
|
||||||
|
projects={mediumPriority}
|
||||||
|
allProjects={allProjects}
|
||||||
|
onRemove={(name) => handleRemove(name, 'medium')}
|
||||||
|
maxItems={2}
|
||||||
|
containerId="medium"
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<SortableContext items={lowPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
||||||
|
<PrioritySlot
|
||||||
|
title="Остальные проекты"
|
||||||
|
projects={lowPriority}
|
||||||
|
allProjects={allProjects}
|
||||||
|
containerId="low"
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
{!maxPriority.length && !mediumPriority.length && !lowPriority.length && (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-gray-600">
|
||||||
|
Проекты не найдены
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{typeof document !== 'undefined'
|
||||||
|
? createPortal(
|
||||||
|
<DragOverlay>
|
||||||
|
{activeProject ? (
|
||||||
|
<div className="bg-white rounded-lg p-3 border-2 border-indigo-400 shadow-lg opacity-90">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getProjectColor(activeProject.name, allProjects) }}
|
||||||
|
></div>
|
||||||
|
<span className="font-semibold text-gray-800">{activeProject.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectPriorityManager
|
||||||
|
|
||||||
158
play-life-web/src/components/ProjectProgressBar.jsx
Normal file
158
play-life-web/src/components/ProjectProgressBar.jsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
function ProjectProgressBar({ projectName, totalScore, minGoalScore, maxGoalScore, onProjectClick, projectColor, priority }) {
|
||||||
|
// Вычисляем максимальное значение для шкалы (берем максимум из maxGoalScore и totalScore + 20%)
|
||||||
|
const maxScale = Math.max(maxGoalScore, totalScore * 1.2, 1)
|
||||||
|
|
||||||
|
// Процентные значения
|
||||||
|
const totalScorePercent = (totalScore / maxScale) * 100
|
||||||
|
const minGoalPercent = (minGoalScore / maxScale) * 100
|
||||||
|
const maxGoalPercent = (maxGoalScore / maxScale) * 100
|
||||||
|
const goalRangePercent = maxGoalPercent - minGoalPercent
|
||||||
|
|
||||||
|
const normalizedPriority = (() => {
|
||||||
|
if (priority === null || priority === undefined) return null
|
||||||
|
const numeric = Number(priority)
|
||||||
|
return Number.isFinite(numeric) ? numeric : null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const priorityBonus = (() => {
|
||||||
|
if (normalizedPriority === 1) return 50
|
||||||
|
if (normalizedPriority === 2) return 35
|
||||||
|
return 20
|
||||||
|
})()
|
||||||
|
|
||||||
|
const goalProgress = (() => {
|
||||||
|
const safeTotal = Number.isFinite(totalScore) ? totalScore : 0
|
||||||
|
const safeMinGoal = Number.isFinite(minGoalScore) ? minGoalScore : 0
|
||||||
|
const safeMaxGoal = Number.isFinite(maxGoalScore) ? maxGoalScore : 0
|
||||||
|
|
||||||
|
// Если нет валидного minGoal, возвращаем прогресс относительно maxGoal либо 0
|
||||||
|
if (safeMinGoal <= 0) {
|
||||||
|
if (safeMaxGoal > 0) {
|
||||||
|
return Math.max(0, Math.min((safeTotal / safeMaxGoal) * 100, 100))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// До достижения minGoal растем линейно от 0 до 100%
|
||||||
|
const baseProgress = Math.max(0, Math.min((safeTotal / safeMinGoal) * 100, 100))
|
||||||
|
|
||||||
|
// Если maxGoal не задан корректно или еще не достигнут minGoal, показываем базовый прогресс
|
||||||
|
if (safeTotal < safeMinGoal || safeMaxGoal <= safeMinGoal) {
|
||||||
|
return baseProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
// Между minGoal и maxGoal добавляем бонус в зависимости от приоритета
|
||||||
|
const extraRange = safeMaxGoal - safeMinGoal
|
||||||
|
const extraRatio = Math.min(1, Math.max(0, (safeTotal - safeMinGoal) / extraRange))
|
||||||
|
const extraProgress = extraRatio * priorityBonus
|
||||||
|
|
||||||
|
// Выше maxGoal прогресс не растет
|
||||||
|
return Math.min(100 + priorityBonus, 100 + extraProgress)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const isGoalReached = totalScore >= minGoalScore
|
||||||
|
const isGoalExceeded = totalScore >= maxGoalScore
|
||||||
|
|
||||||
|
const priorityBorderStyle =
|
||||||
|
normalizedPriority === 1
|
||||||
|
? { borderColor: '#d4af37' }
|
||||||
|
: normalizedPriority === 2
|
||||||
|
? { borderColor: '#c0c0c0' }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const cardBorderClasses =
|
||||||
|
normalizedPriority === 1 || normalizedPriority === 2
|
||||||
|
? 'border-2'
|
||||||
|
: 'border border-gray-200/50 hover:border-indigo-300'
|
||||||
|
|
||||||
|
const cardBaseClasses =
|
||||||
|
'bg-gradient-to-br from-white to-gray-50 rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer'
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (onProjectClick) {
|
||||||
|
onProjectClick(projectName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`${cardBaseClasses} ${cardBorderClasses}`}
|
||||||
|
style={priorityBorderStyle}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: projectColor }}
|
||||||
|
></div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">{projectName}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||||
|
{totalScore.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
из {minGoalScore.toFixed(1)} ({goalProgress.toFixed(0)}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
|
||||||
|
{/* Диапазон цели (min_goal_score до max_goal_score) */}
|
||||||
|
{minGoalScore > 0 && maxGoalScore > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-gradient-to-r from-amber-200 via-yellow-300 to-amber-200 opacity-70 border-l border-r border-amber-400"
|
||||||
|
style={{
|
||||||
|
left: `${minGoalPercent}%`,
|
||||||
|
width: `${goalRangePercent}%`,
|
||||||
|
}}
|
||||||
|
title={`Цель: ${minGoalScore.toFixed(2)} - ${maxGoalScore.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Текущее значение (total_score) */}
|
||||||
|
<div
|
||||||
|
className={`absolute h-full transition-all duration-700 ease-out ${
|
||||||
|
isGoalExceeded
|
||||||
|
? 'bg-gradient-to-r from-green-500 to-emerald-500'
|
||||||
|
: isGoalReached
|
||||||
|
? 'bg-gradient-to-r from-yellow-500 to-amber-500'
|
||||||
|
: 'bg-gradient-to-r from-indigo-500 to-purple-500'
|
||||||
|
} shadow-sm`}
|
||||||
|
style={{
|
||||||
|
width: `${totalScorePercent}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Индикатор текущего значения */}
|
||||||
|
{totalScorePercent > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full w-0.5 bg-white shadow-md"
|
||||||
|
style={{
|
||||||
|
left: `${totalScorePercent}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center text-xs mt-1.5">
|
||||||
|
<span className="text-gray-400">0</span>
|
||||||
|
{minGoalScore > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-400"></div>
|
||||||
|
<span className="text-amber-700 font-medium text-xs">
|
||||||
|
Цель: {minGoalScore.toFixed(1)} - {maxGoalScore.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-400">{maxScale.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectProgressBar
|
||||||
|
|
||||||
347
play-life-web/src/components/TestConfigSelection.css
Normal file
347
play-life-web/src/components/TestConfigSelection.css
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
.config-selection {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.config-selection {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-config-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px dashed #3498db;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 180px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-config-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
||||||
|
background-color: rgba(52, 152, 219, 0.05);
|
||||||
|
border-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-config-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #3498db;
|
||||||
|
margin-bottom: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-config-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3498db;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #e74c3c;
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card {
|
||||||
|
background: #3498db;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 180px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-menu-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-menu-button:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-words-count {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-max-cards {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: -1rem;
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: modalSlideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.config-modal-close:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-modal-edit,
|
||||||
|
.config-modal-delete {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-modal-edit {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-modal-edit:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-modal-delete {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-modal-delete:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
margin: 0.5rem 0 1rem 0;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionaries-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 180px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-card .card-menu-button {
|
||||||
|
background: transparent;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-card .card-menu-button:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-words-count {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dictionary-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px dashed #2c3e50;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 180px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dictionary-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(44, 62, 80, 0.2);
|
||||||
|
background-color: rgba(44, 62, 80, 0.05);
|
||||||
|
border-color: #1a252f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dictionary-button .add-config-icon {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dictionary-button .add-config-text {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
278
play-life-web/src/components/TestConfigSelection.jsx
Normal file
278
play-life-web/src/components/TestConfigSelection.jsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import './TestConfigSelection.css'
|
||||||
|
|
||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
|
function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
||||||
|
const [configs, setConfigs] = useState([])
|
||||||
|
const [dictionaries, setDictionaries] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [selectedConfig, setSelectedConfig] = useState(null)
|
||||||
|
const [selectedDictionary, setSelectedDictionary] = useState(null)
|
||||||
|
const [longPressTimer, setLongPressTimer] = useState(null)
|
||||||
|
const isInitializedRef = useRef(false)
|
||||||
|
const configsRef = useRef([])
|
||||||
|
const dictionariesRef = useRef([])
|
||||||
|
|
||||||
|
// Обновляем ref при изменении состояния
|
||||||
|
useEffect(() => {
|
||||||
|
configsRef.current = configs
|
||||||
|
}, [configs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dictionariesRef.current = dictionaries
|
||||||
|
}, [dictionaries])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTestConfigsAndDictionaries()
|
||||||
|
}, [refreshTrigger])
|
||||||
|
|
||||||
|
|
||||||
|
const fetchTestConfigsAndDictionaries = async () => {
|
||||||
|
try {
|
||||||
|
// Показываем загрузку только при первой инициализации или если нет данных для отображения
|
||||||
|
const isFirstLoad = !isInitializedRef.current
|
||||||
|
const hasData = !isFirstLoad && (configsRef.current.length > 0 || dictionariesRef.current.length > 0)
|
||||||
|
if (!hasData) {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке конфигураций и словарей')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setConfigs(Array.isArray(data.configs) ? data.configs : [])
|
||||||
|
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||||
|
setError('')
|
||||||
|
isInitializedRef.current = true
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setConfigs([])
|
||||||
|
setDictionaries([])
|
||||||
|
isInitializedRef.current = true
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfigSelect = (config) => {
|
||||||
|
onNavigate?.('test', {
|
||||||
|
wordCount: config.words_count,
|
||||||
|
configId: config.id,
|
||||||
|
maxCards: config.max_cards || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDictionarySelect = (dict) => {
|
||||||
|
// For now, navigate to words list
|
||||||
|
// In the future, we might want to filter by dictionary_id
|
||||||
|
onNavigate?.('words', { dictionaryId: dict.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfigMenuClick = (config, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedConfig(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDictionaryMenuClick = (dict, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedDictionary(dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (selectedConfig) {
|
||||||
|
onNavigate?.('add-config', { config: selectedConfig })
|
||||||
|
setSelectedConfig(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDictionaryDelete = async () => {
|
||||||
|
if (!selectedDictionary) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('Delete error:', response.status, errorText)
|
||||||
|
throw new Error(`Ошибка при удалении словаря: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDictionary(null)
|
||||||
|
// Refresh dictionaries list
|
||||||
|
await fetchTestConfigsAndDictionaries()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err)
|
||||||
|
setError(err.message)
|
||||||
|
setSelectedDictionary(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedConfig) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/configs/${selectedConfig.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('Delete error:', response.status, errorText)
|
||||||
|
throw new Error(`Ошибка при удалении конфигурации: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedConfig(null)
|
||||||
|
// Refresh configs and dictionaries list
|
||||||
|
await fetchTestConfigsAndDictionaries()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err)
|
||||||
|
setError(err.message)
|
||||||
|
setSelectedConfig(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setSelectedConfig(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем загрузку только при первой инициализации и если нет данных для отображения
|
||||||
|
const shouldShowLoading = loading && !isInitializedRef.current && configs.length === 0 && dictionaries.length === 0
|
||||||
|
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
return (
|
||||||
|
<div className="config-selection">
|
||||||
|
<div className="loading">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="config-selection">
|
||||||
|
<div className="error-message">{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="config-selection">
|
||||||
|
{/* Секция тестов */}
|
||||||
|
<div className="section-divider">
|
||||||
|
<h2 className="section-title">Тесты</h2>
|
||||||
|
</div>
|
||||||
|
<div className="configs-grid">
|
||||||
|
{configs.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className="config-card"
|
||||||
|
onClick={() => handleConfigSelect(config)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleConfigMenuClick(config, e)}
|
||||||
|
className="card-menu-button"
|
||||||
|
title="Меню"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
<div className="config-words-count">
|
||||||
|
{config.words_count}
|
||||||
|
</div>
|
||||||
|
{config.max_cards && (
|
||||||
|
<div className="config-max-cards">
|
||||||
|
{config.max_cards}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="config-name">{config.name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={() => onNavigate?.('add-config')} className="add-config-button">
|
||||||
|
<div className="add-config-icon">+</div>
|
||||||
|
<div className="add-config-text">Добавить</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Секция словарей */}
|
||||||
|
<div className="dictionaries-section">
|
||||||
|
<div className="section-divider">
|
||||||
|
<h2 className="section-title">Словари</h2>
|
||||||
|
</div>
|
||||||
|
<div className="configs-grid">
|
||||||
|
{dictionaries.map((dict) => (
|
||||||
|
<div
|
||||||
|
key={dict.id}
|
||||||
|
className="dictionary-card"
|
||||||
|
onClick={() => handleDictionarySelect(dict)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDictionaryMenuClick(dict, e)}
|
||||||
|
className="card-menu-button"
|
||||||
|
title="Меню"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
<div className="dictionary-words-count">
|
||||||
|
{dict.wordsCount}
|
||||||
|
</div>
|
||||||
|
<div className="dictionary-name">{dict.name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate?.('words', { isNewDictionary: true })}
|
||||||
|
className="add-dictionary-button"
|
||||||
|
>
|
||||||
|
<div className="add-config-icon">+</div>
|
||||||
|
<div className="add-config-text">Добавить</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{selectedConfig && (
|
||||||
|
<div className="config-modal-overlay" onClick={closeModal}>
|
||||||
|
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="config-modal-header">
|
||||||
|
<h3>{selectedConfig.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="config-modal-actions">
|
||||||
|
<button className="config-modal-edit" onClick={handleEdit}>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button className="config-modal-delete" onClick={handleDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedDictionary && (
|
||||||
|
<div className="config-modal-overlay" onClick={() => setSelectedDictionary(null)}>
|
||||||
|
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="config-modal-header">
|
||||||
|
<h3>{selectedDictionary.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="config-modal-actions">
|
||||||
|
<button className="config-modal-delete" onClick={handleDictionaryDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TestConfigSelection
|
||||||
|
|
||||||
485
play-life-web/src/components/TestWords.css
Normal file
485
play-life-web/src/components/TestWords.css
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
.test-container {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-container-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
z-index: 1500;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-close-x-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
z-index: 1600;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-close-x-button:hover {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-duration-selection {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-duration-selection h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-button {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, transform 0.1s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-button:hover:not(:disabled) {
|
||||||
|
background-color: #2980b9;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-button:disabled {
|
||||||
|
background-color: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-label {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-count {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-loading {
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-progress {
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 300px;
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
height: 500px;
|
||||||
|
position: relative;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.6s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card.flipped {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card-front,
|
||||||
|
.test-card-back {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card-front {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card-back {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
color: white;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card-content {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-word {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-translation {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card-hint {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 2rem;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-action-button {
|
||||||
|
padding: 1.25rem 2.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, opacity 0.2s;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-action-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-action-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-button {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-button:hover {
|
||||||
|
background-color: #229954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-button {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure-button:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-stats {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 4rem 1rem 1rem 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fafafa;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:last-child {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-word-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-word-header {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-word {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-translation {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #34495e;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stat-success {
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stat-separator {
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stat-failure {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stats {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 4rem 1rem 1rem 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fafafa;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-word-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-word-header {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-word {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-translation {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #34495e;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stats-numbers {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stat-success {
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stat-separator {
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stat-failure {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-start-button {
|
||||||
|
padding: 1.25rem 3rem;
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, background-color 0.2s;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-start-button:hover {
|
||||||
|
background-color: #229954;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-start-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-finish-button {
|
||||||
|
padding: 1.25rem 3rem;
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, background-color 0.2s;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-finish-button:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-finish-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
490
play-life-web/src/components/TestWords.jsx
Normal file
490
play-life-web/src/components/TestWords.jsx
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import './TestWords.css'
|
||||||
|
|
||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
|
const DEFAULT_TEST_WORD_COUNT = 10
|
||||||
|
|
||||||
|
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
|
||||||
|
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||||
|
const configId = initialConfigId || null
|
||||||
|
const maxCards = initialMaxCards || null
|
||||||
|
|
||||||
|
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
||||||
|
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
const [flippedCards, setFlippedCards] = useState(new Set())
|
||||||
|
const [wordStats, setWordStats] = useState({}) // Локальная статистика
|
||||||
|
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
|
||||||
|
const [totalAnswers, setTotalAnswers] = useState(0) // Кол-во полученных ответов
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
|
||||||
|
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
|
||||||
|
|
||||||
|
const isFinishingRef = useRef(false)
|
||||||
|
const wordStatsRef = useRef({})
|
||||||
|
const processingRef = useRef(false)
|
||||||
|
|
||||||
|
// Загрузка слов при монтировании
|
||||||
|
useEffect(() => {
|
||||||
|
setWords([])
|
||||||
|
setTestWords([])
|
||||||
|
setCurrentIndex(0)
|
||||||
|
setFlippedCards(new Set())
|
||||||
|
setWordStats({})
|
||||||
|
wordStatsRef.current = {}
|
||||||
|
setCardsShown(0)
|
||||||
|
setTotalAnswers(0)
|
||||||
|
setError('')
|
||||||
|
setShowPreview(false) // Сбрасываем экран предпросмотра
|
||||||
|
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
|
||||||
|
isFinishingRef.current = false
|
||||||
|
processingRef.current = false
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const loadWords = async () => {
|
||||||
|
try {
|
||||||
|
if (configId === null) {
|
||||||
|
throw new Error('config_id обязателен для запуска теста')
|
||||||
|
}
|
||||||
|
const url = `${API_URL}/test/words?config_id=${configId}`
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке слов')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
throw new Error('Недостаточно слов для теста')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализируем статистику из данных бэкенда
|
||||||
|
const stats = {}
|
||||||
|
data.forEach(word => {
|
||||||
|
stats[word.id] = {
|
||||||
|
success: word.success || 0,
|
||||||
|
failure: word.failure || 0,
|
||||||
|
lastSuccessAt: word.last_success_at || null,
|
||||||
|
lastFailureAt: word.last_failure_at || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setWords(data)
|
||||||
|
|
||||||
|
// Формируем пул слов: каждое слово добавляется n раз, затем пул перемешивается
|
||||||
|
// n = max(1, floor(0.7 * maxCards / количество_слов))
|
||||||
|
const wordsCount = data.length
|
||||||
|
const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount
|
||||||
|
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
||||||
|
|
||||||
|
// Создаем пул, где каждое слово повторяется n раз
|
||||||
|
const wordPool = []
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
wordPool.push(...data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перемешиваем пул случайным образом
|
||||||
|
for (let i = wordPool.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[wordPool[i], wordPool[j]] = [wordPool[j], wordPool[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestWords(wordPool)
|
||||||
|
setWordStats(stats)
|
||||||
|
wordStatsRef.current = stats
|
||||||
|
|
||||||
|
// Показываем экран предпросмотра
|
||||||
|
setShowPreview(true)
|
||||||
|
|
||||||
|
// Показываем первую карточку и увеличиваем левый счётчик (будет использовано после начала теста)
|
||||||
|
setCardsShown(0)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadWords()
|
||||||
|
}, [wordCount, configId])
|
||||||
|
|
||||||
|
const getCurrentWord = () => {
|
||||||
|
if (currentIndex < testWords.length && testWords.length > 0) {
|
||||||
|
return testWords[currentIndex]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правый счётчик: кол-во полученных ответов + кол-во слов в пуле (не больше maxCards)
|
||||||
|
const getRightCounter = () => {
|
||||||
|
const total = totalAnswers + testWords.length
|
||||||
|
if (maxCards !== null && maxCards > 0) {
|
||||||
|
return Math.min(total, maxCards)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardFlip = (wordId) => {
|
||||||
|
setFlippedCards(prev => new Set(prev).add(wordId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завершение теста
|
||||||
|
const finishTest = async () => {
|
||||||
|
if (isFinishingRef.current) return
|
||||||
|
isFinishingRef.current = true
|
||||||
|
|
||||||
|
// Сразу показываем экран результатов, чтобы предотвратить показ новых карточек
|
||||||
|
setShowResults(true)
|
||||||
|
|
||||||
|
// Отправляем статистику на бэкенд
|
||||||
|
try {
|
||||||
|
// Получаем актуальные данные из состояния
|
||||||
|
const currentStats = wordStatsRef.current
|
||||||
|
|
||||||
|
// Отправляем все слова, которые были в тесте, с их текущими значениями
|
||||||
|
// Бэкенд сам обновит только измененные поля
|
||||||
|
const updates = words.map(word => {
|
||||||
|
const stats = currentStats[word.id] || {
|
||||||
|
success: word.success || 0,
|
||||||
|
failure: word.failure || 0,
|
||||||
|
lastSuccessAt: word.last_success_at || null,
|
||||||
|
lastFailureAt: word.last_failure_at || null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: word.id,
|
||||||
|
success: stats.success || 0,
|
||||||
|
failure: stats.failure || 0,
|
||||||
|
last_success_at: stats.lastSuccessAt || null,
|
||||||
|
last_failure_at: stats.lastFailureAt || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
console.log('No words to send - empty test')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = { words: updates }
|
||||||
|
if (configId !== null) {
|
||||||
|
requestBody.config_id = configId
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Sending test progress to backend:', {
|
||||||
|
wordsCount: updates.length,
|
||||||
|
configId: configId,
|
||||||
|
requestBody
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/test/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`Server responded with status ${response.status}: ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json().catch(() => ({}))
|
||||||
|
console.log('Test progress saved successfully:', responseData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save progress:', err)
|
||||||
|
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка условий завершения и показ следующей карточки
|
||||||
|
const showNextCardOrFinish = (newTestWords, currentCardsShown) => {
|
||||||
|
// Проверяем, не завершился ли тест (на случай если finishTest уже был вызван)
|
||||||
|
if (isFinishingRef.current || showResults) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Условие 1: Достигли максимума карточек
|
||||||
|
if (maxCards !== null && maxCards > 0 && currentCardsShown >= maxCards) {
|
||||||
|
finishTest()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Условие 2: Пул слов пуст
|
||||||
|
if (newTestWords.length === 0) {
|
||||||
|
finishTest()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем следующую карточку и увеличиваем левый счётчик
|
||||||
|
// Но сначала проверяем, не достигнем ли мы максимума после увеличения
|
||||||
|
const nextCardsShown = currentCardsShown + 1
|
||||||
|
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
||||||
|
finishTest()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCardsShown(nextCardsShown)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSuccess = (wordId) => {
|
||||||
|
if (processingRef.current || isFinishingRef.current || showResults) return
|
||||||
|
processingRef.current = true
|
||||||
|
|
||||||
|
const word = words.find(w => w.id === wordId)
|
||||||
|
if (!word) {
|
||||||
|
processingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// Обновляем статистику: success + 1, lastSuccessAt = now
|
||||||
|
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
|
||||||
|
const updatedStats = {
|
||||||
|
...wordStatsRef.current,
|
||||||
|
[wordId]: {
|
||||||
|
success: (currentWordStats.success || 0) + 1,
|
||||||
|
failure: currentWordStats.failure || 0,
|
||||||
|
lastSuccessAt: now,
|
||||||
|
lastFailureAt: currentWordStats.lastFailureAt || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordStatsRef.current = updatedStats
|
||||||
|
setWordStats(updatedStats)
|
||||||
|
|
||||||
|
// Увеличиваем счётчик ответов
|
||||||
|
const newTotalAnswers = totalAnswers + 1
|
||||||
|
setTotalAnswers(newTotalAnswers)
|
||||||
|
|
||||||
|
// Убираем только один экземпляр слова из пула (по текущему индексу)
|
||||||
|
const newTestWords = [...testWords]
|
||||||
|
newTestWords.splice(currentIndex, 1)
|
||||||
|
|
||||||
|
// Обновляем индекс: если удалили последний элемент, переходим к предыдущему
|
||||||
|
let newIndex = currentIndex
|
||||||
|
if (newTestWords.length === 0) {
|
||||||
|
newIndex = 0
|
||||||
|
} else if (currentIndex >= newTestWords.length) {
|
||||||
|
newIndex = newTestWords.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем состояние
|
||||||
|
setTestWords(newTestWords)
|
||||||
|
setCurrentIndex(newIndex)
|
||||||
|
setFlippedCards(new Set())
|
||||||
|
|
||||||
|
// Проверяем условия завершения или показываем следующую карточку
|
||||||
|
showNextCardOrFinish(newTestWords, cardsShown)
|
||||||
|
|
||||||
|
// Если тест завершился, не сбрасываем processingRef
|
||||||
|
if (isFinishingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processingRef.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFailure = (wordId) => {
|
||||||
|
if (processingRef.current || isFinishingRef.current || showResults) return
|
||||||
|
processingRef.current = true
|
||||||
|
|
||||||
|
const word = words.find(w => w.id === wordId)
|
||||||
|
if (!word) {
|
||||||
|
processingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// Обновляем статистику: failure + 1, lastFailureAt = now
|
||||||
|
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
|
||||||
|
const updatedStats = {
|
||||||
|
...wordStatsRef.current,
|
||||||
|
[wordId]: {
|
||||||
|
success: currentWordStats.success || 0,
|
||||||
|
failure: (currentWordStats.failure || 0) + 1,
|
||||||
|
lastSuccessAt: currentWordStats.lastSuccessAt || null,
|
||||||
|
lastFailureAt: now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordStatsRef.current = updatedStats
|
||||||
|
setWordStats(updatedStats)
|
||||||
|
|
||||||
|
// Увеличиваем счётчик ответов
|
||||||
|
const newTotalAnswers = totalAnswers + 1
|
||||||
|
setTotalAnswers(newTotalAnswers)
|
||||||
|
|
||||||
|
// Слово остаётся в пуле, переходим к следующему
|
||||||
|
let newIndex = currentIndex + 1
|
||||||
|
if (newIndex >= testWords.length) {
|
||||||
|
newIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentIndex(newIndex)
|
||||||
|
setFlippedCards(new Set())
|
||||||
|
|
||||||
|
// Проверяем условия завершения или показываем следующую карточку
|
||||||
|
// При failure пул не изменяется
|
||||||
|
showNextCardOrFinish(testWords, cardsShown)
|
||||||
|
|
||||||
|
// Если тест завершился, не сбрасываем processingRef
|
||||||
|
if (isFinishingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processingRef.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onNavigate?.('test-config')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartTest = () => {
|
||||||
|
setShowPreview(false)
|
||||||
|
// Показываем первую карточку и увеличиваем левый счётчик
|
||||||
|
setCardsShown(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFinish = () => {
|
||||||
|
onNavigate?.('test-config')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRandomSide = (word) => {
|
||||||
|
return word.id % 2 === 0 ? 'word' : 'translation'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="test-container test-container-fullscreen">
|
||||||
|
<button className="test-close-x-button" onClick={handleClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{showPreview ? (
|
||||||
|
<div className="test-preview">
|
||||||
|
<div className="preview-stats">
|
||||||
|
{words.map((word) => {
|
||||||
|
const stats = wordStats[word.id] || { success: 0, failure: 0 }
|
||||||
|
return (
|
||||||
|
<div key={word.id} className="preview-item">
|
||||||
|
<div className="preview-word-content">
|
||||||
|
<div className="preview-word-header">
|
||||||
|
<h3 className="preview-word">{word.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="preview-translation">{word.translation}</div>
|
||||||
|
</div>
|
||||||
|
<div className="preview-stats-numbers">
|
||||||
|
<span className="preview-stat-success">{stats.success}</span>
|
||||||
|
<span className="preview-stat-separator"> | </span>
|
||||||
|
<span className="preview-stat-failure">{stats.failure}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="preview-actions">
|
||||||
|
<button className="test-start-button" onClick={handleStartTest}>
|
||||||
|
Начать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : showResults ? (
|
||||||
|
<div className="test-results">
|
||||||
|
<div className="results-stats">
|
||||||
|
{words.map((word) => {
|
||||||
|
const stats = wordStats[word.id] || { success: 0, failure: 0 }
|
||||||
|
return (
|
||||||
|
<div key={word.id} className="result-item">
|
||||||
|
<div className="result-word-content">
|
||||||
|
<div className="result-word-header">
|
||||||
|
<h3 className="result-word">{word.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="result-translation">{word.translation}</div>
|
||||||
|
</div>
|
||||||
|
<div className="result-stats">
|
||||||
|
<span className="result-stat-success">{stats.success}</span>
|
||||||
|
<span className="result-stat-separator"> | </span>
|
||||||
|
<span className="result-stat-failure">{stats.failure}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="results-actions">
|
||||||
|
<button className="test-finish-button" onClick={handleFinish}>
|
||||||
|
Закончить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="test-screen">
|
||||||
|
{loading && (
|
||||||
|
<div className="test-loading">Загрузка слов...</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="test-error">{error}</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && !isFinishingRef.current && getCurrentWord() && (() => {
|
||||||
|
const word = getCurrentWord()
|
||||||
|
const isFlipped = flippedCards.has(word.id)
|
||||||
|
const showSide = getRandomSide(word)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="test-card-container" key={word.id}>
|
||||||
|
<div
|
||||||
|
className={`test-card ${isFlipped ? 'flipped' : ''}`}
|
||||||
|
onClick={() => !isFlipped && handleCardFlip(word.id)}
|
||||||
|
>
|
||||||
|
<div className="test-card-front">
|
||||||
|
<div className="test-card-content">
|
||||||
|
{showSide === 'word' ? (
|
||||||
|
<div className="test-word">{word.name}</div>
|
||||||
|
) : (
|
||||||
|
<div className="test-translation">{word.translation}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="test-card-back">
|
||||||
|
<div className="test-card-content">
|
||||||
|
{showSide === 'word' ? (
|
||||||
|
<div className="test-translation">{word.translation}</div>
|
||||||
|
) : (
|
||||||
|
<div className="test-word">{word.name}</div>
|
||||||
|
)}
|
||||||
|
<div className="test-card-actions">
|
||||||
|
<button
|
||||||
|
className="test-action-button success-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleSuccess(word.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ Знаю
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="test-action-button failure-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleFailure(word.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✗ Не знаю
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="test-progress">
|
||||||
|
<div className="progress-text">
|
||||||
|
{cardsShown} / {getRightCounter()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TestWords
|
||||||
160
play-life-web/src/components/WeekProgressChart.jsx
Normal file
160
play-life-web/src/components/WeekProgressChart.jsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
|
||||||
|
|
||||||
|
const formatWeekKey = ({ year, week }) => `${year}-W${week.toString().padStart(2, '0')}`
|
||||||
|
|
||||||
|
const parseWeekKey = (weekKey) => {
|
||||||
|
const [yearStr, weekStr] = weekKey.split('-W')
|
||||||
|
return { year: Number(yearStr), week: Number(weekStr) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareWeekKeys = (a, b) => {
|
||||||
|
const [yearA, weekA] = a.split('-W').map(Number)
|
||||||
|
const [yearB, weekB] = b.split('-W').map(Number)
|
||||||
|
|
||||||
|
if (yearA !== yearB) {
|
||||||
|
return yearA - yearB
|
||||||
|
}
|
||||||
|
return weekA - weekB
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedProject }) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем данные по неделям
|
||||||
|
const weeksMap = {}
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
// Фильтруем по выбранному проекту, если он указан
|
||||||
|
if (selectedProject && item.project_name !== selectedProject) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
|
||||||
|
|
||||||
|
if (!weeksMap[weekKey]) {
|
||||||
|
weeksMap[weekKey] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
weeksMap[weekKey].push({
|
||||||
|
projectName: item.project_name,
|
||||||
|
score: parseFloat(item.total_score) || 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем все уникальные недели и сортируем их (новые сверху)
|
||||||
|
const allWeeks = Object.keys(weeksMap).sort((a, b) => -compareWeekKeys(a, b))
|
||||||
|
|
||||||
|
// Берем первые 4 недели (самые актуальные)
|
||||||
|
const last4Weeks = allWeeks.slice(0, 4)
|
||||||
|
|
||||||
|
// Используем переданный отсортированный список проектов или получаем из данных
|
||||||
|
const allProjects = allProjectsSorted || (() => {
|
||||||
|
const allProjectsSet = new Set()
|
||||||
|
data.forEach(item => {
|
||||||
|
allProjectsSet.add(item.project_name)
|
||||||
|
})
|
||||||
|
return Array.from(allProjectsSet).sort()
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Обрабатываем данные для каждой недели
|
||||||
|
const weeksData = last4Weeks.map(weekKey => {
|
||||||
|
const weekProjects = weeksMap[weekKey]
|
||||||
|
const totalScore = weekProjects.reduce((sum, p) => sum + p.score, 0)
|
||||||
|
|
||||||
|
// Используем абсолютные значения (баллы)
|
||||||
|
// Сортируем проекты так же, как в полной статистике (по priority и min_goal_score)
|
||||||
|
const projectsWithData = weekProjects.map(project => {
|
||||||
|
const color = getProjectColor(project.projectName, allProjects)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
color
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Применяем ту же сортировку, что и в FullStatistics
|
||||||
|
const projectNames = projectsWithData.map(p => p.projectName)
|
||||||
|
const sortedProjectNames = currentWeekData
|
||||||
|
? sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
|
||||||
|
: projectNames
|
||||||
|
|
||||||
|
// Пересобираем projectsWithData в правильном порядке
|
||||||
|
const sortedProjectsWithData = sortedProjectNames.map(name => {
|
||||||
|
return projectsWithData.find(p => p.projectName === name)
|
||||||
|
}).filter(Boolean)
|
||||||
|
|
||||||
|
const { year, week } = parseWeekKey(weekKey)
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekKey,
|
||||||
|
year,
|
||||||
|
week,
|
||||||
|
projects: sortedProjectsWithData,
|
||||||
|
totalScore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Находим максимальное значение среди всех недель для единой шкалы сравнения
|
||||||
|
const maxTotalScore = Math.max(...weeksData.map(w => w.totalScore), 1)
|
||||||
|
|
||||||
|
if (weeksData.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">Последние 4 недели</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{weeksData.map((weekData) => (
|
||||||
|
<div key={weekData.weekKey} className="flex items-center gap-3">
|
||||||
|
<div className="min-w-[100px] text-sm font-medium text-gray-700">
|
||||||
|
Неделя {weekData.week}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
|
||||||
|
{weekData.totalScore === 0 ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-xs">
|
||||||
|
Нет данных
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{weekData.projects.map((project, index) => {
|
||||||
|
// Вычисляем позицию и ширину для каждого сегмента на основе абсолютных значений
|
||||||
|
let left = 0
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
left += weekData.projects[i].score
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthPercent = (project.score / maxTotalScore) * 100
|
||||||
|
const leftPercent = (left / maxTotalScore) * 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.projectName}
|
||||||
|
className="absolute h-full transition-all duration-300 hover:opacity-90"
|
||||||
|
style={{
|
||||||
|
left: `${leftPercent}%`,
|
||||||
|
width: `${widthPercent}%`,
|
||||||
|
backgroundColor: project.color,
|
||||||
|
}}
|
||||||
|
title={`${project.projectName}: ${project.score.toFixed(1)} баллов`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[60px] text-right text-sm text-gray-600 font-medium">
|
||||||
|
{weekData.totalScore > 0 ? `${weekData.totalScore.toFixed(1)}` : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WeekProgressChart
|
||||||
|
|
||||||
248
play-life-web/src/components/WordList.css
Normal file
248
play-life-web/src/components/WordList.css
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
.word-list {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-x-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s, color 0.2s;
|
||||||
|
z-index: 1600;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-x-button:hover {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-list h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #3498db;
|
||||||
|
border: 2px solid #3498db;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:hover {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #e74c3c;
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fafafa;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-header {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-translation {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #34495e;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-description {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-success {
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-separator {
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-failure {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-name-input-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-right: 60px; /* Space for close button */
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-name-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-name-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-bottom-color: #3498db;
|
||||||
|
border-bottom-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-name-input::placeholder {
|
||||||
|
color: #95a5a6;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-name-save-button {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 6px rgba(39, 174, 96, 0.25);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-name-save-button:hover:not(:disabled) {
|
||||||
|
background-color: #229954;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-name-save-button:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-name-save-button:disabled {
|
||||||
|
background-color: #95a5a6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 4rem;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 1500;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button:hover {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #3498db;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
246
play-life-web/src/components/WordList.jsx
Normal file
246
play-life-web/src/components/WordList.jsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import './WordList.css'
|
||||||
|
|
||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
|
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
|
||||||
|
const [words, setWords] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [dictionary, setDictionary] = useState(null)
|
||||||
|
const [dictionaryName, setDictionaryName] = useState('')
|
||||||
|
const [originalDictionaryName, setOriginalDictionaryName] = useState('')
|
||||||
|
const [isSavingName, setIsSavingName] = useState(false)
|
||||||
|
const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId)
|
||||||
|
const [isNewDict, setIsNewDict] = useState(isNewDictionary)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentDictionaryId(dictionaryId)
|
||||||
|
setIsNewDict(isNewDictionary)
|
||||||
|
|
||||||
|
if (isNewDictionary) {
|
||||||
|
setLoading(false)
|
||||||
|
setDictionary(null)
|
||||||
|
setDictionaryName('')
|
||||||
|
setOriginalDictionaryName('')
|
||||||
|
setWords([])
|
||||||
|
} else if (dictionaryId !== undefined && dictionaryId !== null) {
|
||||||
|
fetchDictionary()
|
||||||
|
fetchWords()
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
setWords([])
|
||||||
|
}
|
||||||
|
}, [dictionaryId, isNewDictionary, refreshTrigger])
|
||||||
|
|
||||||
|
const fetchDictionary = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/dictionaries`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке словарей')
|
||||||
|
}
|
||||||
|
const dictionaries = await response.json()
|
||||||
|
const dict = dictionaries.find(d => d.id === dictionaryId)
|
||||||
|
if (dict) {
|
||||||
|
setDictionary(dict)
|
||||||
|
setDictionaryName(dict.name)
|
||||||
|
setOriginalDictionaryName(dict.name)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching dictionary:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWords = async () => {
|
||||||
|
if (isNewDictionary || dictionaryId === undefined || dictionaryId === null) {
|
||||||
|
setWords([])
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchWordsForDictionary(dictionaryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWordsForDictionary = async (dictId) => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const url = `${API_URL}/words?dictionary_id=${dictId}`
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке слов')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setWords(Array.isArray(data) ? data : [])
|
||||||
|
setError('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setWords([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNameChange = (e) => {
|
||||||
|
setDictionaryName(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNameSave = async () => {
|
||||||
|
if (!dictionaryName.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingName(true)
|
||||||
|
try {
|
||||||
|
if (isNewDictionary) {
|
||||||
|
// Create new dictionary
|
||||||
|
const response = await fetch(`${API_URL}/dictionaries`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: dictionaryName.trim() }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при создании словаря')
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDict = await response.json()
|
||||||
|
const newDictionaryId = newDict.id
|
||||||
|
|
||||||
|
// Update local state immediately
|
||||||
|
setOriginalDictionaryName(newDict.name)
|
||||||
|
setDictionaryName(newDict.name)
|
||||||
|
setDictionary(newDict)
|
||||||
|
setCurrentDictionaryId(newDictionaryId)
|
||||||
|
setIsNewDict(false)
|
||||||
|
|
||||||
|
// Fetch words for the new dictionary
|
||||||
|
await fetchWordsForDictionary(newDictionaryId)
|
||||||
|
|
||||||
|
// Update navigation to use the new dictionary ID and remove isNewDictionary flag
|
||||||
|
onNavigate?.('words', { dictionaryId: newDictionaryId, isNewDictionary: false })
|
||||||
|
} else if (dictionaryId !== undefined && dictionaryId !== null) {
|
||||||
|
// Update existing dictionary
|
||||||
|
const response = await fetch(`${API_URL}/dictionaries/${dictionaryId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: dictionaryName.trim() }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при обновлении словаря')
|
||||||
|
}
|
||||||
|
|
||||||
|
setOriginalDictionaryName(dictionaryName.trim())
|
||||||
|
if (dictionary) {
|
||||||
|
setDictionary({ ...dictionary, name: dictionaryName.trim() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setIsSavingName(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSaveButton = dictionaryName.trim() !== '' && dictionaryName.trim() !== originalDictionaryName
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="word-list">
|
||||||
|
<div className="loading">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="word-list">
|
||||||
|
<div className="error-message">{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="word-list">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate?.('test-config')}
|
||||||
|
className="close-x-button"
|
||||||
|
title="Закрыть"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dictionary name input */}
|
||||||
|
<div className="dictionary-name-input-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="dictionary-name-input"
|
||||||
|
value={dictionaryName}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
placeholder="Введите название словаря"
|
||||||
|
/>
|
||||||
|
{showSaveButton && (
|
||||||
|
<button
|
||||||
|
className="dictionary-name-save-button"
|
||||||
|
onClick={handleNameSave}
|
||||||
|
disabled={isSavingName}
|
||||||
|
title="Сохранить название"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show add button and words list:
|
||||||
|
- If dictionary exists (has dictionaryId), show regardless of name
|
||||||
|
- If new dictionary (no dictionaryId), show only if name is set */}
|
||||||
|
{((currentDictionaryId !== undefined && currentDictionaryId !== null && !isNewDict) || (isNewDict && dictionaryName.trim())) && (
|
||||||
|
<>
|
||||||
|
{(!words || words.length === 0) ? (
|
||||||
|
<>
|
||||||
|
<button onClick={() => onNavigate?.('add-words', { dictionaryId: currentDictionaryId, dictionaryName })} className="add-button">
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>Слов пока нет. Добавьте слова через экран "Добавить слова".</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => onNavigate?.('add-words', { dictionaryId: currentDictionaryId, dictionaryName })} className="add-button">
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
<div className="words-grid">
|
||||||
|
{words.map((word) => (
|
||||||
|
<div key={word.id} className="word-card">
|
||||||
|
<div className="word-content">
|
||||||
|
<div className="word-header">
|
||||||
|
<h3 className="word-name">{word.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="word-translation">{word.translation}</div>
|
||||||
|
{word.description && (
|
||||||
|
<div className="word-description">{word.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="word-stats">
|
||||||
|
<span className="stat-success">{word.success || 0}</span>
|
||||||
|
<span className="stat-separator"> | </span>
|
||||||
|
<span className="stat-failure">{word.failure || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WordList
|
||||||
|
|
||||||
48
play-life-web/src/index.css
Normal file
48
play-life-web/src/index.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* Dynamic viewport height для мобильных устройств */
|
||||||
|
background: #f3f4f6;
|
||||||
|
background-attachment: fixed;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
11
play-life-web/src/main.jsx
Normal file
11
play-life-web/src/main.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
104
play-life-web/src/utils/projectUtils.js
Normal file
104
play-life-web/src/utils/projectUtils.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Утилиты для работы с проектами - обеспечивают единую сортировку и цвета
|
||||||
|
|
||||||
|
// Функция для генерации цвета проекта на основе его индекса в отсортированном списке
|
||||||
|
export function getProjectColorByIndex(index) {
|
||||||
|
const hue = (index * 137.508) % 360 // Золотой угол для равномерного распределения цветов
|
||||||
|
return `hsl(${hue}, 70%, 50%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает отсортированный список всех проектов для определения цветов
|
||||||
|
* Сортировка: по алфавиту (стабильный порядок для цветов)
|
||||||
|
*
|
||||||
|
* @param {Array} allProjectsData - данные полной статистики (массив объектов с project_name)
|
||||||
|
* @param {Array} currentWeekData - данные текущей недели (массив объектов с project_name)
|
||||||
|
* @returns {Array} отсортированный массив названий проектов
|
||||||
|
*/
|
||||||
|
export function getAllProjectsSorted(allProjectsData, currentWeekData = null) {
|
||||||
|
const projectsSet = new Set()
|
||||||
|
|
||||||
|
// Собираем проекты из полной статистики (приоритетный источник)
|
||||||
|
if (allProjectsData && allProjectsData.length > 0) {
|
||||||
|
allProjectsData.forEach(item => {
|
||||||
|
projectsSet.add(item.project_name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если данных полной статистики нет, используем проекты из текущей недели
|
||||||
|
if (projectsSet.size === 0 && currentWeekData) {
|
||||||
|
const projects = Array.isArray(currentWeekData)
|
||||||
|
? currentWeekData
|
||||||
|
: (currentWeekData?.projects || [])
|
||||||
|
|
||||||
|
projects.forEach(item => {
|
||||||
|
projectsSet.add(item.project_name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(projectsSet).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает цвет проекта на основе его названия
|
||||||
|
*
|
||||||
|
* @param {string} projectName - название проекта
|
||||||
|
* @param {Array} allProjectsSorted - отсортированный список всех проектов
|
||||||
|
* @returns {string} цвет в формате HSL
|
||||||
|
*/
|
||||||
|
export function getProjectColor(projectName, allProjectsSorted) {
|
||||||
|
const projectIndex = allProjectsSorted.indexOf(projectName)
|
||||||
|
return projectIndex >= 0 ? getProjectColorByIndex(projectIndex) : '#9CA3AF'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нормализует значение priority для сортировки
|
||||||
|
*/
|
||||||
|
function normalizePriority(value) {
|
||||||
|
if (value === null || value === undefined) return Infinity
|
||||||
|
const numeric = Number(value)
|
||||||
|
return Number.isFinite(numeric) ? numeric : Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сортирует проекты так же, как на экране списка проектов:
|
||||||
|
* сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
|
||||||
|
*
|
||||||
|
* @param {Array} projectNames - массив названий проектов для сортировки
|
||||||
|
* @param {Array} currentWeekData - данные текущей недели с информацией о priority и min_goal_score
|
||||||
|
* @returns {Array} отсортированный массив названий проектов
|
||||||
|
*/
|
||||||
|
export function sortProjectsLikeCurrentWeek(projectNames, currentWeekData) {
|
||||||
|
if (!currentWeekData || projectNames.length === 0) {
|
||||||
|
return projectNames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем данные проектов из currentWeekData
|
||||||
|
const projectsData = currentWeekData?.projects || (Array.isArray(currentWeekData) ? currentWeekData : [])
|
||||||
|
|
||||||
|
// Создаем Map для быстрого доступа к данным проекта
|
||||||
|
const projectDataMap = new Map()
|
||||||
|
projectsData.forEach(project => {
|
||||||
|
if (project.project_name) {
|
||||||
|
projectDataMap.set(project.project_name, {
|
||||||
|
priority: project.priority,
|
||||||
|
min_goal_score: parseFloat(project.min_goal_score) || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Сортируем проекты
|
||||||
|
return [...projectNames].sort((a, b) => {
|
||||||
|
const dataA = projectDataMap.get(a) || { priority: null, min_goal_score: 0 }
|
||||||
|
const dataB = projectDataMap.get(b) || { priority: null, min_goal_score: 0 }
|
||||||
|
|
||||||
|
const priorityA = normalizePriority(dataA.priority)
|
||||||
|
const priorityB = normalizePriority(dataB.priority)
|
||||||
|
|
||||||
|
if (priorityA !== priorityB) {
|
||||||
|
return priorityA - priorityB
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataB.min_goal_score - dataA.min_goal_score
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
12
play-life-web/tailwind.config.js
Normal file
12
play-life-web/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
51
play-life-web/vite.config.js
Normal file
51
play-life-web/vite.config.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
// Загружаем переменные окружения из корня проекта
|
||||||
|
// Сначала пробуем корневой .env, затем локальный
|
||||||
|
const rootEnv = loadEnv(mode, resolve(process.cwd(), '..'), '')
|
||||||
|
const localEnv = loadEnv(mode, process.cwd(), '')
|
||||||
|
|
||||||
|
// Объединяем переменные (локальные имеют приоритет)
|
||||||
|
const env = { ...rootEnv, ...localEnv, ...process.env }
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: parseInt(env.VITE_PORT || '3000', 10),
|
||||||
|
proxy: {
|
||||||
|
// Proxy API requests to backend
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
// Proxy other API endpoints
|
||||||
|
'/playlife-feed': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
'/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
'/projects': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
'/project': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
25
supervisord.conf
Normal file
25
supervisord.conf
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
user=root
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=/usr/sbin/nginx -g "daemon off;"
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stderr_logfile=/var/log/supervisor/nginx.err.log
|
||||||
|
stdout_logfile=/var/log/supervisor/nginx.out.log
|
||||||
|
priority=10
|
||||||
|
|
||||||
|
[program:backend]
|
||||||
|
command=/app/backend/main
|
||||||
|
directory=/app/backend
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stderr_logfile=/var/log/supervisor/backend.err.log
|
||||||
|
stdout_logfile=/var/log/supervisor/backend.out.log
|
||||||
|
priority=20
|
||||||
|
# Переменные окружения будут переданы из docker run --env-file
|
||||||
|
# PORT по умолчанию 8080 внутри контейнера
|
||||||
|
|
||||||
Reference in New Issue
Block a user