Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c81a36de | ||
|
|
c654a01116 | ||
|
|
7200cdfda9 | ||
|
|
0e509dd61a | ||
|
|
1f423e1ed3 | ||
|
|
f13838d91a | ||
|
|
f9928c6470 | ||
|
|
5ebb55510e | ||
|
|
81e8ebdf66 | ||
|
|
ce7e0e584a | ||
|
|
db3b2640a8 | ||
|
|
cfd9339e48 | ||
|
|
0783602fe8 | ||
|
|
22995b654d | ||
|
|
b8ae0bb17a | ||
|
|
441f872f33 | ||
|
|
0e53dfbdf7 | ||
|
|
a54c9983d4 | ||
|
|
22bafd8c28 | ||
|
|
f56278c670 | ||
|
|
72a6a3caf9 | ||
|
|
9fbe2081ed | ||
|
|
705eb2400e | ||
|
|
3cf3cd4edb | ||
|
|
b3a83e1e8f | ||
|
|
d368929a4a | ||
|
|
f19ed9cb81 | ||
|
|
e2059ef555 | ||
|
|
932dba8682 | ||
|
|
8023fb9108 | ||
|
|
08f81887b0 | ||
|
|
64d192796c | ||
|
|
f3a7d1c503 | ||
|
|
29cf05a3c3 | ||
|
|
a8cb7c2730 | ||
|
|
374d03cdfd | ||
|
|
d5e4699bcf | ||
|
|
5ccb214c04 | ||
|
|
11e0d0074c | ||
|
|
dde8858d7d | ||
|
|
cc7c6a905e | ||
|
|
3d3fa13f41 | ||
|
|
cbdcecea45 | ||
|
|
6cf4be65b2 | ||
|
|
ef59781633 | ||
|
|
97c031eda4 | ||
|
|
1097a84d06 | ||
|
|
b57b0bc901 | ||
|
|
60a6f4deb4 | ||
|
|
b1cfea22e6 | ||
|
|
2f16876185 | ||
|
|
b9133f60dc | ||
|
|
db74626068 | ||
|
|
b41f6e7cdc | ||
|
|
508355dcb3 | ||
|
|
1da35aaea4 | ||
|
|
d9db42a598 | ||
|
|
28a45ab81e | ||
|
|
9e5790f70e | ||
|
|
7df258da15 | ||
|
|
0ea531889d | ||
|
|
28d8148665 | ||
|
|
a7bc912db3 | ||
|
|
647c549ec9 | ||
|
|
a6065d7ff1 | ||
|
|
79430ba7f0 | ||
|
|
6d7d59d2ae | ||
|
|
2b9a024d3e | ||
|
|
4767f5975c | ||
|
|
bacb605a0c | ||
|
|
3bdad682b3 | ||
|
|
01e8b3468c | ||
|
|
ac34f480be | ||
|
|
27befeb92b | ||
|
|
9e50a718d8 | ||
|
|
08c5422d35 | ||
|
|
bf539c6e91 | ||
|
|
2326a774ad | ||
|
|
1cfaaa9506 | ||
|
|
ecc61c2a5f | ||
|
|
a5ce0de236 | ||
|
|
ccb365c95c | ||
|
|
1b2c79a8f2 | ||
|
|
d012f39be8 | ||
|
|
8b66e5fd6e | ||
|
|
4ca6eb4fd5 | ||
|
|
3a256dc290 | ||
|
|
38f640e38e | ||
|
|
8f7acee60c | ||
|
|
bcea4b2bf5 | ||
|
|
bd6dfd968c | ||
|
|
7547058507 | ||
|
|
53e3f23422 | ||
|
|
713f6020f6 | ||
|
|
72002a2b4f | ||
|
|
bc73160e1a | ||
|
|
9206b73b33 | ||
|
|
e74c4cf599 | ||
|
|
a7128703fe | ||
|
|
8ba7e8fd45 | ||
|
|
4df054536a | ||
|
|
cf4d5d40c3 | ||
|
|
d96bb2ce8d | ||
|
|
6f77f0643c | ||
|
|
edc29fbd97 | ||
|
|
7704de334c | ||
|
|
ad1caceda0 | ||
|
|
91d9b52524 | ||
|
|
914998980e | ||
|
|
b709192447 | ||
|
|
4a06ceb7f6 | ||
|
|
6015b62d29 | ||
|
|
f8aa81f963 |
33
.cursor/commands.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "init",
|
||||||
|
"description": "Инициализация Play Life: остановка контейнеров, поднятие сервисов, создание дампа с продакшена и восстановление в локальную базу",
|
||||||
|
"command": "./init.sh",
|
||||||
|
"type": "shell",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "run",
|
||||||
|
"description": "Перезапуск Play Life: перезапуск всех контейнеров",
|
||||||
|
"command": "./run.sh",
|
||||||
|
"type": "shell",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "backupFromProd",
|
||||||
|
"description": "Создание дампа базы данных с продакшена",
|
||||||
|
"command": "./dump-db.sh",
|
||||||
|
"type": "shell",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "restoreToLocal",
|
||||||
|
"description": "Восстановление базы данных из самого свежего дампа в локальную базу (автоматически выбирает последний дамп)",
|
||||||
|
"command": "./restore-db.sh",
|
||||||
|
"type": "shell",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
16
.cursor/rules/restart_on_changes.mdc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
description: "Перезапуск приложения после изменений в бэкенде или фронтенде"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правило перезапуска приложения
|
||||||
|
|
||||||
|
**ВАЖНО:** После применения всех изменений в бэкенде (`play-life-backend/`) или фронтенде (`play-life-web/`), а также после изменений в `docker-compose.yml`, **ОБЯЗАТЕЛЬНО** выполни команду `./run.sh` для перезапуска всех сервисов приложения.
|
||||||
|
|
||||||
|
Это правило применяется при работе с:
|
||||||
|
- Go кодом в `play-life-backend/`
|
||||||
|
- Миграциями базы данных в `play-life-backend/migrations/`
|
||||||
|
- React компонентами и стилями в `play-life-web/src/`
|
||||||
|
- Docker конфигурациями (`docker-compose.yml`, `Dockerfile`)
|
||||||
|
|
||||||
|
**Команда для перезапуска:** `./run.sh` или `bash run.sh` в корне проекта.
|
||||||
49
.cursor/rules/version_bump_and_push.mdc
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
description: "Правило для поднятия версии и пуша в git"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правило поднятия версии и пуша
|
||||||
|
|
||||||
|
Когда пользователь просит **поднять версию и запушить**, выполни следующие шаги:
|
||||||
|
|
||||||
|
### 1. Определи тип версии
|
||||||
|
|
||||||
|
Определи по сообщению пользователя, какую часть версии нужно поднять:
|
||||||
|
- **major** (мажор) - первая цифра (например: 1.0.0 → 2.0.0)
|
||||||
|
- **minor** (минор) - вторая цифра (например: 1.0.0 → 1.1.0)
|
||||||
|
- **patch** (патч) - третья цифра (например: 1.0.0 → 1.0.1)
|
||||||
|
|
||||||
|
**Если тип версии непонятен из контекста — обязательно спроси у пользователя!**
|
||||||
|
|
||||||
|
### 2. Обнови версию в файлах
|
||||||
|
|
||||||
|
Обнови версию в двух файлах:
|
||||||
|
- `VERSION` (в корне проекта)
|
||||||
|
- `play-life-web/package.json` (поле `"version"`)
|
||||||
|
|
||||||
|
### 3. Проанализируй git diff
|
||||||
|
|
||||||
|
Выполни `git diff --staged` и `git diff` для анализа изменений. На основе изменений составь **короткий commit message** (максимум 50 символов) на русском языке, описывающий суть изменений.
|
||||||
|
|
||||||
|
### 4. Закоммить изменения
|
||||||
|
|
||||||
|
Выполни:
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "<commit message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Запушь в репозиторий
|
||||||
|
|
||||||
|
Выполни:
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Пример использования:**
|
||||||
|
- "Подними патч и запушь" → поднять patch версию
|
||||||
|
- "Bump minor and push" → поднять minor версию
|
||||||
|
- "Подними версию и запушь" → спросить какой тип версии поднять
|
||||||
@@ -43,14 +43,67 @@ jobs:
|
|||||||
echo "${{ secrets.GIT_TOKEN }}" | docker login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
|
echo "${{ secrets.GIT_TOKEN }}" | docker login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
|
id: build
|
||||||
if: steps.version_check.outputs.changed == 'true'
|
if: steps.version_check.outputs.changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
|
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
|
||||||
VER="${{ steps.version_check.outputs.current }}"
|
VER="${{ steps.version_check.outputs.current }}"
|
||||||
|
|
||||||
|
echo "Building Docker image..."
|
||||||
|
echo "Registry: $REGISTRY"
|
||||||
|
echo "Tags: latest, $VER"
|
||||||
|
|
||||||
# Собираем один раз
|
# Собираем один раз
|
||||||
docker build -t $REGISTRY:latest -t $REGISTRY:$VER .
|
docker build -t $REGISTRY:latest -t $REGISTRY:$VER .
|
||||||
|
|
||||||
# Пушим оба тега
|
# Пушим оба тега
|
||||||
|
echo "Pushing image to registry..."
|
||||||
docker push $REGISTRY:latest
|
docker push $REGISTRY:latest
|
||||||
docker push $REGISTRY:$VER
|
docker push $REGISTRY:$VER
|
||||||
|
|
||||||
|
echo "✅ Successfully pushed to registry:"
|
||||||
|
echo " - $REGISTRY:latest"
|
||||||
|
echo " - $REGISTRY:$VER"
|
||||||
|
|
||||||
|
- name: Send Telegram notification (success)
|
||||||
|
if: success() && steps.version_check.outputs.changed == 'true'
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_TO }}
|
||||||
|
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
message: |
|
||||||
|
✅ Сборка и публикация успешны!
|
||||||
|
|
||||||
|
Проект: play-life
|
||||||
|
Версия: ${{ steps.version_check.outputs.current }}
|
||||||
|
Registry: dungeonsiege.synology.me/poignatov/play-life
|
||||||
|
Теги: latest, ${{ steps.version_check.outputs.current }}
|
||||||
|
Ветка: ${{ github.ref_name }}
|
||||||
|
Коммит: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Send Telegram notification (failure)
|
||||||
|
if: failure()
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_TO }}
|
||||||
|
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
message: |
|
||||||
|
❌ Сборка завершилась с ошибкой!
|
||||||
|
|
||||||
|
Проект: play-life
|
||||||
|
Версия: ${{ steps.version_check.outputs.current }}
|
||||||
|
Ветка: ${{ github.ref_name }}
|
||||||
|
Коммит: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Send Telegram notification (skipped)
|
||||||
|
if: steps.version_check.outputs.changed == 'false'
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_TO }}
|
||||||
|
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
message: |
|
||||||
|
ℹ️ Сборка пропущена
|
||||||
|
|
||||||
|
Проект: play-life
|
||||||
|
Версия не изменилась: ${{ steps.version_check.outputs.current }}
|
||||||
|
Ветка: ${{ github.ref_name }}
|
||||||
3
.gitignore
vendored
@@ -11,3 +11,6 @@ node_modules/
|
|||||||
database-dumps/*.sql
|
database-dumps/*.sql
|
||||||
database-dumps/*.sql.gz
|
database-dumps/*.sql.gz
|
||||||
!database-dumps/.gitkeep
|
!database-dumps/.gitkeep
|
||||||
|
|
||||||
|
# Uploaded files
|
||||||
|
uploads/
|
||||||
|
|||||||
4
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": []
|
||||||
|
}
|
||||||
82
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "init",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "./init.sh",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": true,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"detail": "Инициализация Play Life: остановка контейнеров, поднятие сервисов, создание дампа с продакшена и восстановление в локальную базу"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "./run.sh",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": true,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"detail": "Перезапуск Play Life: перезапуск всех контейнеров"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "backupFromProd",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "./dump-db.sh",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": true,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"detail": "Создание дампа базы данных с продакшена"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "restoreToLocal",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "./restore-db.sh",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": true,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"detail": "Восстановление базы данных из самого свежего дампа в локальную базу (автоматически выбирает последний дамп)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -33,12 +33,20 @@ RUN apk --no-cache add \
|
|||||||
# Создаем директории
|
# Создаем директории
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Создаем директорию для загруженных файлов
|
||||||
|
RUN mkdir -p /app/uploads/wishlist && \
|
||||||
|
chmod 755 /app/uploads
|
||||||
|
|
||||||
# Копируем собранный frontend
|
# Копируем собранный frontend
|
||||||
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Копируем собранный backend
|
# Копируем собранный backend
|
||||||
COPY --from=backend-builder /app/backend/main /app/backend/main
|
COPY --from=backend-builder /app/backend/main /app/backend/main
|
||||||
COPY play-life-backend/admin.html /app/backend/admin.html
|
COPY play-life-backend/admin.html /app/backend/admin.html
|
||||||
|
# Копируем миграции для применения при запуске
|
||||||
|
COPY play-life-backend/migrations /migrations
|
||||||
|
# Копируем файл версии
|
||||||
|
COPY VERSION /app/VERSION
|
||||||
|
|
||||||
# Копируем конфигурацию nginx
|
# Копируем конфигурацию nginx
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|||||||
727
TODOIST_REFACTOR_PLAN.md
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
# План рефакторинга интеграции с Todoist
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Переделать интеграцию с Todoist для использования **единого приложения**, созданного в Todoist Developer Platform. Все пользователи Play Life используют одно Todoist приложение с единым webhook URL.
|
||||||
|
|
||||||
|
## Текущая реализация
|
||||||
|
- Каждый пользователь имеет уникальный `webhook_token` в таблице `todoist_integrations`
|
||||||
|
- Webhook URL: `/webhook/todoist/{token}` (токен в URL)
|
||||||
|
- Пользователь определяется по токену из URL
|
||||||
|
- Пользователь должен вручную копировать webhook URL
|
||||||
|
|
||||||
|
## Новая реализация (Единое приложение)
|
||||||
|
- **Единое Todoist приложение** для всех пользователей Play Life
|
||||||
|
- **Единый Webhook URL:** `/webhook/todoist` (без токена!)
|
||||||
|
- Webhook настроен в Todoist Developer Console на уровне приложения
|
||||||
|
- Пользователь определяется по `todoist_user_id` из `event_data` webhook
|
||||||
|
- OAuth используется для привязки Todoist аккаунта к Play Life аккаунту
|
||||||
|
- **Пользователю не нужно ничего настраивать** — просто нажать "Подключить Todoist"!
|
||||||
|
|
||||||
|
## Краткое резюме изменений
|
||||||
|
|
||||||
|
### База данных:
|
||||||
|
- **Удалить** поле `webhook_token` (больше не нужно!)
|
||||||
|
- Добавить поля: `todoist_user_id`, `todoist_email`, `access_token`
|
||||||
|
|
||||||
|
### Переменные окружения:
|
||||||
|
- `TODOIST_CLIENT_ID` - Client ID приложения
|
||||||
|
- `TODOIST_CLIENT_SECRET` - Client Secret приложения
|
||||||
|
- `WEBHOOK_BASE_URL` - для формирования OAuth Redirect URI
|
||||||
|
|
||||||
|
### Backend:
|
||||||
|
- **Изменить webhook handler** — идентификация по `todoist_user_id`
|
||||||
|
- Добавить OAuth endpoints для подключения/отключения
|
||||||
|
- Убрать логику с токенами в URL
|
||||||
|
|
||||||
|
### Frontend:
|
||||||
|
- **Убрать отображение webhook URL** (не нужно!)
|
||||||
|
- Показать кнопку "Подключить Todoist"
|
||||||
|
- После подключения показать email и статус
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Изменения в базе данных
|
||||||
|
|
||||||
|
### Миграция: `013_refactor_todoist_single_app.sql`
|
||||||
|
|
||||||
|
**Изменения в таблице `todoist_integrations`:**
|
||||||
|
|
||||||
|
1. **Удалить:**
|
||||||
|
- `webhook_token` — больше не нужен! Webhook единый для всего приложения.
|
||||||
|
|
||||||
|
2. **Добавить:**
|
||||||
|
- `todoist_user_id` (BIGINT) — ID пользователя в Todoist (из OAuth, для идентификации в webhook)
|
||||||
|
- `todoist_email` (VARCHAR(255)) — Email пользователя в Todoist (из OAuth)
|
||||||
|
- `access_token` (TEXT) — OAuth access token (бессрочный в Todoist)
|
||||||
|
|
||||||
|
3. **Индексы:**
|
||||||
|
- **Уникальный** индекс на `todoist_user_id` — ключевой для идентификации в webhook!
|
||||||
|
- Уникальный индекс на `todoist_email`
|
||||||
|
- Удалить индекс на `webhook_token`
|
||||||
|
|
||||||
|
**Структура после миграции:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE todoist_integrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
todoist_user_id BIGINT, -- ID пользователя в Todoist (КЛЮЧЕВОЕ для webhook!)
|
||||||
|
todoist_email VARCHAR(255), -- Email пользователя в Todoist
|
||||||
|
access_token TEXT, -- OAuth access token (бессрочный)
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Индексы
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id
|
||||||
|
ON todoist_integrations(todoist_user_id)
|
||||||
|
WHERE todoist_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email
|
||||||
|
ON todoist_integrations(todoist_email)
|
||||||
|
WHERE todoist_email IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ключевое изменение:** `todoist_user_id` теперь используется для идентификации пользователя при получении webhook от Todoist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Переменные окружения (.env)
|
||||||
|
|
||||||
|
### Добавить в `env.example`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ============================================
|
||||||
|
# Todoist OAuth Configuration
|
||||||
|
# ============================================
|
||||||
|
# Client ID единого Todoist приложения
|
||||||
|
# Получить в: https://developer.todoist.com/appconsole.html
|
||||||
|
TODOIST_CLIENT_ID=your-todoist-client-id
|
||||||
|
|
||||||
|
# Client Secret единого Todoist приложения
|
||||||
|
TODOIST_CLIENT_SECRET=your-todoist-client-secret
|
||||||
|
|
||||||
|
# Секрет для проверки подлинности webhook от Todoist (опционально)
|
||||||
|
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
|
||||||
|
TODOIST_WEBHOOK_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что нужно получить из Todoist приложения:**
|
||||||
|
1. `TODOIST_CLIENT_ID` - Client ID приложения
|
||||||
|
2. `TODOIST_CLIENT_SECRET` - Client Secret приложения
|
||||||
|
3. `TODOIST_WEBHOOK_SECRET` (опционально) - для дополнительной безопасности webhook
|
||||||
|
|
||||||
|
**Важно:** В настройках Todoist приложения нужно указать Redirect URI:
|
||||||
|
- Используйте: `<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback`
|
||||||
|
- Например, если `WEBHOOK_BASE_URL=https://your-domain.com`, то Redirect URI: `https://your-domain.com/api/integrations/todoist/oauth/callback`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Изменения в Backend (main.go)
|
||||||
|
|
||||||
|
### 3.1. Обновить структуру `TodoistIntegration`:
|
||||||
|
```go
|
||||||
|
type TodoistIntegration struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
TodoistUserID *int64 `json:"todoist_user_id,omitempty"` // Ключевое для webhook!
|
||||||
|
TodoistEmail *string `json:"todoist_email,omitempty"`
|
||||||
|
AccessToken *string `json:"-"` // Не отдавать в JSON!
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- `AccessToken` не должен отдаваться в JSON ответах (используйте `json:"-"`)
|
||||||
|
- `TodoistUserID` — ключевое поле для идентификации пользователя в webhook
|
||||||
|
|
||||||
|
### 3.2. Webhook handler (`todoistWebhookHandler`) - КЛЮЧЕВОЕ ИЗМЕНЕНИЕ:
|
||||||
|
|
||||||
|
**Новый подход:**
|
||||||
|
- URL: `/webhook/todoist` (БЕЗ токена!)
|
||||||
|
- Webhook настроен в Todoist Developer Console для всего приложения
|
||||||
|
- Извлекает `user_id` из `event_data` webhook
|
||||||
|
- Находит пользователя по `todoist_user_id`
|
||||||
|
|
||||||
|
**Новая логика:**
|
||||||
|
```go
|
||||||
|
func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// CORS, OPTIONS handling
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
// Проверка webhook secret (если настроен)
|
||||||
|
todoistWebhookSecret := getEnv("TODOIST_WEBHOOK_SECRET", "")
|
||||||
|
if todoistWebhookSecret != "" {
|
||||||
|
providedSecret := r.Header.Get("X-Todoist-Hmac-SHA256")
|
||||||
|
// TODO: проверить HMAC подпись
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим webhook
|
||||||
|
var webhook TodoistWebhook
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&webhook); err != nil {
|
||||||
|
log.Printf("Todoist webhook: error decoding: %v", err)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Todoist webhook: event=%s", webhook.EventName)
|
||||||
|
|
||||||
|
// Обрабатываем только item:completed
|
||||||
|
if webhook.EventName != "item:completed" {
|
||||||
|
log.Printf("Todoist webhook: ignoring event %s", webhook.EventName)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем user_id из event_data (это Todoist user_id!)
|
||||||
|
// Может приходить как string или float64
|
||||||
|
var todoistUserID int64
|
||||||
|
switch v := webhook.EventData["user_id"].(type) {
|
||||||
|
case float64:
|
||||||
|
todoistUserID = int64(v)
|
||||||
|
case string:
|
||||||
|
todoistUserID, _ = strconv.ParseInt(v, 10, 64)
|
||||||
|
default:
|
||||||
|
log.Printf("Todoist webhook: user_id not found or invalid type in event_data")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим пользователя Play Life по todoist_user_id
|
||||||
|
var userID int
|
||||||
|
err := a.DB.QueryRow(`
|
||||||
|
SELECT user_id FROM todoist_integrations
|
||||||
|
WHERE todoist_user_id = $1
|
||||||
|
`, todoistUserID).Scan(&userID)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Пользователь не подключил Play Life — игнорируем
|
||||||
|
log.Printf("Todoist webhook: no user found for todoist_user_id=%d (ignoring)", todoistUserID)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist webhook: DB error: %v", err)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Todoist webhook: todoist_user_id=%d -> user_id=%d", todoistUserID, userID)
|
||||||
|
|
||||||
|
// ... остальная логика обработки события (как раньше) ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3. Маршрут webhook - ИЗМЕНИТЬ:
|
||||||
|
```go
|
||||||
|
// Было:
|
||||||
|
r.HandleFunc("/webhook/todoist/{token}", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
|
// Стало:
|
||||||
|
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** Этот URL нужно указать в Todoist Developer Console при настройке приложения!
|
||||||
|
|
||||||
|
### 3.4. Добавить OAuth endpoints:
|
||||||
|
|
||||||
|
1. **Инициация OAuth:**
|
||||||
|
- `GET /api/integrations/todoist/oauth/connect` - перенаправляет на Todoist OAuth
|
||||||
|
- **ВАЖНО:** Требует авторизацию пользователя (JWT token в cookie или header)
|
||||||
|
- Генерирует `state` параметр с user_id (JWT подписанный jwtSecret)
|
||||||
|
- Формирует `redirect_uri` из `WEBHOOK_BASE_URL`:
|
||||||
|
```go
|
||||||
|
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||||
|
if baseURL == "" {
|
||||||
|
sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback"
|
||||||
|
|
||||||
|
// Генерируем state с user_id
|
||||||
|
state := generateOAuthState(userID, jwtSecret) // JWT с user_id и exp
|
||||||
|
|
||||||
|
// Формируем URL для редиректа
|
||||||
|
authURL := fmt.Sprintf(
|
||||||
|
"https://todoist.com/oauth/authorize?client_id=%s&scope=data:read_write&state=%s&redirect_uri=%s",
|
||||||
|
url.QueryEscape(todoistClientID),
|
||||||
|
url.QueryEscape(state),
|
||||||
|
url.QueryEscape(redirectURI),
|
||||||
|
)
|
||||||
|
|
||||||
|
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **OAuth callback:**
|
||||||
|
- `GET /api/integrations/todoist/oauth/callback` - обрабатывает callback от Todoist
|
||||||
|
- **ПУБЛИЧНЫЙ ENDPOINT** (без авторизации, так как пользователь приходит от Todoist)
|
||||||
|
- Логика:
|
||||||
|
1. Проверяет `state` параметр (JWT с user_id, exp = 1 день)
|
||||||
|
2. Извлекает `code` из query parameters
|
||||||
|
3. Обменивает `code` на `access_token` через POST запрос к Todoist
|
||||||
|
4. Получает информацию о пользователе через Sync API
|
||||||
|
5. Сохраняет/обновляет данные в БД
|
||||||
|
6. Перенаправляет пользователя на страницу интеграций
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (a *App) todoistOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
frontendURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||||
|
redirectSuccess := frontendURL + "/?integration=todoist&status=connected"
|
||||||
|
redirectError := frontendURL + "/?integration=todoist&status=error"
|
||||||
|
|
||||||
|
// 1. Проверяем state (JWT с user_id, exp = 1 день)
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
userID, err := validateOAuthState(state, jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist OAuth: invalid state: %v", err)
|
||||||
|
http.Redirect(w, r, redirectError+"&message=invalid_state", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Получаем code
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
log.Printf("Todoist OAuth: no code in callback")
|
||||||
|
http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Обмениваем code на access_token
|
||||||
|
accessToken, err := exchangeCodeForToken(code, redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist OAuth: token exchange failed: %v", err)
|
||||||
|
http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Получаем информацию о пользователе
|
||||||
|
todoistUser, err := getTodoistUserInfo(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist OAuth: get user info failed: %v", err)
|
||||||
|
http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Todoist OAuth: user_id=%d connected todoist_user_id=%d email=%s",
|
||||||
|
userID, todoistUser.ID, todoistUser.Email)
|
||||||
|
|
||||||
|
// 5. Сохраняем в БД (INSERT или UPDATE)
|
||||||
|
_, err = a.DB.Exec(`
|
||||||
|
INSERT INTO todoist_integrations (user_id, todoist_user_id, todoist_email, access_token)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
todoist_user_id = $2,
|
||||||
|
todoist_email = $3,
|
||||||
|
access_token = $4,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, userID, todoistUser.ID, todoistUser.Email, accessToken)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist OAuth: DB error: %v", err)
|
||||||
|
http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Редирект на страницу интеграций
|
||||||
|
http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Получение статуса интеграции:**
|
||||||
|
- `GET /api/integrations/todoist/status` - возвращает статус подключения
|
||||||
|
- Требует авторизацию (protected endpoint)
|
||||||
|
- Возвращает:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"connected": true,
|
||||||
|
"todoist_email": "user@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
или если не подключено:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"connected": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Примечание:** webhook_url больше не нужен — он единый для всего приложения!
|
||||||
|
|
||||||
|
4. **Отключение интеграции:**
|
||||||
|
- `DELETE /api/integrations/todoist/disconnect` - отключает интеграцию
|
||||||
|
- Требует авторизацию (protected endpoint)
|
||||||
|
- **Удаляет запись** из `todoist_integrations` полностью
|
||||||
|
- Возвращает: `{"success": true, "message": "Todoist disconnected"}`
|
||||||
|
|
||||||
|
### 3.5. Новые маршруты:
|
||||||
|
```go
|
||||||
|
// OAuth endpoints
|
||||||
|
protected.HandleFunc("/api/integrations/todoist/oauth/connect", app.todoistOAuthConnectHandler).Methods("GET")
|
||||||
|
r.HandleFunc("/api/integrations/todoist/oauth/callback", app.todoistOAuthCallbackHandler).Methods("GET") // Публичный!
|
||||||
|
protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS")
|
||||||
|
|
||||||
|
// Webhook (единый для всего приложения)
|
||||||
|
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
|
// УДАЛИТЬ старый endpoint:
|
||||||
|
// protected.HandleFunc("/api/integrations/todoist/webhook-url", ...) // Больше не нужен!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- OAuth callback должен быть публичным (пользователь приходит от Todoist без JWT)
|
||||||
|
- Webhook тоже публичный (Todoist отправляет события)
|
||||||
|
- `/api/integrations/todoist/webhook-url` — **УДАЛИТЬ**, больше не нужен!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Изменения в Frontend (TodoistIntegration.jsx)
|
||||||
|
|
||||||
|
### 4.1. Добавить проверку статуса подключения:
|
||||||
|
- При загрузке компонента вызывать `GET /api/integrations/todoist/status`
|
||||||
|
- Определять, подключен ли Todoist
|
||||||
|
|
||||||
|
### 4.2. Добавить OAuth flow:
|
||||||
|
- **Если не подключено:**
|
||||||
|
- Показать кнопку "Подключить Todoist"
|
||||||
|
- При клике: `window.location.href = '/api/integrations/todoist/oauth/connect'`
|
||||||
|
- После OAuth callback backend перенаправит на `/?integration=todoist&status=connected`
|
||||||
|
- При загрузке проверять URL параметры и показывать соответствующее сообщение
|
||||||
|
|
||||||
|
- **Если подключено:**
|
||||||
|
- Показать email пользователя Todoist
|
||||||
|
- Показать статус: "✅ Todoist подключен"
|
||||||
|
- Кнопка "Отключить Todoist" (вызывает `DELETE /api/integrations/todoist/disconnect`)
|
||||||
|
- **Webhook URL не нужен** — всё работает автоматически!
|
||||||
|
|
||||||
|
### 4.3. Обновить инструкции:
|
||||||
|
- **Если не подключено:**
|
||||||
|
- Инструкция: "Нажмите кнопку 'Подключить Todoist' для авторизации"
|
||||||
|
|
||||||
|
- **Если подключено:**
|
||||||
|
- Инструкция: "✅ Todoist подключен! Закрывайте задачи в Todoist — они автоматически появятся в Play Life."
|
||||||
|
- **Никаких дополнительных настроек не требуется!**
|
||||||
|
|
||||||
|
### 4.4. Удалить:
|
||||||
|
- Отображение webhook URL
|
||||||
|
- Кнопку "Копировать"
|
||||||
|
- Инструкции по настройке webhook в Todoist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Порядок выполнения изменений
|
||||||
|
|
||||||
|
### Шаг 1: Создать миграцию БД
|
||||||
|
- Создать файл `013_refactor_todoist_single_app.sql`
|
||||||
|
- Содержимое миграции:
|
||||||
|
```sql
|
||||||
|
-- Migration: Refactor todoist_integrations for single Todoist app
|
||||||
|
-- Webhook теперь единый для всего приложения, токены в URL больше не нужны
|
||||||
|
|
||||||
|
-- 1. Добавляем новые поля
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS todoist_user_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS todoist_email VARCHAR(255);
|
||||||
|
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS access_token TEXT;
|
||||||
|
|
||||||
|
-- 2. Удаляем webhook_token (больше не нужен!)
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
DROP COLUMN IF EXISTS webhook_token;
|
||||||
|
|
||||||
|
-- 3. Удаляем старый индекс на webhook_token
|
||||||
|
DROP INDEX IF EXISTS idx_todoist_integrations_webhook_token;
|
||||||
|
|
||||||
|
-- 4. Создаем новые индексы
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id
|
||||||
|
ON todoist_integrations(todoist_user_id)
|
||||||
|
WHERE todoist_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email
|
||||||
|
ON todoist_integrations(todoist_email)
|
||||||
|
WHERE todoist_email IS NOT NULL;
|
||||||
|
|
||||||
|
-- 5. Комментарии
|
||||||
|
COMMENT ON COLUMN todoist_integrations.todoist_user_id IS 'Todoist user ID (from OAuth) - used to identify user in webhooks';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.todoist_email IS 'Todoist user email (from OAuth)';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.access_token IS 'Todoist OAuth access token (permanent)';
|
||||||
|
```
|
||||||
|
- Применить миграцию
|
||||||
|
|
||||||
|
**Важно:** После миграции старые записи с `webhook_token` будут работать пока не применится миграция. После миграции все пользователи должны переподключить Todoist через OAuth.
|
||||||
|
|
||||||
|
### Шаг 2: Обновить .env
|
||||||
|
- Добавить новые переменные окружения
|
||||||
|
- Получить данные из Todoist приложения
|
||||||
|
|
||||||
|
### Шаг 3: Обновить Backend
|
||||||
|
- Обновить структуру `TodoistIntegration`
|
||||||
|
- Изменить webhook handler
|
||||||
|
- Добавить OAuth endpoints
|
||||||
|
- Обновить маршруты
|
||||||
|
|
||||||
|
### Шаг 4: Обновить Frontend
|
||||||
|
- Обновить компонент `TodoistIntegration.jsx`
|
||||||
|
- Добавить OAuth flow
|
||||||
|
|
||||||
|
### Шаг 5: Тестирование
|
||||||
|
- Протестировать OAuth flow
|
||||||
|
- Протестировать webhook с новым способом идентификации
|
||||||
|
- Проверить миграцию данных
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Важные замечания
|
||||||
|
|
||||||
|
### 6.1. Идентификация пользователя в webhook
|
||||||
|
**Новый подход:**
|
||||||
|
- Используется `todoist_user_id` из `event_data` webhook
|
||||||
|
- `todoist_user_id` сохраняется при OAuth подключении
|
||||||
|
- Webhook приходит на единый URL `/webhook/todoist`
|
||||||
|
- Находим пользователя Play Life по `todoist_user_id`
|
||||||
|
|
||||||
|
### 6.2. Миграция существующих данных
|
||||||
|
- **Удаляем `webhook_token`** — больше не нужен
|
||||||
|
- Все существующие записи будут работать после миграции, но без OAuth данных
|
||||||
|
- Пользователям нужно **переподключить Todoist через OAuth** для работы интеграции
|
||||||
|
- После миграции старый endpoint `/webhook/todoist/{token}` перестанет работать
|
||||||
|
|
||||||
|
### 6.3. Обратная совместимость
|
||||||
|
- **НЕТ обратной совместимости** — это breaking change
|
||||||
|
- Старый endpoint `/webhook/todoist/{token}` удаляется
|
||||||
|
- Все пользователи должны переподключить Todoist
|
||||||
|
- **Рекомендация:** Уведомить пользователей о необходимости переподключения
|
||||||
|
|
||||||
|
### 6.3.1. Удаляемый код
|
||||||
|
**Удалить полностью:**
|
||||||
|
- Endpoint `GET /api/integrations/todoist/webhook-url`
|
||||||
|
- Handler `getTodoistWebhookURLHandler`
|
||||||
|
- Маршрут `/webhook/todoist/{token}`
|
||||||
|
- Функция генерации webhook_token для Todoist
|
||||||
|
|
||||||
|
### 6.4. Безопасность
|
||||||
|
- OAuth токен (`access_token`) не отдавать в JSON ответах (json:"-")
|
||||||
|
- Использовать `TODOIST_WEBHOOK_SECRET` для проверки подлинности webhook (если настроен в Todoist)
|
||||||
|
- Todoist access_token бессрочный, но пользователь может отозвать его в настройках Todoist
|
||||||
|
- User-Agent для запросов к Todoist API: `PlayLife`
|
||||||
|
|
||||||
|
### 6.5. OAuth Flow (детально)
|
||||||
|
1. Пользователь нажимает "Подключить Todoist"
|
||||||
|
2. Backend генерирует `state` (случайная строка или JWT с user_id) и сохраняет его
|
||||||
|
3. Перенаправление на Todoist OAuth:
|
||||||
|
```
|
||||||
|
https://todoist.com/oauth/authorize?
|
||||||
|
client_id=<TODOIST_CLIENT_ID>&
|
||||||
|
scope=data:read_write&
|
||||||
|
state=<state>&
|
||||||
|
redirect_uri=<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
|
||||||
|
```
|
||||||
|
4. Пользователь авторизуется в Todoist
|
||||||
|
5. Todoist перенаправляет на `redirect_uri` с `code` и `state`
|
||||||
|
6. Backend проверяет `state` и обменивает `code` на `access_token`:
|
||||||
|
```
|
||||||
|
POST https://todoist.com/oauth/access_token
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
client_id=<TODOIST_CLIENT_ID>&
|
||||||
|
client_secret=<TODOIST_CLIENT_SECRET>&
|
||||||
|
code=<code>&
|
||||||
|
redirect_uri=<redirect_uri>
|
||||||
|
```
|
||||||
|
7. Backend получает информацию о пользователе через Todoist Sync API:
|
||||||
|
```
|
||||||
|
POST https://api.todoist.com/sync/v9/sync
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
User-Agent: PlayLife
|
||||||
|
|
||||||
|
sync_token=*&resource_types=["user"]
|
||||||
|
```
|
||||||
|
Ответ содержит `user.id` и `user.email`
|
||||||
|
8. Backend сохраняет `todoist_user_id`, `todoist_email`, `access_token` в БД
|
||||||
|
9. Перенаправление пользователя на страницу интеграций
|
||||||
|
|
||||||
|
### 6.6. Хранение state для OAuth
|
||||||
|
Используем JWT токен (не требует хранения в БД):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Генерация state (таймаут = 1 день)
|
||||||
|
func generateOAuthState(userID int, jwtSecret string) string {
|
||||||
|
state := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": userID,
|
||||||
|
"type": "todoist_oauth",
|
||||||
|
"exp": time.Now().Add(24 * time.Hour).Unix(), // 1 день
|
||||||
|
})
|
||||||
|
stateString, _ := state.SignedString([]byte(jwtSecret))
|
||||||
|
return stateString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка state в callback
|
||||||
|
func validateOAuthState(stateString string, jwtSecret string) (int, error) {
|
||||||
|
token, err := jwt.Parse(stateString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(jwtSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return 0, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims["type"] != "todoist_oauth" {
|
||||||
|
return 0, fmt.Errorf("wrong token type")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := int(claims["user_id"].(float64))
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7. Особенности Todoist OAuth
|
||||||
|
- **Scope:** `data:read_write` — полный доступ к данным пользователя
|
||||||
|
- **Access Token:** Todoist выдает бессрочный access_token
|
||||||
|
- **Refresh Token:** Todoist НЕ использует refresh_token
|
||||||
|
- **Отзыв токена:** Пользователь может отозвать доступ в настройках Todoist
|
||||||
|
|
||||||
|
### 6.8. Обработка ошибок
|
||||||
|
|
||||||
|
**Если todoist_user_id не найден в webhook:**
|
||||||
|
- Логировать: `log.Printf("Todoist webhook: no user found for todoist_user_id=%d", todoistUserID)`
|
||||||
|
- Возвращать `200 OK` (чтобы Todoist не делал retry)
|
||||||
|
- Игнорировать событие
|
||||||
|
|
||||||
|
**Если токен отозван пользователем:**
|
||||||
|
- При попытке использовать access_token Todoist вернет ошибку
|
||||||
|
- Автоматически отключить интеграцию (удалить запись из БД)
|
||||||
|
- Логировать: `log.Printf("Todoist: token revoked for user_id=%d, disconnecting", userID)`
|
||||||
|
|
||||||
|
**При disconnect:**
|
||||||
|
- Просто удалить запись из БД
|
||||||
|
- НЕ отзывать токен через Todoist API (упрощение)
|
||||||
|
|
||||||
|
### 6.9. События Todoist
|
||||||
|
Подписываемся только на: **`item:completed`**
|
||||||
|
|
||||||
|
Другие события (`item:added`, `item:updated`, `item:deleted`) не нужны.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Архитектура: Единый Webhook
|
||||||
|
|
||||||
|
**Ключевое решение:** Используем единый webhook URL для всего приложения.
|
||||||
|
|
||||||
|
### Как это работает:
|
||||||
|
|
||||||
|
1. **Настройка в Todoist Developer Console:**
|
||||||
|
- Создать приложение в https://developer.todoist.com/appconsole.html
|
||||||
|
- Указать Webhook URL: `<WEBHOOK_BASE_URL>/webhook/todoist`
|
||||||
|
- Указать OAuth Redirect URI: `<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback`
|
||||||
|
- Выбрать события: `item:completed`
|
||||||
|
|
||||||
|
2. **При OAuth подключении:**
|
||||||
|
- Пользователь нажимает "Подключить Todoist"
|
||||||
|
- Авторизуется в Todoist
|
||||||
|
- Play Life получает `access_token` и информацию о пользователе
|
||||||
|
- Сохраняем `todoist_user_id` — это ключ для идентификации в webhook
|
||||||
|
|
||||||
|
3. **При получении webhook:**
|
||||||
|
- Todoist отправляет POST на `/webhook/todoist`
|
||||||
|
- В `event_data` есть `user_id` (это Todoist user_id)
|
||||||
|
- Находим пользователя Play Life по `todoist_user_id`
|
||||||
|
- Обрабатываем событие
|
||||||
|
|
||||||
|
### Преимущества:
|
||||||
|
- ✅ Пользователю не нужно ничего настраивать!
|
||||||
|
- ✅ Нет токенов в URL
|
||||||
|
- ✅ Простая архитектура
|
||||||
|
- ✅ Webhook настраивается один раз в Developer Console
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Настройка Todoist приложения в Developer Console
|
||||||
|
|
||||||
|
### Шаги настройки:
|
||||||
|
1. Зайти в https://developer.todoist.com/appconsole.html
|
||||||
|
2. Создать новое приложение или открыть существующее
|
||||||
|
3. Заполнить:
|
||||||
|
- **App name:** Play Life
|
||||||
|
- **App description:** Интеграция с Play Life для отслеживания прогресса
|
||||||
|
- **OAuth Redirect URL:** `<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback`
|
||||||
|
- **Webhooks callback URL:** `<WEBHOOK_BASE_URL>/webhook/todoist`
|
||||||
|
- **Watched events:** `item:completed` (только это событие!)
|
||||||
|
4. Скопировать:
|
||||||
|
- **Client ID** → `TODOIST_CLIENT_ID`
|
||||||
|
- **Client Secret** → `TODOIST_CLIENT_SECRET`
|
||||||
|
- **Client secret for webhooks** (если есть) → `TODOIST_WEBHOOK_SECRET`
|
||||||
|
|
||||||
|
### Важные настройки:
|
||||||
|
- **OAuth scope:** `data:read_write`
|
||||||
|
- **Watched events:** только `item:completed`
|
||||||
|
- Другие события НЕ подписывать
|
||||||
|
|
||||||
|
### Формат webhook от Todoist:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_name": "item:completed",
|
||||||
|
"user_id": "12345678", // ← Это todoist_user_id для идентификации!
|
||||||
|
"event_data": {
|
||||||
|
"id": "task_id",
|
||||||
|
"content": "Task title",
|
||||||
|
"description": "Task description",
|
||||||
|
"user_id": "12345678", // ← Тоже здесь
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** `user_id` приходит как string, нужно конвертировать в int64.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Краткая сводка для быстрого старта
|
||||||
|
|
||||||
|
### Настройка Todoist приложения:
|
||||||
|
1. Зайти в https://developer.todoist.com/appconsole.html
|
||||||
|
2. Создать приложение
|
||||||
|
3. Настроить:
|
||||||
|
- **OAuth Redirect URL:** `<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback`
|
||||||
|
- **Webhooks callback URL:** `<WEBHOOK_BASE_URL>/webhook/todoist`
|
||||||
|
- **Watched events:** `item:completed`
|
||||||
|
4. Скопировать Client ID и Client Secret
|
||||||
|
|
||||||
|
### Что добавить в .env:
|
||||||
|
```env
|
||||||
|
TODOIST_CLIENT_ID=your-client-id-here
|
||||||
|
TODOIST_CLIENT_SECRET=your-client-secret-here
|
||||||
|
TODOIST_WEBHOOK_SECRET= # опционально, из Developer Console
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что изменится в базе данных:
|
||||||
|
- Добавятся поля: `todoist_user_id`, `todoist_email`, `access_token`
|
||||||
|
- **Удалится поле:** `webhook_token`
|
||||||
|
|
||||||
|
### Что изменится для пользователей:
|
||||||
|
- Пользователи нажимают "Подключить Todoist"
|
||||||
|
- Авторизуются в Todoist
|
||||||
|
- **Готово!** Никаких дополнительных настроек!
|
||||||
|
- Закрытые задачи в Todoist автоматически появляются в Play Life
|
||||||
|
|
||||||
|
### Порядок реализации:
|
||||||
|
1. ⬜ Настроить Todoist приложение в Developer Console
|
||||||
|
2. ⬜ Создать миграцию БД (`013_refactor_todoist_single_app.sql`)
|
||||||
|
3. ⬜ Обновить `.env` с новыми переменными
|
||||||
|
4. ⬜ Реализовать OAuth endpoints в Backend
|
||||||
|
5. ⬜ Обновить webhook handler (идентификация по todoist_user_id)
|
||||||
|
6. ⬜ Обновить Frontend компонент
|
||||||
|
7. ⬜ Удалить старый код (webhook-url endpoint, токены)
|
||||||
|
8. ⬜ Протестировать OAuth flow и webhook
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./play-life-backend/migrations:/migrations
|
- ./play-life-backend/migrations:/migrations
|
||||||
|
- ./uploads:/app/uploads
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
|
|||||||
59
dump-db.sh
@@ -65,27 +65,46 @@ echo " Хост: $DB_HOST:$DB_PORT"
|
|||||||
echo " Пользователь: $DB_USER"
|
echo " Пользователь: $DB_USER"
|
||||||
echo " Файл: $DUMP_PATH"
|
echo " Файл: $DUMP_PATH"
|
||||||
|
|
||||||
# Создаем дамп через docker-compose, если контейнер запущен
|
# Создаем дамп через docker-compose, если контейнер запущен И хост локальный
|
||||||
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
if [ "$DB_HOST" = "localhost" ] || [ "$DB_HOST" = "127.0.0.1" ] || [ -z "$DB_HOST" ]; then
|
||||||
echo " Используется docker-compose..."
|
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
||||||
docker-compose exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
echo " Используется docker-compose..."
|
||||||
elif command -v pg_dump &> /dev/null; then
|
docker-compose exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
# Или напрямую через pg_dump, если БД доступна локально
|
elif command -v pg_dump &> /dev/null; then
|
||||||
echo " Используется локальный pg_dump..."
|
# Или напрямую через pg_dump, если БД доступна локально
|
||||||
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
echo " Используется локальный pg_dump..."
|
||||||
elif command -v docker &> /dev/null; then
|
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
# Используем Docker образ postgres для создания дампа
|
elif command -v docker &> /dev/null; then
|
||||||
# Используем latest для совместимости с разными версиями сервера
|
# Используем Docker образ postgres для создания дампа
|
||||||
echo " Используется Docker (postgres:latest)..."
|
echo " Используется Docker (postgres:latest)..."
|
||||||
# Используем --network host для доступа к удаленным хостам
|
docker run --rm -i --network host \
|
||||||
docker run --rm -i --network host \
|
-e PGPASSWORD="$DB_PASSWORD" \
|
||||||
-e PGPASSWORD="$DB_PASSWORD" \
|
postgres:latest \
|
||||||
postgres:latest \
|
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
else
|
||||||
|
echo "❌ Ошибка: pg_dump не найден, docker-compose не запущен и Docker недоступен"
|
||||||
|
echo " Установите PostgreSQL клиент или Docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ Ошибка: pg_dump не найден, docker-compose не запущен и Docker недоступен"
|
# Для удаленных хостов используем pg_dump или Docker
|
||||||
echo " Установите PostgreSQL клиент или Docker"
|
if command -v pg_dump &> /dev/null; then
|
||||||
exit 1
|
echo " Используется локальный pg_dump..."
|
||||||
|
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
|
elif command -v docker &> /dev/null; then
|
||||||
|
# Используем Docker образ postgres для создания дампа
|
||||||
|
# Используем latest для совместимости с разными версиями сервера
|
||||||
|
echo " Используется Docker (postgres:latest)..."
|
||||||
|
# Используем --network host для доступа к удаленным хостам
|
||||||
|
docker run --rm -i --network host \
|
||||||
|
-e PGPASSWORD="$DB_PASSWORD" \
|
||||||
|
postgres:latest \
|
||||||
|
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
|
else
|
||||||
|
echo "❌ Ошибка: pg_dump не найден и Docker недоступен"
|
||||||
|
echo " Установите PostgreSQL клиент или Docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Сжимаем дамп
|
# Сжимаем дамп
|
||||||
|
|||||||
36
env.example
@@ -28,8 +28,10 @@ WEB_PORT=3001
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Telegram Bot Configuration
|
# Telegram Bot Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
# Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
|
# Токен единого бота для всех пользователей
|
||||||
# Get token from @BotFather in Telegram: https://t.me/botfather
|
# Получить у @BotFather: https://t.me/botfather
|
||||||
|
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
||||||
|
|
||||||
# Base URL для автоматической настройки webhook
|
# Base URL для автоматической настройки webhook
|
||||||
# Примеры:
|
# Примеры:
|
||||||
# - Для production с HTTPS: https://your-domain.com
|
# - Для production с HTTPS: https://your-domain.com
|
||||||
@@ -40,13 +42,35 @@ WEB_PORT=3001
|
|||||||
WEBHOOK_BASE_URL=https://your-domain.com
|
WEBHOOK_BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Todoist Webhook Configuration (optional)
|
# Todoist Integration Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
# Секрет для проверки подлинности webhook от Todoist
|
# Единое Todoist приложение для всех пользователей Play Life
|
||||||
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
|
# Настроить в: https://developer.todoist.com/appconsole.html
|
||||||
# Оставьте пустым, если не хотите использовать проверку секрета
|
#
|
||||||
|
# В настройках Todoist приложения указать:
|
||||||
|
# - OAuth Redirect URL: <WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
|
||||||
|
# - Webhooks callback URL: <WEBHOOK_BASE_URL>/webhook/todoist
|
||||||
|
# - Watched events: item:completed
|
||||||
|
|
||||||
|
# Client ID единого Todoist приложения
|
||||||
|
TODOIST_CLIENT_ID=
|
||||||
|
|
||||||
|
# Client Secret единого Todoist приложения
|
||||||
|
TODOIST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Секрет для проверки подлинности webhook от Todoist (опционально)
|
||||||
|
# Получить в Developer Console: "Client secret for webhooks"
|
||||||
TODOIST_WEBHOOK_SECRET=
|
TODOIST_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Authentication Configuration
|
||||||
|
# ============================================
|
||||||
|
# Секретный ключ для подписи JWT токенов
|
||||||
|
# ВАЖНО: Обязательно задайте свой уникальный секретный ключ для production!
|
||||||
|
# Если не задан, будет использован случайно сгенерированный (не рекомендуется для production)
|
||||||
|
# Можно сгенерировать с помощью: openssl rand -base64 32
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Scheduler Configuration
|
# Scheduler Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
160
init.sh
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для первоначальной настройки и запуска приложения
|
||||||
|
# Использование: ./init.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Проверка наличия .env файла
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo -e "${RED}❌ Файл .env не найден!${NC}"
|
||||||
|
echo " Создайте файл .env на основе env.example"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Загружаем переменные окружения
|
||||||
|
export $(cat .env | grep -v '^#' | grep -v '^$' | xargs)
|
||||||
|
|
||||||
|
# Значения по умолчанию
|
||||||
|
DB_USER=${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD=${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME=${DB_NAME:-playeng}
|
||||||
|
DB_PORT=${DB_PORT:-5432}
|
||||||
|
PORT=${PORT:-8080}
|
||||||
|
WEB_PORT=${WEB_PORT:-3001}
|
||||||
|
|
||||||
|
echo -e "${GREEN}🚀 Инициализация Play Life...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Остановка и удаление существующих контейнеров
|
||||||
|
echo -e "${YELLOW}1. Остановка существующих контейнеров...${NC}"
|
||||||
|
docker-compose down -v 2>/dev/null || true
|
||||||
|
echo -e "${GREEN} ✅ Контейнеры остановлены${NC}"
|
||||||
|
|
||||||
|
# Удаляем старые образы postgres, если они есть
|
||||||
|
echo -e "${YELLOW} Удаление старых образов postgres...${NC}"
|
||||||
|
docker images | grep -E "postgres:(15|16|17|18|latest)" | awk '{print $3}' | xargs -r docker rmi -f 2>/dev/null || true
|
||||||
|
echo -e "${GREEN} ✅ Старые образы postgres удалены${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. Поднятие всех сервисов
|
||||||
|
echo -e "${YELLOW}2. Поднятие сервисов через Docker Compose...${NC}"
|
||||||
|
echo " - База данных PostgreSQL 18.0 (порт: $DB_PORT)"
|
||||||
|
echo " - Backend сервер (порт: $PORT)"
|
||||||
|
echo " - Frontend приложение (порт: $WEB_PORT)"
|
||||||
|
docker-compose up -d --build
|
||||||
|
echo -e "${GREEN} ✅ Сервисы запущены${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3. Ожидание готовности базы данных
|
||||||
|
echo -e "${YELLOW}3. Ожидание готовности базы данных...${NC}"
|
||||||
|
MAX_WAIT=60
|
||||||
|
WAIT_COUNT=0
|
||||||
|
while ! docker-compose exec -T db pg_isready -U "$DB_USER" >/dev/null 2>&1; do
|
||||||
|
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
|
||||||
|
echo -e "${RED} ❌ База данных не готова за $MAX_WAIT секунд${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 1
|
||||||
|
WAIT_COUNT=$((WAIT_COUNT + 1))
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN} ✅ База данных готова${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. Поиск самого свежего дампа
|
||||||
|
echo -e "${YELLOW}4. Поиск самого свежего дампа...${NC}"
|
||||||
|
DUMP_DIR="database-dumps"
|
||||||
|
|
||||||
|
if [ ! -d "$DUMP_DIR" ]; then
|
||||||
|
echo -e "${YELLOW} ⚠️ Директория дампов не найдена, создаём...${NC}"
|
||||||
|
mkdir -p "$DUMP_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ищем все дампы (сначала .sql.gz, потом .sql)
|
||||||
|
LATEST_DUMP=$(ls -t "$DUMP_DIR"/*.{sql.gz,sql} 2>/dev/null | head -n 1)
|
||||||
|
|
||||||
|
if [ -z "$LATEST_DUMP" ]; then
|
||||||
|
echo -e "${YELLOW} ⚠️ Дампы не найдены${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Создаём дамп с продакшена используя креденшелы из .env
|
||||||
|
echo -e "${YELLOW}5. Создание дампа с продакшена...${NC}"
|
||||||
|
echo -e "${BLUE} 📦 Используются креденшелы из .env${NC}"
|
||||||
|
echo " Используется скрипт dump-db.sh"
|
||||||
|
|
||||||
|
if [ -f "./dump-db.sh" ]; then
|
||||||
|
chmod +x ./dump-db.sh
|
||||||
|
DUMP_NAME="prod_backup_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
|
||||||
|
# Временно останавливаем контейнер db, чтобы dump-db.sh не использовал docker-compose exec
|
||||||
|
# и подключился напрямую к продакшен базе по креденшелам из .env
|
||||||
|
echo -e "${BLUE} ⏸️ Временно останавливаем локальный контейнер db для создания дампа с продакшена...${NC}"
|
||||||
|
docker-compose stop db 2>/dev/null || true
|
||||||
|
|
||||||
|
# Используем dump-db.sh с креденшелами из .env (по умолчанию)
|
||||||
|
# Теперь он подключится напрямую к продакшен базе, а не через docker-compose
|
||||||
|
./dump-db.sh "$DUMP_NAME"
|
||||||
|
|
||||||
|
# Запускаем контейнер db обратно
|
||||||
|
echo -e "${BLUE} ▶️ Запускаем локальный контейнер db обратно...${NC}"
|
||||||
|
docker-compose start db 2>/dev/null || docker-compose up -d db
|
||||||
|
|
||||||
|
# Проверяем, был ли создан дамп
|
||||||
|
CREATED_DUMP=$(ls -t "$DUMP_DIR"/"$DUMP_NAME".sql.gz 2>/dev/null | head -n 1)
|
||||||
|
if [ -n "$CREATED_DUMP" ]; then
|
||||||
|
echo -e "${GREEN} ✅ Дамп с продакшена создан: $(basename "$CREATED_DUMP")${NC}"
|
||||||
|
LATEST_DUMP="$CREATED_DUMP"
|
||||||
|
# Продолжаем с восстановлением ниже
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Не удалось создать дамп с продакшена${NC}"
|
||||||
|
echo -e "${YELLOW} ⚠️ Проверьте креденшелы в .env и доступность базы данных${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Скрипт dump-db.sh не найден${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Если дамп найден или создан, восстанавливаем его
|
||||||
|
if [ -n "$LATEST_DUMP" ]; then
|
||||||
|
LATEST_DUMP_NAME=$(basename "$LATEST_DUMP")
|
||||||
|
echo -e "${GREEN} ✅ Найден дамп: $LATEST_DUMP_NAME${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 6. Восстановление базы данных
|
||||||
|
echo -e "${YELLOW}6. Восстановление базы данных из дампа...${NC}"
|
||||||
|
echo " Файл: $LATEST_DUMP_NAME"
|
||||||
|
echo " Используется скрипт restore-db.sh (восстановление в локальную базу)"
|
||||||
|
|
||||||
|
# Используем restore-db.sh, который автоматически восстанавливает в локальную базу при использовании .env
|
||||||
|
# restore-db.sh автоматически выберет самый свежий дамп, если имя не указано
|
||||||
|
if [ -f "./restore-db.sh" ]; then
|
||||||
|
chmod +x ./restore-db.sh
|
||||||
|
# Автоматически подтверждаем восстановление
|
||||||
|
# restore-db.sh сам выберет самый свежий дамп из database-dumps/
|
||||||
|
echo "yes" | ./restore-db.sh
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Скрипт restore-db.sh не найден${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Инициализация завершена!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}📋 Статус сервисов:${NC}"
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Proxy other API endpoints to backend
|
# Proxy other API endpoints to backend
|
||||||
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|message/post|weekly_goals/setup|admin|admin\.html)$ {
|
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|project/create|message/post|weekly_goals/setup|admin|admin\.html)$ {
|
||||||
proxy_pass http://localhost:8080;
|
proxy_pass http://localhost:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -62,6 +62,31 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Service Worker должен быть без кэширования
|
||||||
|
location /sw.js {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Manifest тоже без долгого кэширования
|
||||||
|
location /manifest.webmanifest {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Раздача загруженных файлов (картинки wishlist) - проксируем через backend
|
||||||
|
# Используем ^~ чтобы этот location имел приоритет над regex locations
|
||||||
|
location ^~ /uploads/ {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
# Handle React Router (SPA)
|
# Handle React Router (SPA)
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Database Configuration
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_USER=playeng
|
|
||||||
DB_PASSWORD=playeng
|
|
||||||
DB_NAME=playeng
|
|
||||||
|
|
||||||
# Server Configuration
|
|
||||||
PORT=8080
|
|
||||||
|
|
||||||
# Telegram Bot Configuration
|
|
||||||
# Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
|
|
||||||
# Get token from @BotFather in Telegram: https://t.me/botfather
|
|
||||||
|
|
||||||
# Scheduler Configuration
|
|
||||||
# Часовой пояс для планировщика (формат IANA: Europe/Moscow, America/New_York и т.д.)
|
|
||||||
# ВАЖНО: Если не указан, используется UTC!
|
|
||||||
TIMEZONE=Europe/Moscow
|
|
||||||
@@ -4,8 +4,15 @@ go 1.21
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
golang.org/x/crypto v0.28.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
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 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
@@ -8,3 +12,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
|||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||||
|
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
|||||||
128
play-life-backend/migrations/009_add_users_and_multitenancy.sql
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
-- Migration: Add users table and user_id to all tables for multi-tenancy
|
||||||
|
-- This script adds user authentication and makes all data user-specific
|
||||||
|
-- All statements use IF NOT EXISTS / IF EXISTS for idempotency
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: users
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_login_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: refresh_tokens
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to projects
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE projects
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);
|
||||||
|
|
||||||
|
-- Drop old unique constraint (name now unique per user, handled in app)
|
||||||
|
ALTER TABLE projects DROP CONSTRAINT IF EXISTS unique_project_name;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to entries
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE entries
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to dictionaries
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE dictionaries
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dictionaries_user_id ON dictionaries(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to words
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE words
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_words_user_id ON words(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to progress
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE progress
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_progress_user_id ON progress(user_id);
|
||||||
|
|
||||||
|
-- Drop old unique constraint (word_id now unique per user)
|
||||||
|
ALTER TABLE progress DROP CONSTRAINT IF EXISTS progress_word_id_key;
|
||||||
|
|
||||||
|
-- Create new unique constraint per user
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to configs
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE configs
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_configs_user_id ON configs(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to telegram_integrations
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_integrations_user_id ON telegram_integrations(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to weekly_goals
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE weekly_goals
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_goals_user_id ON weekly_goals(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to nodes (score data)
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE nodes
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_user_id ON nodes(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE users IS 'Users table for authentication and multi-tenancy';
|
||||||
|
COMMENT ON COLUMN users.email IS 'User email address (unique, used for login)';
|
||||||
|
COMMENT ON COLUMN users.password_hash IS 'Bcrypt hashed password';
|
||||||
|
COMMENT ON COLUMN users.name IS 'User display name';
|
||||||
|
COMMENT ON COLUMN users.is_active IS 'Whether the user account is active';
|
||||||
|
COMMENT ON TABLE refresh_tokens IS 'JWT refresh tokens for persistent login';
|
||||||
|
|
||||||
|
-- Note: The first user who logs in will automatically become the owner of all
|
||||||
|
-- existing data (projects, entries, dictionaries, words, etc.) that have NULL user_id.
|
||||||
|
-- This is handled in the application code (claimOrphanedData function).
|
||||||
17
play-life-backend/migrations/011_add_webhook_tokens.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration: Add webhook_token to telegram_integrations
|
||||||
|
-- This allows identifying user by webhook URL token
|
||||||
|
|
||||||
|
-- Add webhook_token column to telegram_integrations
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255);
|
||||||
|
|
||||||
|
-- Create unique index on webhook_token for fast lookups
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token
|
||||||
|
ON telegram_integrations(webhook_token)
|
||||||
|
WHERE webhook_token IS NOT NULL;
|
||||||
|
|
||||||
|
-- Generate webhook tokens for existing integrations
|
||||||
|
-- This will be handled by application code, but we ensure the column exists
|
||||||
|
|
||||||
|
COMMENT ON COLUMN telegram_integrations.webhook_token IS 'Unique token for webhook URL identification (e.g., /webhook/telegram/{token})';
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
-- Migration: Refactor telegram_integrations for single shared bot
|
||||||
|
-- and move Todoist webhook_token to separate table
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. Создаем таблицу todoist_integrations
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS todoist_integrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
webhook_token VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_webhook_token
|
||||||
|
ON todoist_integrations(webhook_token);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_todoist_integrations_user_id
|
||||||
|
ON todoist_integrations(user_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE todoist_integrations IS 'Todoist webhook integration settings per user';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.webhook_token IS 'Unique token for Todoist webhook URL';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. Мигрируем webhook_token из telegram_integrations в todoist_integrations
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO todoist_integrations (user_id, webhook_token, created_at, updated_at)
|
||||||
|
SELECT user_id, webhook_token, COALESCE(created_at, CURRENT_TIMESTAMP), CURRENT_TIMESTAMP
|
||||||
|
FROM telegram_integrations
|
||||||
|
WHERE webhook_token IS NOT NULL
|
||||||
|
AND webhook_token != ''
|
||||||
|
AND user_id IS NOT NULL
|
||||||
|
ON CONFLICT (user_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. Модифицируем telegram_integrations
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Удаляем bot_token (будет в .env)
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
DROP COLUMN IF EXISTS bot_token;
|
||||||
|
|
||||||
|
-- Удаляем webhook_token (перенесли в todoist_integrations)
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
DROP COLUMN IF EXISTS webhook_token;
|
||||||
|
|
||||||
|
-- Добавляем telegram_user_id
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS telegram_user_id BIGINT;
|
||||||
|
|
||||||
|
-- Добавляем start_token для deep links
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS start_token VARCHAR(255);
|
||||||
|
|
||||||
|
-- Добавляем timestamps если их нет
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. Создаем индексы
|
||||||
|
-- ============================================
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_start_token
|
||||||
|
ON telegram_integrations(start_token)
|
||||||
|
WHERE start_token IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_telegram_user_id
|
||||||
|
ON telegram_integrations(telegram_user_id)
|
||||||
|
WHERE telegram_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Уникальность user_id
|
||||||
|
DROP INDEX IF EXISTS idx_telegram_integrations_user_id;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_user_id_unique
|
||||||
|
ON telegram_integrations(user_id)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Индекс для поиска по chat_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_integrations_chat_id
|
||||||
|
ON telegram_integrations(chat_id)
|
||||||
|
WHERE chat_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Удаляем старый индекс webhook_token
|
||||||
|
DROP INDEX IF EXISTS idx_telegram_integrations_webhook_token;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 5. Очищаем данные Telegram для переподключения
|
||||||
|
-- ============================================
|
||||||
|
UPDATE telegram_integrations
|
||||||
|
SET chat_id = NULL,
|
||||||
|
telegram_user_id = NULL,
|
||||||
|
start_token = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Комментарии
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN telegram_integrations.telegram_user_id IS 'Telegram user ID (message.from.id)';
|
||||||
|
COMMENT ON COLUMN telegram_integrations.chat_id IS 'Telegram chat ID для отправки сообщений';
|
||||||
|
COMMENT ON COLUMN telegram_integrations.start_token IS 'Временный токен для deep link при первом подключении';
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Migration: Refactor todoist_integrations for single Todoist app
|
||||||
|
-- Webhook теперь единый для всего приложения, токены в URL больше не нужны
|
||||||
|
-- Все пользователи используют одно Todoist приложение
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. Добавляем новые поля
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS todoist_user_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS todoist_email VARCHAR(255);
|
||||||
|
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS access_token TEXT;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. Удаляем webhook_token (больше не нужен!)
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
DROP COLUMN IF EXISTS webhook_token;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. Удаляем старый индекс на webhook_token
|
||||||
|
-- ============================================
|
||||||
|
DROP INDEX IF EXISTS idx_todoist_integrations_webhook_token;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. Создаем новые индексы
|
||||||
|
-- ============================================
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id
|
||||||
|
ON todoist_integrations(todoist_user_id)
|
||||||
|
WHERE todoist_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email
|
||||||
|
ON todoist_integrations(todoist_email)
|
||||||
|
WHERE todoist_email IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 5. Комментарии
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN todoist_integrations.todoist_user_id IS 'Todoist user ID (from OAuth) - used to identify user in webhooks';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.todoist_email IS 'Todoist user email (from OAuth)';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.access_token IS 'Todoist OAuth access token (permanent)';
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Migration: Make refresh tokens permanent (no expiration)
|
||||||
|
-- Refresh tokens теперь не имеют срока действия (expires_at может быть NULL)
|
||||||
|
-- Access tokens живут 24 часа вместо 15 минут
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. Изменяем expires_at на NULLABLE
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE refresh_tokens
|
||||||
|
ALTER COLUMN expires_at DROP NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. Устанавливаем NULL для всех существующих токенов
|
||||||
|
-- (или можно оставить их как есть, если они еще не истекли)
|
||||||
|
-- ============================================
|
||||||
|
-- UPDATE refresh_tokens SET expires_at = NULL WHERE expires_at > NOW();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. Комментарий
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN refresh_tokens.expires_at IS 'Expiration date for refresh token. NULL means token never expires.';
|
||||||
|
|
||||||
58
play-life-backend/migrations/015_add_tasks.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- Migration: Add tasks and reward_configs tables
|
||||||
|
-- This script creates tables for task management system
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: tasks
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
completed INTEGER DEFAULT 0,
|
||||||
|
last_completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
reward_message TEXT,
|
||||||
|
progression_base NUMERIC(10,4),
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_last_completed_at ON tasks(last_completed_at);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: reward_configs
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS reward_configs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
value NUMERIC(10,4) NOT NULL,
|
||||||
|
use_progression BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reward_configs_task_id ON reward_configs(task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reward_configs_project_id ON reward_configs(project_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_reward_configs_task_position ON reward_configs(task_id, position);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE tasks IS 'Tasks table for task management system';
|
||||||
|
COMMENT ON COLUMN tasks.name IS 'Task name (required for main tasks, optional for subtasks)';
|
||||||
|
COMMENT ON COLUMN tasks.completed IS 'Number of times task was completed';
|
||||||
|
COMMENT ON COLUMN tasks.last_completed_at IS 'Date and time of last task completion';
|
||||||
|
COMMENT ON COLUMN tasks.parent_task_id IS 'Parent task ID for subtasks (NULL for main tasks)';
|
||||||
|
COMMENT ON COLUMN tasks.reward_message IS 'Reward message template with placeholders ${0}, ${1}, etc.';
|
||||||
|
COMMENT ON COLUMN tasks.progression_base IS 'Base value for progression calculation (NULL means no progression)';
|
||||||
|
COMMENT ON COLUMN tasks.deleted IS 'Soft delete flag';
|
||||||
|
|
||||||
|
COMMENT ON TABLE reward_configs IS 'Reward configurations for tasks';
|
||||||
|
COMMENT ON COLUMN reward_configs.position IS 'Position in reward_message template (0, 1, 2, etc.)';
|
||||||
|
COMMENT ON COLUMN reward_configs.task_id IS 'Task this reward belongs to';
|
||||||
|
COMMENT ON COLUMN reward_configs.project_id IS 'Project to add reward to';
|
||||||
|
COMMENT ON COLUMN reward_configs.value IS 'Default score value (can be negative)';
|
||||||
|
COMMENT ON COLUMN reward_configs.use_progression IS 'Whether to use progression multiplier for this reward';
|
||||||
|
|
||||||
14
play-life-backend/migrations/016_add_repetition_period.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Add repetition_period field to tasks table
|
||||||
|
-- This script adds the repetition_period field for recurring tasks
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add repetition_period column
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS repetition_period INTERVAL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN tasks.repetition_period IS 'Period after which task should be repeated (NULL means task is not recurring)';
|
||||||
|
|
||||||
14
play-life-backend/migrations/017_add_next_show_at.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Add next_show_at field to tasks table
|
||||||
|
-- This script adds the next_show_at field for postponing tasks
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add next_show_at column
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN tasks.next_show_at IS 'Date when task should be shown again (NULL means use last_completed_at + period)';
|
||||||
|
|
||||||
16
play-life-backend/migrations/018_add_repetition_date.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: Add repetition_date field to tasks table
|
||||||
|
-- This script adds the repetition_date field for pattern-based recurring tasks
|
||||||
|
-- Format examples: "2 week" (2nd day of week), "15 month" (15th day of month), "02-01 year" (Feb 1st)
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add repetition_date column
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS repetition_date TEXT;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN tasks.repetition_date IS 'Pattern-based repetition: "N week" (day of week 1-7), "N month" (day of month 1-31), "MM-DD year" (specific date). Mutually exclusive with repetition_period.';
|
||||||
|
|
||||||
|
|
||||||
86
play-life-backend/migrations/019_add_wishlist.sql
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
-- Migration: Add wishlist tables
|
||||||
|
-- This script creates tables for wishlist management system
|
||||||
|
-- Supports multiple unlock conditions per wishlist item (AND logic)
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: wishlist_items
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS wishlist_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
price NUMERIC(10,2),
|
||||||
|
image_path VARCHAR(500),
|
||||||
|
link TEXT,
|
||||||
|
completed BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_id ON wishlist_items(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_deleted ON wishlist_items(user_id, deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_completed ON wishlist_items(user_id, completed, deleted);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: task_conditions
|
||||||
|
-- ============================================
|
||||||
|
-- Reusable conditions for task completion
|
||||||
|
CREATE TABLE IF NOT EXISTS task_conditions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_task_condition UNIQUE (task_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_conditions_task_id ON task_conditions(task_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: score_conditions
|
||||||
|
-- ============================================
|
||||||
|
-- Reusable conditions for project points
|
||||||
|
CREATE TABLE IF NOT EXISTS score_conditions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
required_points NUMERIC(10,4) NOT NULL,
|
||||||
|
period_type VARCHAR(20), -- 'week', 'month', 'year', NULL (all time)
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_score_condition UNIQUE (project_id, required_points, period_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_score_conditions_project_id ON score_conditions(project_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: wishlist_conditions
|
||||||
|
-- ============================================
|
||||||
|
-- Links wishlist items to unlock conditions
|
||||||
|
CREATE TABLE IF NOT EXISTS wishlist_conditions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
wishlist_item_id INTEGER NOT NULL REFERENCES wishlist_items(id) ON DELETE CASCADE,
|
||||||
|
task_condition_id INTEGER REFERENCES task_conditions(id) ON DELETE CASCADE,
|
||||||
|
score_condition_id INTEGER REFERENCES score_conditions(id) ON DELETE CASCADE,
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT check_exactly_one_condition CHECK (
|
||||||
|
(task_condition_id IS NOT NULL AND score_condition_id IS NULL) OR
|
||||||
|
(task_condition_id IS NULL AND score_condition_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_item_id ON wishlist_conditions(wishlist_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_item_order ON wishlist_conditions(wishlist_item_id, display_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_task_condition_id ON wishlist_conditions(task_condition_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_score_condition_id ON wishlist_conditions(score_condition_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE wishlist_items IS 'Wishlist items for users';
|
||||||
|
COMMENT ON COLUMN wishlist_items.completed IS 'Flag indicating item was purchased/received';
|
||||||
|
COMMENT ON COLUMN wishlist_items.image_path IS 'Path to image file relative to uploads root';
|
||||||
|
|
||||||
|
COMMENT ON TABLE task_conditions IS 'Reusable unlock conditions based on task completion';
|
||||||
|
COMMENT ON TABLE score_conditions IS 'Reusable unlock conditions based on project points';
|
||||||
|
COMMENT ON TABLE wishlist_conditions IS 'Links between wishlist items and unlock conditions. Multiple conditions per item use AND logic.';
|
||||||
|
COMMENT ON COLUMN wishlist_conditions.display_order IS 'Order for displaying conditions in UI';
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Migration: Change period_type to start_date in score_conditions
|
||||||
|
-- This allows specifying a start date for counting points instead of period type
|
||||||
|
-- Date can be in the past or future, NULL means count all time
|
||||||
|
|
||||||
|
-- Добавляем новое поле start_date
|
||||||
|
ALTER TABLE score_conditions
|
||||||
|
ADD COLUMN IF NOT EXISTS start_date DATE;
|
||||||
|
|
||||||
|
-- Миграция данных: для существующих записей с period_type устанавливаем start_date
|
||||||
|
-- Если period_type = 'week', то start_date = начало текущей недели
|
||||||
|
-- Если period_type = 'month', то start_date = начало текущего месяца
|
||||||
|
-- Если period_type = 'year', то start_date = начало текущего года
|
||||||
|
-- Если period_type IS NULL, то start_date = NULL (за всё время)
|
||||||
|
UPDATE score_conditions
|
||||||
|
SET start_date = CASE
|
||||||
|
WHEN period_type = 'week' THEN DATE_TRUNC('week', CURRENT_DATE)::DATE
|
||||||
|
WHEN period_type = 'month' THEN DATE_TRUNC('month', CURRENT_DATE)::DATE
|
||||||
|
WHEN period_type = 'year' THEN DATE_TRUNC('year', CURRENT_DATE)::DATE
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
WHERE start_date IS NULL;
|
||||||
|
|
||||||
|
-- Обновляем уникальное ограничение (удаляем старое, добавляем новое)
|
||||||
|
ALTER TABLE score_conditions
|
||||||
|
DROP CONSTRAINT IF EXISTS unique_score_condition;
|
||||||
|
|
||||||
|
ALTER TABLE score_conditions
|
||||||
|
ADD CONSTRAINT unique_score_condition
|
||||||
|
UNIQUE (project_id, required_points, start_date);
|
||||||
|
|
||||||
|
-- Обновляем комментарии
|
||||||
|
COMMENT ON COLUMN score_conditions.start_date IS 'Date from which to start counting points. NULL means count all time.';
|
||||||
|
|
||||||
|
-- Примечание: поле period_type оставляем пока для обратной совместимости
|
||||||
|
-- Его можно будет удалить позже после проверки, что всё работает:
|
||||||
|
-- ALTER TABLE score_conditions DROP COLUMN period_type;
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migration: Add wishlist_id to tasks table for linking tasks to wishlist items
|
||||||
|
-- This allows creating tasks directly from wishlist items and tracking the relationship
|
||||||
|
|
||||||
|
-- Добавляем поле wishlist_id в таблицу tasks
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS wishlist_id INTEGER REFERENCES wishlist_items(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Создаём индекс для быстрого поиска задач по wishlist_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_wishlist_id ON tasks(wishlist_id);
|
||||||
|
|
||||||
|
-- Уникальный индекс: только одна незавершённая задача на желание
|
||||||
|
-- Это предотвращает создание нескольких задач для одного желания
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_wishlist_id_unique
|
||||||
|
ON tasks(wishlist_id) WHERE wishlist_id IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
-- Добавляем комментарий для документации
|
||||||
|
COMMENT ON COLUMN tasks.wishlist_id IS 'Link to wishlist item that this task fulfills. NULL if task is not linked to any wishlist item.';
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Migration: Refactor configs to link via tasks.config_id
|
||||||
|
-- This migration adds config_id to tasks table and migrates existing configs to tasks
|
||||||
|
-- After migration: configs only contain words_count, max_cards (name and try_message removed)
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 1: Add config_id to tasks
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS config_id INTEGER REFERENCES configs(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_config_id ON tasks(config_id);
|
||||||
|
|
||||||
|
-- Unique index: only one task per config
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_config_id_unique
|
||||||
|
ON tasks(config_id) WHERE config_id IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tasks.config_id IS 'Link to test config. NULL if task is not a test.';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 2: Migrate existing configs to tasks
|
||||||
|
-- Create a task for each config that doesn't have one yet
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO tasks (user_id, name, reward_message, repetition_period, repetition_date, config_id)
|
||||||
|
SELECT
|
||||||
|
c.user_id,
|
||||||
|
c.name, -- Config name -> Task name
|
||||||
|
c.try_message, -- try_message -> reward_message
|
||||||
|
'0 day'::INTERVAL, -- repetition_period = 0 (infinite task)
|
||||||
|
'0 week', -- repetition_date = 0 (infinite task)
|
||||||
|
c.id -- Link to config
|
||||||
|
FROM configs c
|
||||||
|
WHERE c.name IS NOT NULL -- Only configs with names
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tasks t WHERE t.config_id = c.id AND t.deleted = FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 3: Remove name and try_message from configs
|
||||||
|
-- These are now stored in the linked task
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE configs DROP COLUMN IF EXISTS name;
|
||||||
|
ALTER TABLE configs DROP COLUMN IF EXISTS try_message;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE configs IS 'Test configurations (words_count, max_cards, dictionary associations). Linked to tasks via tasks.config_id.';
|
||||||
|
|
||||||
|
|
||||||
116
play-life-backend/migrations/023_add_wishlist_boards.sql
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
-- Migration: Add wishlist boards for multi-user collaboration
|
||||||
|
-- Each user can have multiple boards, share them via invite links,
|
||||||
|
-- and collaborate with other users on shared wishes
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: wishlist_boards (доски желаний)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS wishlist_boards (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
-- Настройки доступа по ссылке
|
||||||
|
invite_token VARCHAR(64) UNIQUE,
|
||||||
|
invite_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_boards_owner_id ON wishlist_boards(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_boards_invite_token ON wishlist_boards(invite_token)
|
||||||
|
WHERE invite_token IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_boards_owner_deleted ON wishlist_boards(owner_id, deleted);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: wishlist_board_members (участники доски)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS wishlist_board_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
board_id INTEGER NOT NULL REFERENCES wishlist_boards(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT unique_board_member UNIQUE (board_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_board_members_board_id ON wishlist_board_members(board_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_board_members_user_id ON wishlist_board_members(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Modify: wishlist_items - добавляем board_id и author_id
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
ADD COLUMN IF NOT EXISTS board_id INTEGER REFERENCES wishlist_boards(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
ADD COLUMN IF NOT EXISTS author_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_board_id ON wishlist_items(board_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_author_id ON wishlist_items(author_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Modify: wishlist_conditions - добавляем user_id для персональных целей
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE wishlist_conditions
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_user_id ON wishlist_conditions(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Modify: tasks - добавляем политику награждения для wishlist задач
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS reward_policy VARCHAR(20) DEFAULT 'personal';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tasks.reward_policy IS
|
||||||
|
'For wishlist tasks: personal = only if user completes, shared = anyone completes';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Миграция данных: Этап 1 - создаём персональные доски
|
||||||
|
-- ============================================
|
||||||
|
-- Создаём доску "Мои желания" для каждого пользователя с желаниями
|
||||||
|
INSERT INTO wishlist_boards (owner_id, name)
|
||||||
|
SELECT DISTINCT user_id, 'Мои желания'
|
||||||
|
FROM wishlist_items
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
AND deleted = FALSE
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM wishlist_boards wb
|
||||||
|
WHERE wb.owner_id = wishlist_items.user_id AND wb.name = 'Мои желания'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Миграция данных: Этап 2 - привязываем желания к доскам
|
||||||
|
-- ============================================
|
||||||
|
UPDATE wishlist_items wi
|
||||||
|
SET
|
||||||
|
board_id = wb.id,
|
||||||
|
author_id = COALESCE(wi.author_id, wi.user_id)
|
||||||
|
FROM wishlist_boards wb
|
||||||
|
WHERE wi.board_id IS NULL
|
||||||
|
AND wi.user_id = wb.owner_id
|
||||||
|
AND wb.name = 'Мои желания';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Миграция данных: Этап 3 - заполняем user_id в условиях
|
||||||
|
-- ============================================
|
||||||
|
UPDATE wishlist_conditions wc
|
||||||
|
SET user_id = wi.user_id
|
||||||
|
FROM wishlist_items wi
|
||||||
|
WHERE wc.wishlist_item_id = wi.id
|
||||||
|
AND wc.user_id IS NULL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE wishlist_boards IS 'Wishlist boards for organizing and sharing wishes';
|
||||||
|
COMMENT ON COLUMN wishlist_boards.invite_token IS 'Token for invite link, NULL = disabled';
|
||||||
|
COMMENT ON COLUMN wishlist_boards.invite_enabled IS 'Whether invite link is active';
|
||||||
|
COMMENT ON TABLE wishlist_board_members IS 'Users who joined boards via invite link (not owners)';
|
||||||
|
COMMENT ON COLUMN wishlist_conditions.user_id IS 'Owner of this condition. Each user has their own goals on shared boards.';
|
||||||
|
COMMENT ON COLUMN wishlist_items.author_id IS 'User who created this item (may differ from board owner on shared boards)';
|
||||||
|
COMMENT ON COLUMN wishlist_items.board_id IS 'Board this item belongs to';
|
||||||
|
|
||||||
13
play-life-backend/migrations/024_add_reward_policy.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration: Add reward_policy to tasks table
|
||||||
|
-- This migration adds reward_policy column for wishlist tasks
|
||||||
|
-- If the column already exists (from migration 023), this will be a no-op
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Modify: tasks - добавляем политику награждения для wishlist задач
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS reward_policy VARCHAR(20) DEFAULT 'personal';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tasks.reward_policy IS
|
||||||
|
'For wishlist tasks: personal = only if user completes, shared = anyone completes';
|
||||||
|
|
||||||
80
play-life-web/generate-icons.cjs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Скрипт для генерации базовых PWA иконок
|
||||||
|
// Требует: npm install sharp
|
||||||
|
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const publicDir = path.join(__dirname, 'public');
|
||||||
|
|
||||||
|
// Создаем SVG шаблон для обычной иконки (со скругленными углами)
|
||||||
|
const createIconSVG = (size) => `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||||
|
<text x="50" y="70" font-family="Arial, sans-serif" font-size="60" font-weight="bold" fill="white" text-anchor="middle">P</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Создаем SVG шаблон для maskable иконки (без скругления, контент в безопасной зоне 80%)
|
||||||
|
const createMaskableIconSVG = (size) => `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="100" height="100" fill="url(#grad)"/>
|
||||||
|
<text x="50" y="66" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">P</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function generateIcons() {
|
||||||
|
// Создаем базовые SVG
|
||||||
|
const baseSVG = createIconSVG(512);
|
||||||
|
const svgBuffer = Buffer.from(baseSVG);
|
||||||
|
|
||||||
|
const maskableSVG = createMaskableIconSVG(512);
|
||||||
|
const maskableSvgBuffer = Buffer.from(maskableSVG);
|
||||||
|
|
||||||
|
// Генерируем иконки разных размеров
|
||||||
|
const sizes = [
|
||||||
|
{ name: 'favicon.ico', size: 32 },
|
||||||
|
{ name: 'apple-touch-icon.png', size: 180 },
|
||||||
|
{ name: 'pwa-192x192.png', size: 192 },
|
||||||
|
{ name: 'pwa-512x512.png', size: 512 },
|
||||||
|
{ name: 'pwa-maskable-192x192.png', size: 192, maskable: true },
|
||||||
|
{ name: 'pwa-maskable-512x512.png', size: 512, maskable: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const icon of sizes) {
|
||||||
|
// Для maskable иконок используем специальный SVG с контентом в безопасной зоне
|
||||||
|
const sourceBuffer = icon.maskable ? maskableSvgBuffer : svgBuffer;
|
||||||
|
const image = sharp(sourceBuffer).resize(icon.size, icon.size);
|
||||||
|
|
||||||
|
const outputPath = path.join(publicDir, icon.name);
|
||||||
|
await image.png().toFile(outputPath);
|
||||||
|
console.log(`✓ Создана иконка: ${icon.name} (${icon.size}x${icon.size})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✓ Все иконки успешно созданы!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем наличие sharp
|
||||||
|
try {
|
||||||
|
require('sharp');
|
||||||
|
generateIcons().catch(console.error);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Для генерации иконок необходимо установить sharp:');
|
||||||
|
console.log('npm install sharp --save-dev');
|
||||||
|
console.log('\nИли создайте иконки вручную используя онлайн генераторы:');
|
||||||
|
console.log('- https://realfavicongenerator.net/');
|
||||||
|
console.log('- https://www.pwabuilder.com/imageGenerator');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,8 +2,18 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="theme-color" content="#4f46e5" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="PlayLife" />
|
||||||
|
<meta name="description" content="Трекер продуктивности и изучения слов" />
|
||||||
|
|
||||||
<title>PlayLife - Статистика</title>
|
<title>PlayLife - Статистика</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Proxy other API endpoints to backend
|
# Proxy other API endpoints to backend
|
||||||
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|message/post|webhook/|weekly_goals/setup|admin|admin\.html)$ {
|
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|project/create|message/post|webhook/|weekly_goals/setup|admin|admin\.html)$ {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass http://backend:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -36,6 +36,30 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Service Worker должен быть без кэширования
|
||||||
|
location /sw.js {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Manifest тоже без долгого кэширования
|
||||||
|
location /manifest.webmanifest {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Раздача загруженных файлов (картинки wishlist) - проксируем через backend
|
||||||
|
location ^~ /uploads/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
# Handle React Router (SPA)
|
# Handle React Router (SPA)
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
5196
play-life-web/package-lock.json
generated
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "1.1.0",
|
"version": "3.14.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-easy-crop": "^5.5.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
@@ -22,7 +23,9 @@
|
|||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^3.3.6",
|
"tailwindcss": "^3.3.6",
|
||||||
"vite": "^5.0.8"
|
"vite": "^5.0.8",
|
||||||
|
"vite-plugin-pwa": "^1.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
play-life-web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
play-life-web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 732 B |
11
play-life-web/public/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||||
|
<text x="50" y="70" font-family="Arial, sans-serif" font-size="60" font-weight="bold" fill="white" text-anchor="middle">P</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 511 B |
BIN
play-life-web/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
play-life-web/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
play-life-web/public/pwa-maskable-192x192.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
play-life-web/public/pwa-maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -4,16 +4,46 @@ import FullStatistics from './components/FullStatistics'
|
|||||||
import ProjectPriorityManager from './components/ProjectPriorityManager'
|
import ProjectPriorityManager from './components/ProjectPriorityManager'
|
||||||
import WordList from './components/WordList'
|
import WordList from './components/WordList'
|
||||||
import AddWords from './components/AddWords'
|
import AddWords from './components/AddWords'
|
||||||
import TestConfigSelection from './components/TestConfigSelection'
|
import DictionaryList from './components/DictionaryList'
|
||||||
import AddConfig from './components/AddConfig'
|
|
||||||
import TestWords from './components/TestWords'
|
import TestWords from './components/TestWords'
|
||||||
import Integrations from './components/Integrations'
|
import Profile from './components/Profile'
|
||||||
|
import TaskList from './components/TaskList'
|
||||||
|
import TaskForm from './components/TaskForm.jsx'
|
||||||
|
import Wishlist from './components/Wishlist'
|
||||||
|
import WishlistForm from './components/WishlistForm'
|
||||||
|
import WishlistDetail from './components/WishlistDetail'
|
||||||
|
import BoardForm from './components/BoardForm'
|
||||||
|
import BoardJoinPreview from './components/BoardJoinPreview'
|
||||||
|
import TodoistIntegration from './components/TodoistIntegration'
|
||||||
|
import TelegramIntegration from './components/TelegramIntegration'
|
||||||
|
import { AuthProvider, useAuth } from './components/auth/AuthContext'
|
||||||
|
import AuthScreen from './components/auth/AuthScreen'
|
||||||
|
import PWAUpdatePrompt from './components/PWAUpdatePrompt'
|
||||||
|
|
||||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||||
const CURRENT_WEEK_API_URL = '/playlife-feed'
|
const CURRENT_WEEK_API_URL = '/playlife-feed'
|
||||||
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
||||||
|
|
||||||
function App() {
|
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
||||||
|
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
|
||||||
|
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||||
|
|
||||||
|
// Show loading while checking auth
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
|
||||||
|
<div className="text-white text-xl">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show auth screen if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <AuthScreen />
|
||||||
|
}
|
||||||
const [activeTab, setActiveTab] = useState('current')
|
const [activeTab, setActiveTab] = useState('current')
|
||||||
const [selectedProject, setSelectedProject] = useState(null)
|
const [selectedProject, setSelectedProject] = useState(null)
|
||||||
const [loadedTabs, setLoadedTabs] = useState({
|
const [loadedTabs, setLoadedTabs] = useState({
|
||||||
@@ -22,10 +52,18 @@ function App() {
|
|||||||
full: false,
|
full: false,
|
||||||
words: false,
|
words: false,
|
||||||
'add-words': false,
|
'add-words': false,
|
||||||
'test-config': false,
|
dictionaries: false,
|
||||||
'add-config': false,
|
|
||||||
test: false,
|
test: false,
|
||||||
integrations: false,
|
tasks: false,
|
||||||
|
'task-form': false,
|
||||||
|
wishlist: false,
|
||||||
|
'wishlist-form': false,
|
||||||
|
'wishlist-detail': false,
|
||||||
|
'board-form': false,
|
||||||
|
'board-join': false,
|
||||||
|
profile: false,
|
||||||
|
'todoist-integration': false,
|
||||||
|
'telegram-integration': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||||||
@@ -35,10 +73,18 @@ function App() {
|
|||||||
full: false,
|
full: false,
|
||||||
words: false,
|
words: false,
|
||||||
'add-words': false,
|
'add-words': false,
|
||||||
'test-config': false,
|
dictionaries: false,
|
||||||
'add-config': false,
|
|
||||||
test: false,
|
test: false,
|
||||||
integrations: false,
|
tasks: false,
|
||||||
|
'task-form': false,
|
||||||
|
wishlist: false,
|
||||||
|
'wishlist-form': false,
|
||||||
|
'wishlist-detail': false,
|
||||||
|
'board-form': false,
|
||||||
|
'board-join': false,
|
||||||
|
profile: false,
|
||||||
|
'todoist-integration': false,
|
||||||
|
'telegram-integration': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Параметры для навигации между вкладками
|
// Параметры для навигации между вкладками
|
||||||
@@ -47,46 +93,104 @@ function App() {
|
|||||||
// Кеширование данных
|
// Кеширование данных
|
||||||
const [currentWeekData, setCurrentWeekData] = useState(null)
|
const [currentWeekData, setCurrentWeekData] = useState(null)
|
||||||
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
||||||
|
const [tasksData, setTasksData] = useState(null)
|
||||||
|
|
||||||
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
|
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
|
||||||
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
|
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
|
||||||
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
|
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
|
||||||
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
|
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
|
||||||
|
const [tasksLoading, setTasksLoading] = useState(false)
|
||||||
|
|
||||||
// Состояния фоновой загрузки (не показываются визуально)
|
// Состояния фоновой загрузки (не показываются визуально)
|
||||||
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
|
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
|
||||||
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
|
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
|
||||||
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
|
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
|
||||||
|
const [tasksBackgroundLoading, setTasksBackgroundLoading] = useState(false)
|
||||||
|
|
||||||
// Ошибки
|
// Ошибки
|
||||||
const [currentWeekError, setCurrentWeekError] = useState(null)
|
const [currentWeekError, setCurrentWeekError] = useState(null)
|
||||||
const [fullStatisticsError, setFullStatisticsError] = useState(null)
|
const [fullStatisticsError, setFullStatisticsError] = useState(null)
|
||||||
const [prioritiesError, setPrioritiesError] = useState(null)
|
const [prioritiesError, setPrioritiesError] = useState(null)
|
||||||
|
const [tasksError, setTasksError] = useState(null)
|
||||||
|
|
||||||
// Состояние для кнопки Refresh (если она есть)
|
// Состояние для кнопки Refresh (если она есть)
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
||||||
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
|
const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
|
||||||
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
||||||
|
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
// Восстанавливаем последний выбранный таб после перезагрузки
|
// Восстанавливаем последний выбранный таб после перезагрузки
|
||||||
const [isInitialized, setIsInitialized] = useState(false)
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
|
|
||||||
|
// Инициализация из URL (только для глубоких табов) или localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialized) return
|
if (isInitialized) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedTab = window.localStorage?.getItem('activeTab')
|
// Проверяем путь /invite/:token для присоединения к доске
|
||||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'integrations']
|
const path = window.location.pathname
|
||||||
if (savedTab && validTabs.includes(savedTab)) {
|
if (path.startsWith('/invite/')) {
|
||||||
setActiveTab(savedTab)
|
const token = path.replace('/invite/', '')
|
||||||
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
|
if (token) {
|
||||||
setIsInitialized(true)
|
setActiveTab('board-join')
|
||||||
} else {
|
setLoadedTabs(prev => ({ ...prev, 'board-join': true }))
|
||||||
setIsInitialized(true)
|
setTabParams({ inviteToken: token })
|
||||||
|
setIsInitialized(true)
|
||||||
|
// Очищаем путь, оставляем только параметры
|
||||||
|
window.history.replaceState({}, '', '/?tab=board-join&inviteToken=' + token)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем URL только для глубоких табов
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const tabFromUrl = urlParams.get('tab')
|
||||||
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration']
|
||||||
|
|
||||||
|
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
||||||
|
// Если в URL есть глубокий таб, восстанавливаем его
|
||||||
|
setActiveTab(tabFromUrl)
|
||||||
|
setLoadedTabs(prev => ({ ...prev, [tabFromUrl]: true }))
|
||||||
|
|
||||||
|
// Восстанавливаем параметры из URL
|
||||||
|
const params = {}
|
||||||
|
urlParams.forEach((value, key) => {
|
||||||
|
if (key !== 'tab') {
|
||||||
|
try {
|
||||||
|
params[key] = JSON.parse(value)
|
||||||
|
} catch {
|
||||||
|
params[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (Object.keys(params).length > 0) {
|
||||||
|
setTabParams(params)
|
||||||
|
// Если это экран full с selectedProject, восстанавливаем его
|
||||||
|
if (tabFromUrl === 'full' && params.selectedProject) {
|
||||||
|
setSelectedProject(params.selectedProject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если в URL нет глубокого таба, проверяем localStorage для основного таба
|
||||||
|
const savedTab = window.localStorage?.getItem('activeTab')
|
||||||
|
if (savedTab && validTabs.includes(savedTab)) {
|
||||||
|
setActiveTab(savedTab)
|
||||||
|
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
|
||||||
|
}
|
||||||
|
// Очищаем URL от параметров таба, если это основной таб
|
||||||
|
if (tabFromUrl && mainTabs.includes(tabFromUrl)) {
|
||||||
|
const url = new URL(window.location)
|
||||||
|
url.searchParams.delete('tab')
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
url.searchParams.delete(key)
|
||||||
|
})
|
||||||
|
window.history.replaceState({}, '', url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsInitialized(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Не удалось прочитать активный таб из localStorage', err)
|
console.warn('Не удалось прочитать активный таб', err)
|
||||||
setIsInitialized(true)
|
setIsInitialized(true)
|
||||||
}
|
}
|
||||||
}, [isInitialized])
|
}, [isInitialized])
|
||||||
@@ -95,6 +199,91 @@ function App() {
|
|||||||
setLoadedTabs(prev => (prev[tab] ? prev : { ...prev, [tab]: true }))
|
setLoadedTabs(prev => (prev[tab] ? prev : { ...prev, [tab]: true }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Функция для обновления URL (только для глубоких табов)
|
||||||
|
const updateUrl = useCallback((tab, params = {}, previousTab = null) => {
|
||||||
|
if (!deepTabs.includes(tab)) {
|
||||||
|
// Для основных табов не обновляем URL
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(window.location)
|
||||||
|
url.searchParams.set('tab', tab)
|
||||||
|
|
||||||
|
// Удаляем старые параметры таба
|
||||||
|
const keysToRemove = []
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
if (key !== 'tab') {
|
||||||
|
keysToRemove.push(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
keysToRemove.forEach(key => url.searchParams.delete(key))
|
||||||
|
|
||||||
|
// Добавляем новые параметры
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Сохраняем предыдущий таб в state для восстановления при "Назад"
|
||||||
|
window.history.pushState({ tab, params, previousTab }, '', url)
|
||||||
|
}, []) // deepTabs - константа, не нужно в зависимостях
|
||||||
|
|
||||||
|
// Функция для очистки URL (при возврате к основному табу)
|
||||||
|
const clearUrl = useCallback((tab = null, usePushState = false) => {
|
||||||
|
const url = new URL(window.location)
|
||||||
|
const hasTabParam = url.searchParams.has('tab')
|
||||||
|
if (hasTabParam) {
|
||||||
|
url.searchParams.delete('tab')
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
url.searchParams.delete(key)
|
||||||
|
})
|
||||||
|
// Сохраняем текущий таб в state для восстановления при "Назад"
|
||||||
|
if (usePushState && tab) {
|
||||||
|
window.history.pushState({ tab }, '', url)
|
||||||
|
} else {
|
||||||
|
window.history.replaceState(tab ? { tab } : {}, '', url)
|
||||||
|
}
|
||||||
|
} else if (tab) {
|
||||||
|
// Если URL уже чистый, но нужно сохранить state таба
|
||||||
|
if (usePushState) {
|
||||||
|
window.history.pushState({ tab }, '', url)
|
||||||
|
} else {
|
||||||
|
window.history.replaceState({ tab }, '', url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Функция для обновления URL без создания новой записи в истории (для обновления параметров того же таба)
|
||||||
|
const replaceUrl = useCallback((tab, params = {}) => {
|
||||||
|
if (!deepTabs.includes(tab)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(window.location)
|
||||||
|
url.searchParams.set('tab', tab)
|
||||||
|
|
||||||
|
// Удаляем старые параметры таба
|
||||||
|
const keysToRemove = []
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
if (key !== 'tab') {
|
||||||
|
keysToRemove.push(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
keysToRemove.forEach(key => url.searchParams.delete(key))
|
||||||
|
|
||||||
|
// Добавляем новые параметры
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Сохраняем текущий state, чтобы не потерять previousTab
|
||||||
|
const currentState = window.history.state || {}
|
||||||
|
window.history.replaceState({ ...currentState, tab, params }, '', url)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchCurrentWeekData = useCallback(async (isBackground = false) => {
|
const fetchCurrentWeekData = useCallback(async (isBackground = false) => {
|
||||||
try {
|
try {
|
||||||
if (isBackground) {
|
if (isBackground) {
|
||||||
@@ -103,8 +292,7 @@ function App() {
|
|||||||
setCurrentWeekLoading(true)
|
setCurrentWeekLoading(true)
|
||||||
}
|
}
|
||||||
setCurrentWeekError(null)
|
setCurrentWeekError(null)
|
||||||
console.log('Fetching current week data from:', CURRENT_WEEK_API_URL)
|
const response = await authFetch(CURRENT_WEEK_API_URL)
|
||||||
const response = await fetch(CURRENT_WEEK_API_URL)
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка загрузки данных')
|
throw new Error('Ошибка загрузки данных')
|
||||||
}
|
}
|
||||||
@@ -149,7 +337,7 @@ function App() {
|
|||||||
setCurrentWeekLoading(false)
|
setCurrentWeekLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [authFetch])
|
||||||
|
|
||||||
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
|
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
|
||||||
try {
|
try {
|
||||||
@@ -159,7 +347,7 @@ function App() {
|
|||||||
setFullStatisticsLoading(true)
|
setFullStatisticsLoading(true)
|
||||||
}
|
}
|
||||||
setFullStatisticsError(null)
|
setFullStatisticsError(null)
|
||||||
const response = await fetch(FULL_STATISTICS_API_URL)
|
const response = await authFetch(FULL_STATISTICS_API_URL)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка загрузки данных')
|
throw new Error('Ошибка загрузки данных')
|
||||||
}
|
}
|
||||||
@@ -175,7 +363,33 @@ function App() {
|
|||||||
setFullStatisticsLoading(false)
|
setFullStatisticsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [authFetch])
|
||||||
|
|
||||||
|
const fetchTasksData = useCallback(async (isBackground = false) => {
|
||||||
|
try {
|
||||||
|
if (isBackground) {
|
||||||
|
setTasksBackgroundLoading(true)
|
||||||
|
} else {
|
||||||
|
setTasksLoading(true)
|
||||||
|
}
|
||||||
|
setTasksError(null)
|
||||||
|
const response = await authFetch('/api/tasks')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки данных')
|
||||||
|
}
|
||||||
|
const jsonData = await response.json()
|
||||||
|
setTasksData(jsonData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки списка задач:', err)
|
||||||
|
setTasksError(err.message || 'Ошибка загрузки данных')
|
||||||
|
} finally {
|
||||||
|
if (isBackground) {
|
||||||
|
setTasksBackgroundLoading(false)
|
||||||
|
} else {
|
||||||
|
setTasksLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [authFetch])
|
||||||
|
|
||||||
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
||||||
const tabsInitializedRef = useRef({
|
const tabsInitializedRef = useRef({
|
||||||
@@ -184,16 +398,20 @@ function App() {
|
|||||||
full: false,
|
full: false,
|
||||||
words: false,
|
words: false,
|
||||||
'add-words': false,
|
'add-words': false,
|
||||||
'test-config': false,
|
dictionaries: false,
|
||||||
'add-config': false,
|
|
||||||
test: false,
|
test: false,
|
||||||
integrations: false,
|
tasks: false,
|
||||||
|
'task-form': false,
|
||||||
|
profile: false,
|
||||||
|
'todoist-integration': false,
|
||||||
|
'telegram-integration': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
||||||
const cacheRef = useRef({
|
const cacheRef = useRef({
|
||||||
current: null,
|
current: null,
|
||||||
full: null,
|
full: null,
|
||||||
|
tasks: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Обновляем ref при изменении данных
|
// Обновляем ref при изменении данных
|
||||||
@@ -205,6 +423,10 @@ function App() {
|
|||||||
cacheRef.current.full = fullStatisticsData
|
cacheRef.current.full = fullStatisticsData
|
||||||
}, [fullStatisticsData])
|
}, [fullStatisticsData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cacheRef.current.tasks = tasksData
|
||||||
|
}, [tasksData])
|
||||||
|
|
||||||
// Функция для загрузки данных таба
|
// Функция для загрузки данных таба
|
||||||
const loadTabData = useCallback((tab, isBackground = false) => {
|
const loadTabData = useCallback((tab, isBackground = false) => {
|
||||||
if (tab === 'current') {
|
if (tab === 'current') {
|
||||||
@@ -246,20 +468,33 @@ function App() {
|
|||||||
// Возврат на таб - фоновая загрузка
|
// Возврат на таб - фоновая загрузка
|
||||||
setPrioritiesRefreshTrigger(prev => prev + 1)
|
setPrioritiesRefreshTrigger(prev => prev + 1)
|
||||||
}
|
}
|
||||||
} else if (tab === 'test-config') {
|
} else if (tab === 'dictionaries') {
|
||||||
const isInitialized = tabsInitializedRef.current['test-config']
|
const isInitialized = tabsInitializedRef.current['dictionaries']
|
||||||
|
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
// Первая загрузка таба
|
// Первая загрузка таба
|
||||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
setDictionariesRefreshTrigger(prev => prev + 1)
|
||||||
tabsInitializedRef.current['test-config'] = true
|
tabsInitializedRef.current['dictionaries'] = true
|
||||||
setTabsInitialized(prev => ({ ...prev, 'test-config': true }))
|
setTabsInitialized(prev => ({ ...prev, 'dictionaries': true }))
|
||||||
} else if (isBackground) {
|
} else if (isBackground) {
|
||||||
// Возврат на таб - фоновая загрузка
|
// Возврат на таб - фоновая загрузка
|
||||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
setDictionariesRefreshTrigger(prev => prev + 1)
|
||||||
|
}
|
||||||
|
} else if (tab === 'tasks') {
|
||||||
|
const hasCache = cacheRef.current.tasks !== null
|
||||||
|
const isInitialized = tabsInitializedRef.current.tasks
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
// Первая загрузка таба - загружаем с индикатором
|
||||||
|
fetchTasksData(false)
|
||||||
|
tabsInitializedRef.current.tasks = true
|
||||||
|
setTabsInitialized(prev => ({ ...prev, tasks: true }))
|
||||||
|
} else if (hasCache && isBackground) {
|
||||||
|
// Возврат на таб с кешем - фоновая загрузка
|
||||||
|
fetchTasksData(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [fetchCurrentWeekData, fetchFullStatisticsData])
|
}, [fetchCurrentWeekData, fetchFullStatisticsData, fetchTasksData])
|
||||||
|
|
||||||
// Функция для обновления всех данных (для кнопки Refresh, если она есть)
|
// Функция для обновления всех данных (для кнопки Refresh, если она есть)
|
||||||
const refreshAllData = useCallback(async () => {
|
const refreshAllData = useCallback(async () => {
|
||||||
@@ -280,6 +515,82 @@ function App() {
|
|||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}, [fetchCurrentWeekData, fetchFullStatisticsData])
|
}, [fetchCurrentWeekData, fetchFullStatisticsData])
|
||||||
|
|
||||||
|
// Обработчик кнопки "назад" в браузере (только для глубоких табов)
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePopState = (event) => {
|
||||||
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
||||||
|
|
||||||
|
// Проверяем state текущей записи истории (куда мы вернулись)
|
||||||
|
if (event.state && event.state.tab) {
|
||||||
|
const { tab, params = {} } = event.state
|
||||||
|
|
||||||
|
if (validTabs.includes(tab)) {
|
||||||
|
setActiveTab(tab)
|
||||||
|
setTabParams(params)
|
||||||
|
markTabAsLoaded(tab)
|
||||||
|
// Если это экран full с selectedProject, восстанавливаем его
|
||||||
|
if (tab === 'full' && params.selectedProject) {
|
||||||
|
setSelectedProject(params.selectedProject)
|
||||||
|
} else if (tab === 'full') {
|
||||||
|
setSelectedProject(null)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если state пустой или не содержит таб, пытаемся восстановить из URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const tabFromUrl = urlParams.get('tab')
|
||||||
|
|
||||||
|
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
||||||
|
// Если в URL есть глубокий таб, восстанавливаем его
|
||||||
|
setActiveTab(tabFromUrl)
|
||||||
|
markTabAsLoaded(tabFromUrl)
|
||||||
|
|
||||||
|
const params = {}
|
||||||
|
urlParams.forEach((value, key) => {
|
||||||
|
if (key !== 'tab') {
|
||||||
|
try {
|
||||||
|
params[key] = JSON.parse(value)
|
||||||
|
} catch {
|
||||||
|
params[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setTabParams(params)
|
||||||
|
// Если это экран full с selectedProject, восстанавливаем его
|
||||||
|
if (tabFromUrl === 'full' && params.selectedProject) {
|
||||||
|
setSelectedProject(params.selectedProject)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если в URL нет глубокого таба, значит мы вернулись на основной таб
|
||||||
|
// Проверяем state - если там есть tab, используем его
|
||||||
|
if (event.state && event.state.tab && validTabs.includes(event.state.tab)) {
|
||||||
|
setActiveTab(event.state.tab)
|
||||||
|
setTabParams({})
|
||||||
|
markTabAsLoaded(event.state.tab)
|
||||||
|
setSelectedProject(null)
|
||||||
|
clearUrl(event.state.tab)
|
||||||
|
} else {
|
||||||
|
// Если state пустой, используем сохраненный таб из localStorage
|
||||||
|
const savedTab = window.localStorage?.getItem('activeTab')
|
||||||
|
const validMainTab = savedTab && validTabs.includes(savedTab) ? savedTab : 'current'
|
||||||
|
setActiveTab(validMainTab)
|
||||||
|
setTabParams({})
|
||||||
|
markTabAsLoaded(validMainTab)
|
||||||
|
setSelectedProject(null)
|
||||||
|
clearUrl(validMainTab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', handlePopState)
|
||||||
|
}
|
||||||
|
}, [markTabAsLoaded, clearUrl]) // mainTabs и deepTabs - константы, не нужно в зависимостях
|
||||||
|
|
||||||
// Обновляем данные при возвращении экрана в фокус (фоново)
|
// Обновляем данные при возвращении экрана в фокус (фоново)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
@@ -301,6 +612,8 @@ function App() {
|
|||||||
const handleProjectClick = (projectName) => {
|
const handleProjectClick = (projectName) => {
|
||||||
setSelectedProject(projectName)
|
setSelectedProject(projectName)
|
||||||
markTabAsLoaded('full')
|
markTabAsLoaded('full')
|
||||||
|
setTabParams({ selectedProject: projectName })
|
||||||
|
updateUrl('full', { selectedProject: projectName }, activeTab)
|
||||||
setActiveTab('full')
|
setActiveTab('full')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,14 +621,46 @@ function App() {
|
|||||||
if (tab === 'full' && activeTab === 'full') {
|
if (tab === 'full' && activeTab === 'full') {
|
||||||
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
|
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
|
||||||
setSelectedProject(null)
|
setSelectedProject(null)
|
||||||
} else if (tab !== activeTab) {
|
setTabParams({})
|
||||||
|
updateUrl('full', {}, activeTab)
|
||||||
|
} else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form') {
|
||||||
|
// Для task-form и wishlist-form всегда обновляем параметры, даже если это тот же таб
|
||||||
markTabAsLoaded(tab)
|
markTabAsLoaded(tab)
|
||||||
// Сбрасываем tabParams при переходе с add-config на другой таб
|
|
||||||
if (activeTab === 'add-config' && tab !== 'add-config') {
|
// Определяем, является ли текущий таб глубоким
|
||||||
setTabParams({})
|
const isCurrentTabDeep = deepTabs.includes(activeTab)
|
||||||
} else {
|
const isNewTabDeep = deepTabs.includes(tab)
|
||||||
setTabParams(params)
|
const isCurrentTabMain = mainTabs.includes(activeTab)
|
||||||
|
const isNewTabMain = mainTabs.includes(tab)
|
||||||
|
|
||||||
|
{
|
||||||
|
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
|
||||||
|
// task-form может иметь taskId (редактирование), wishlistId (создание из желания), или returnTo (возврат после создания)
|
||||||
|
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined
|
||||||
|
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && params.boardId === undefined
|
||||||
|
if (isTaskFormWithNoParams || isWishlistFormWithNoParams) {
|
||||||
|
setTabParams({})
|
||||||
|
if (isNewTabMain) {
|
||||||
|
clearUrl()
|
||||||
|
} else if (isNewTabDeep) {
|
||||||
|
updateUrl(tab, {}, activeTab)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTabParams(params)
|
||||||
|
// Обновляем URL только для глубоких табов
|
||||||
|
if (isNewTabDeep) {
|
||||||
|
// Сохраняем текущий таб как предыдущий при переходе на глубокий таб
|
||||||
|
updateUrl(tab, params, activeTab)
|
||||||
|
} else if (isNewTabMain && isCurrentTabDeep) {
|
||||||
|
// При переходе с глубокого таба на основной - очищаем URL и сохраняем таб в state
|
||||||
|
clearUrl(tab)
|
||||||
|
} else if (isNewTabMain && isCurrentTabMain) {
|
||||||
|
// При переходе между основными табами - сохраняем таб в state без изменения URL, НЕ создаем новую запись в истории
|
||||||
|
clearUrl(tab, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveTab(tab)
|
setActiveTab(tab)
|
||||||
if (tab === 'current') {
|
if (tab === 'current') {
|
||||||
setSelectedProject(null)
|
setSelectedProject(null)
|
||||||
@@ -324,6 +669,21 @@ function App() {
|
|||||||
if (activeTab === 'add-words' && tab === 'words') {
|
if (activeTab === 'add-words' && tab === 'words') {
|
||||||
setWordsRefreshTrigger(prev => prev + 1)
|
setWordsRefreshTrigger(prev => prev + 1)
|
||||||
}
|
}
|
||||||
|
// Обновляем список задач при возврате из экрана редактирования
|
||||||
|
// Используем фоновую загрузку, чтобы не показывать индикатор загрузки
|
||||||
|
if (activeTab === 'task-form' && tab === 'tasks') {
|
||||||
|
fetchTasksData(true)
|
||||||
|
}
|
||||||
|
// Обновляем список желаний при возврате из экрана редактирования
|
||||||
|
if (activeTab === 'wishlist-form' && tab === 'wishlist') {
|
||||||
|
// Сохраняем boardId из параметров или текущих tabParams
|
||||||
|
const savedBoardId = params.boardId || tabParams.boardId
|
||||||
|
// Параметры уже установлены в строке 649, но мы можем их обновить, чтобы сохранить boardId
|
||||||
|
if (savedBoardId) {
|
||||||
|
setTabParams(prev => ({ ...prev, boardId: savedBoardId }))
|
||||||
|
}
|
||||||
|
setWishlistRefreshTrigger(prev => prev + 1)
|
||||||
|
}
|
||||||
// Загрузка данных произойдет в useEffect при изменении activeTab
|
// Загрузка данных произойдет в useEffect при изменении activeTab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,12 +736,37 @@ function App() {
|
|||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
||||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config'
|
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'dictionaries'
|
||||||
|
|
||||||
|
// Определяем отступы для контейнера
|
||||||
|
const getContainerPadding = () => {
|
||||||
|
if (!isFullscreenTab) {
|
||||||
|
// Для tasks и profile на широких экранах увеличиваем отступ
|
||||||
|
if (activeTab === 'tasks' || activeTab === 'profile') {
|
||||||
|
return 'p-4 md:p-8'
|
||||||
|
}
|
||||||
|
return 'p-4 md:p-6'
|
||||||
|
}
|
||||||
|
// Для экрана статистики используем такие же отступы как для приоритетов
|
||||||
|
if (activeTab === 'full') {
|
||||||
|
return 'px-4 md:px-8 py-0'
|
||||||
|
}
|
||||||
|
// Для экрана приоритетов используем такие же отступы как для profile
|
||||||
|
if (activeTab === 'priorities') {
|
||||||
|
return 'px-4 md:px-8 py-0'
|
||||||
|
}
|
||||||
|
// Для экрана словарей используем такие же отступы как для приоритетов
|
||||||
|
if (activeTab === 'dictionaries') {
|
||||||
|
return 'px-4 md:px-8 py-0'
|
||||||
|
}
|
||||||
|
// Для остальных fullscreen экранов без отступов
|
||||||
|
return 'p-0'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen min-h-dvh">
|
<div className="flex flex-col h-screen h-dvh overflow-hidden">
|
||||||
<div className={`flex-1 ${isFullscreenTab ? 'pb-0' : 'pb-20'}`}>
|
<div className={`flex-1 overflow-y-auto ${isFullscreenTab ? 'pb-0' : 'pb-20'}`}>
|
||||||
<div className={`max-w-7xl mx-auto ${isFullscreenTab ? 'p-0' : 'p-4 md:p-6'}`}>
|
<div className={`max-w-7xl mx-auto ${getContainerPadding()}`}>
|
||||||
{loadedTabs.current && (
|
{loadedTabs.current && (
|
||||||
<div className={activeTab === 'current' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'current' ? 'block' : 'hidden'}>
|
||||||
<CurrentWeek
|
<CurrentWeek
|
||||||
@@ -414,7 +799,11 @@ function App() {
|
|||||||
<div className={activeTab === 'full' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'full' ? 'block' : 'hidden'}>
|
||||||
<FullStatistics
|
<FullStatistics
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
onClearSelection={() => setSelectedProject(null)}
|
onClearSelection={() => {
|
||||||
|
setSelectedProject(null)
|
||||||
|
setTabParams({})
|
||||||
|
replaceUrl('full', {})
|
||||||
|
}}
|
||||||
data={fullStatisticsData}
|
data={fullStatisticsData}
|
||||||
loading={fullStatisticsLoading}
|
loading={fullStatisticsLoading}
|
||||||
error={fullStatisticsError}
|
error={fullStatisticsError}
|
||||||
@@ -446,21 +835,11 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loadedTabs['test-config'] && (
|
{loadedTabs.dictionaries && (
|
||||||
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'dictionaries' ? 'block' : 'hidden'}>
|
||||||
<TestConfigSelection
|
<DictionaryList
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
refreshTrigger={testConfigRefreshTrigger}
|
refreshTrigger={dictionariesRefreshTrigger}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loadedTabs['add-config'] && (
|
|
||||||
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
|
|
||||||
<AddConfig
|
|
||||||
key={tabParams.config?.id || 'new'}
|
|
||||||
onNavigate={handleNavigate}
|
|
||||||
editingConfig={tabParams.config}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -472,20 +851,118 @@ function App() {
|
|||||||
wordCount={tabParams.wordCount}
|
wordCount={tabParams.wordCount}
|
||||||
configId={tabParams.configId}
|
configId={tabParams.configId}
|
||||||
maxCards={tabParams.maxCards}
|
maxCards={tabParams.maxCards}
|
||||||
|
taskId={tabParams.taskId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loadedTabs.integrations && (
|
{loadedTabs.tasks && (
|
||||||
<div className={activeTab === 'integrations' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'tasks' ? 'block' : 'hidden'}>
|
||||||
<Integrations onNavigate={handleNavigate} />
|
<TaskList
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
data={tasksData}
|
||||||
|
loading={tasksLoading}
|
||||||
|
backgroundLoading={tasksBackgroundLoading}
|
||||||
|
error={tasksError}
|
||||||
|
onRetry={() => fetchTasksData(false)}
|
||||||
|
onRefresh={(isBackground = false) => fetchTasksData(isBackground)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['task-form'] && (
|
||||||
|
<div className={activeTab === 'task-form' ? 'block' : 'hidden'}>
|
||||||
|
<TaskForm
|
||||||
|
key={tabParams.taskId || 'new'}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
taskId={tabParams.taskId}
|
||||||
|
wishlistId={tabParams.wishlistId}
|
||||||
|
returnTo={tabParams.returnTo}
|
||||||
|
returnWishlistId={tabParams.returnWishlistId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs.wishlist && (
|
||||||
|
<div className={activeTab === 'wishlist' ? 'block' : 'hidden'}>
|
||||||
|
<Wishlist
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
refreshTrigger={wishlistRefreshTrigger}
|
||||||
|
isActive={activeTab === 'wishlist'}
|
||||||
|
initialBoardId={tabParams.boardId}
|
||||||
|
boardDeleted={tabParams.boardDeleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['wishlist-form'] && (
|
||||||
|
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
|
||||||
|
<WishlistForm
|
||||||
|
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}-${tabParams.newTaskId ?? ''}-${tabParams.boardId ?? ''}`}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
wishlistId={tabParams.wishlistId}
|
||||||
|
editConditionIndex={tabParams.editConditionIndex}
|
||||||
|
newTaskId={tabParams.newTaskId}
|
||||||
|
boardId={tabParams.boardId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['wishlist-detail'] && (
|
||||||
|
<div className={activeTab === 'wishlist-detail' ? 'block' : 'hidden'}>
|
||||||
|
<WishlistDetail
|
||||||
|
key={tabParams.wishlistId}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
wishlistId={tabParams.wishlistId}
|
||||||
|
boardId={tabParams.boardId}
|
||||||
|
onRefresh={() => setWishlistRefreshTrigger(prev => prev + 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['board-form'] && (
|
||||||
|
<div className={activeTab === 'board-form' ? 'block' : 'hidden'}>
|
||||||
|
<BoardForm
|
||||||
|
key={tabParams.boardId || 'new'}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
boardId={tabParams.boardId}
|
||||||
|
onSaved={() => setWishlistRefreshTrigger(prev => prev + 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['board-join'] && (
|
||||||
|
<div className={activeTab === 'board-join' ? 'block' : 'hidden'}>
|
||||||
|
<BoardJoinPreview
|
||||||
|
key={tabParams.inviteToken}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
inviteToken={tabParams.inviteToken}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs.profile && (
|
||||||
|
<div className={activeTab === 'profile' ? 'block' : 'hidden'}>
|
||||||
|
<Profile onNavigate={handleNavigate} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['todoist-integration'] && (
|
||||||
|
<div className={activeTab === 'todoist-integration' ? 'block' : 'hidden'}>
|
||||||
|
<TodoistIntegration onNavigate={handleNavigate} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['telegram-integration'] && (
|
||||||
|
<div className={activeTab === 'telegram-integration' ? 'block' : 'hidden'}>
|
||||||
|
<TelegramIntegration onNavigate={handleNavigate} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isFullscreenTab && (
|
{!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="fixed bottom-0 left-0 right-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 w-full" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('current')}
|
onClick={() => handleTabChange('current')}
|
||||||
@@ -509,43 +986,62 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('test-config')}
|
onClick={() => handleTabChange('tasks')}
|
||||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||||
activeTab === 'test-config' || activeTab === 'test'
|
activeTab === 'tasks' || activeTab === 'task-form'
|
||||||
? 'text-indigo-700 bg-white/50'
|
? 'text-indigo-700 bg-white/50'
|
||||||
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||||
}`}
|
}`}
|
||||||
title="Тест"
|
title="Задачи"
|
||||||
>
|
>
|
||||||
<span className="relative z-10 flex items-center justify-center">
|
<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">
|
<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="M9 11l3 3L22 4"></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="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||||
<path d="M8 7h6"></path>
|
|
||||||
<path d="M8 11h4"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{(activeTab === 'test-config' || activeTab === 'test') && (
|
{(activeTab === 'tasks' || activeTab === 'task-form') && (
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange('integrations')}
|
onClick={() => handleTabChange('wishlist')}
|
||||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||||
activeTab === 'integrations'
|
activeTab === 'wishlist' || activeTab === 'wishlist-form'
|
||||||
? 'text-indigo-700 bg-white/50'
|
? 'text-indigo-700 bg-white/50'
|
||||||
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||||
}`}
|
}`}
|
||||||
title="Интеграции"
|
title="Желания"
|
||||||
>
|
>
|
||||||
<span className="relative z-10 flex items-center justify-center">
|
<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">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
<rect x="2" y="7" width="20" height="5"></rect>
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
<line x1="12" y1="22" x2="12" y2="7"></line>
|
||||||
|
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
||||||
|
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{activeTab === 'integrations' && (
|
{(activeTab === 'wishlist' || activeTab === 'wishlist-form') && (
|
||||||
|
<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('profile')}
|
||||||
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||||
|
activeTab === 'profile'
|
||||||
|
? '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="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{activeTab === 'profile' && (
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -556,6 +1052,15 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
<PWAUpdatePrompt />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './AddWords.css'
|
import './AddWords.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [markdownText, setMarkdownText] = useState('')
|
const [markdownText, setMarkdownText] = useState('')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -81,7 +83,7 @@ function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
|
|||||||
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
|
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/words`, {
|
const response = await authFetch(`${API_URL}/words`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
170
play-life-web/src/components/BoardForm.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
.board-form {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-form h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.toggle-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-field input[type="checkbox"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 26px;
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 13px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-field input:checked + .toggle-slider {
|
||||||
|
background: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-field input:checked + .toggle-slider::after {
|
||||||
|
transform: translateX(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invite link section */
|
||||||
|
.invite-link-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-url-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-url-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #374151;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regenerate-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regenerate-btn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete button */
|
||||||
|
.delete-board-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-board-btn:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
279
play-life-web/src/components/BoardForm.jsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import BoardMembers from './BoardMembers'
|
||||||
|
import Toast from './Toast'
|
||||||
|
import './BoardForm.css'
|
||||||
|
|
||||||
|
function BoardForm({ boardId, onNavigate, onSaved }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [inviteEnabled, setInviteEnabled] = useState(false)
|
||||||
|
const [inviteURL, setInviteURL] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [loadingBoard, setLoadingBoard] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
|
||||||
|
const isEdit = !!boardId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (boardId) {
|
||||||
|
fetchBoard()
|
||||||
|
}
|
||||||
|
}, [boardId])
|
||||||
|
|
||||||
|
const fetchBoard = async () => {
|
||||||
|
setLoadingBoard(true)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/boards/${boardId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setName(data.name)
|
||||||
|
setInviteEnabled(data.invite_enabled)
|
||||||
|
setInviteURL(data.invite_url || '')
|
||||||
|
} else {
|
||||||
|
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoadingBoard(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setToastMessage({ text: 'Введите название доски', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const url = boardId
|
||||||
|
? `/api/wishlist/boards/${boardId}`
|
||||||
|
: '/api/wishlist/boards'
|
||||||
|
|
||||||
|
const res = await authFetch(url, {
|
||||||
|
method: boardId ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
invite_enabled: inviteEnabled
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.invite_url) {
|
||||||
|
setInviteURL(data.invite_url)
|
||||||
|
}
|
||||||
|
onSaved?.()
|
||||||
|
if (!boardId) {
|
||||||
|
// При создании возвращаемся назад
|
||||||
|
onNavigate('wishlist', { boardId: data.id })
|
||||||
|
} else {
|
||||||
|
// При редактировании возвращаемся на доску
|
||||||
|
onNavigate('wishlist', { boardId: boardId })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegenerateLink = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/boards/${boardId}/regenerate-invite`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setInviteURL(data.invite_url)
|
||||||
|
setInviteEnabled(true)
|
||||||
|
setToastMessage({ text: 'Ссылка обновлена', type: 'success' })
|
||||||
|
} else {
|
||||||
|
setToastMessage({ text: 'Ошибка обновления ссылки', type: 'error' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка', type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
navigator.clipboard.writeText(inviteURL)
|
||||||
|
setCopied(true)
|
||||||
|
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleInvite = async (enabled) => {
|
||||||
|
setInviteEnabled(enabled)
|
||||||
|
|
||||||
|
if (boardId && enabled && !inviteURL) {
|
||||||
|
// Автоматически генерируем ссылку при включении
|
||||||
|
await handleRegenerateLink()
|
||||||
|
} else if (boardId) {
|
||||||
|
// Просто обновляем статус
|
||||||
|
try {
|
||||||
|
await authFetch(`/api/wishlist/boards/${boardId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ invite_enabled: enabled })
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating invite status:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/boards/${boardId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
onSaved?.()
|
||||||
|
// Передаём флаг, что доска удалена, чтобы Wishlist выбрал первую доступную
|
||||||
|
onNavigate('wishlist', { boardDeleted: true })
|
||||||
|
} else {
|
||||||
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onNavigate('wishlist')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingBoard) {
|
||||||
|
return (
|
||||||
|
<div className="board-form">
|
||||||
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="board-form">
|
||||||
|
<button className="close-x-button" onClick={handleClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
|
||||||
|
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="board-name">Название</label>
|
||||||
|
<input
|
||||||
|
id="board-name"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Название доски"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEdit && (
|
||||||
|
<>
|
||||||
|
{/* Настройки доступа */}
|
||||||
|
<div className="form-section">
|
||||||
|
<h3>Доступ по ссылке</h3>
|
||||||
|
|
||||||
|
<label className="toggle-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteEnabled}
|
||||||
|
onChange={e => handleToggleInvite(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
<span className="toggle-label">Разрешить присоединение по ссылке</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{inviteEnabled && inviteURL && (
|
||||||
|
<div className="invite-link-section">
|
||||||
|
<div className="invite-url-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="invite-url-input"
|
||||||
|
value={inviteURL}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="copy-btn"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
title="Копировать ссылку"
|
||||||
|
>
|
||||||
|
{copied ? '✓' : '📋'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="regenerate-btn"
|
||||||
|
onClick={handleRegenerateLink}
|
||||||
|
>
|
||||||
|
🔄 Перегенерировать ссылку
|
||||||
|
</button>
|
||||||
|
<p className="invite-hint">
|
||||||
|
Пользователь, открывший ссылку, сможет присоединиться к доске
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список участников */}
|
||||||
|
<BoardMembers
|
||||||
|
boardId={boardId}
|
||||||
|
onMemberRemoved={() => {
|
||||||
|
setToastMessage({ text: 'Участник удалён', type: 'success' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="cancel-button" onClick={handleClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="submit-button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading || !name.trim()}
|
||||||
|
>
|
||||||
|
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEdit && (
|
||||||
|
<button className="delete-board-btn" onClick={handleDelete}>
|
||||||
|
🗑 Удалить доску
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage.text}
|
||||||
|
type={toastMessage.type}
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BoardForm
|
||||||
|
|
||||||
199
play-life-web/src/components/BoardJoinPreview.css
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
.board-join-preview {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card.error-card {
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-info {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-owner,
|
||||||
|
.board-members {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-owner .value,
|
||||||
|
.board-members .value {
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #ef4444;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-prompt {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-prompt p {
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-link {
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-link:hover {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
156
play-life-web/src/components/BoardJoinPreview.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import './BoardJoinPreview.css'
|
||||||
|
|
||||||
|
function BoardJoinPreview({ inviteToken, onNavigate }) {
|
||||||
|
const { authFetch, user } = useAuth()
|
||||||
|
const [board, setBoard] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [joining, setJoining] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inviteToken) {
|
||||||
|
fetchBoardInfo()
|
||||||
|
}
|
||||||
|
}, [inviteToken])
|
||||||
|
|
||||||
|
const fetchBoardInfo = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/invite/${inviteToken}`)
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setBoard(await res.json())
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
setError(err.error || 'Ссылка недействительна или устарела')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка загрузки')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJoin = async () => {
|
||||||
|
if (!user) {
|
||||||
|
// Сохраняем токен для возврата после логина
|
||||||
|
sessionStorage.setItem('pendingInviteToken', inviteToken)
|
||||||
|
onNavigate('login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setJoining(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/invite/${inviteToken}/join`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
// Переходим на доску
|
||||||
|
onNavigate('wishlist', { boardId: data.board.id })
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
setError(err.error || 'Ошибка при присоединении')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка при присоединении')
|
||||||
|
} finally {
|
||||||
|
setJoining(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGoBack = () => {
|
||||||
|
onNavigate('wishlist')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="board-join-preview">
|
||||||
|
<div className="preview-loading">
|
||||||
|
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !board) {
|
||||||
|
return (
|
||||||
|
<div className="board-join-preview">
|
||||||
|
<div className="preview-card error-card">
|
||||||
|
<div className="error-icon">❌</div>
|
||||||
|
<h2>Ошибка</h2>
|
||||||
|
<p className="error-text">{error}</p>
|
||||||
|
<button className="back-btn" onClick={handleGoBack}>
|
||||||
|
Вернуться к желаниям
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="board-join-preview">
|
||||||
|
<div className="preview-card">
|
||||||
|
<div className="invite-icon">✨</div>
|
||||||
|
<h2>Приглашение на доску</h2>
|
||||||
|
|
||||||
|
<div className="board-info">
|
||||||
|
<div className="board-name">{board.name}</div>
|
||||||
|
<div className="board-owner">
|
||||||
|
<span className="label">Владелец:</span>
|
||||||
|
<span className="value">{board.owner_name}</span>
|
||||||
|
</div>
|
||||||
|
{board.member_count > 0 && (
|
||||||
|
<div className="board-members">
|
||||||
|
<span className="label">Участников:</span>
|
||||||
|
<span className="value">{board.member_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="join-error">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<button
|
||||||
|
className="join-btn"
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={joining}
|
||||||
|
>
|
||||||
|
{joining ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-small"></span>
|
||||||
|
<span>Присоединение...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>🎉</span>
|
||||||
|
<span>Присоединиться</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="login-prompt">
|
||||||
|
<p>Для присоединения необходимо войти в аккаунт</p>
|
||||||
|
<button className="login-btn" onClick={() => onNavigate('login')}>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="cancel-link" onClick={handleGoBack}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BoardJoinPreview
|
||||||
|
|
||||||
132
play-life-web/src/components/BoardMembers.css
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
.board-members-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-members-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-loading {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-members {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-members p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-members .hint {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-date {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-member-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-member-btn:hover:not(:disabled) {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-member-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
border-top-color: #6366f1;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
113
play-life-web/src/components/BoardMembers.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import './BoardMembers.css'
|
||||||
|
|
||||||
|
function BoardMembers({ boardId, onMemberRemoved }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [members, setMembers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [removingId, setRemovingId] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (boardId) {
|
||||||
|
fetchMembers()
|
||||||
|
}
|
||||||
|
}, [boardId])
|
||||||
|
|
||||||
|
const fetchMembers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/boards/${boardId}/members`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setMembers(data || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching members:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveMember = async (userId) => {
|
||||||
|
if (!window.confirm('Удалить участника из доски?')) return
|
||||||
|
|
||||||
|
setRemovingId(userId)
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/wishlist/boards/${boardId}/members/${userId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setMembers(members.filter(m => m.user_id !== userId))
|
||||||
|
onMemberRemoved?.()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error removing member:', err)
|
||||||
|
} finally {
|
||||||
|
setRemovingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="board-members-section">
|
||||||
|
<h3>Участники</h3>
|
||||||
|
<div className="members-loading">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="board-members-section">
|
||||||
|
<h3>Участники ({members.length})</h3>
|
||||||
|
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div className="no-members">
|
||||||
|
<p>Пока никто не присоединился к доске</p>
|
||||||
|
<p className="hint">Поделитесь ссылкой, чтобы пригласить участников</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="members-list">
|
||||||
|
{members.map(member => (
|
||||||
|
<div key={member.id} className="member-item">
|
||||||
|
<div className="member-avatar">
|
||||||
|
{(member.name || member.email).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="member-info">
|
||||||
|
<div className="member-name">
|
||||||
|
{member.name || member.email}
|
||||||
|
</div>
|
||||||
|
<div className="member-date">
|
||||||
|
Присоединился {formatDate(member.joined_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="remove-member-btn"
|
||||||
|
onClick={() => handleRemoveMember(member.user_id)}
|
||||||
|
disabled={removingId === member.user_id}
|
||||||
|
title="Удалить участника"
|
||||||
|
>
|
||||||
|
{removingId === member.user_id ? (
|
||||||
|
<span className="spinner-small"></span>
|
||||||
|
) : (
|
||||||
|
'✕'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BoardMembers
|
||||||
|
|
||||||
242
play-life-web/src/components/BoardSelector.css
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
.board-selector {
|
||||||
|
position: relative;
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Дополнительный отступ сверху на больших экранах, чтобы соответствовать кнопке "Добавить" на экране задач */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.board-selector {
|
||||||
|
margin-top: 0.5rem; /* 8px - разница между md:p-8 (32px) и md:p-6 (24px) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Основная кнопка-pill */
|
||||||
|
.board-pill {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 26px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-pill:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-pill:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-pill.open {
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2), 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-pill:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка действия (настройки/выход) */
|
||||||
|
.board-action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
color: #6b7280;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-action-btn:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #374151;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-action-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-action-btn svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Выпадающий список */
|
||||||
|
.board-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12), 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px) scale(0.98);
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-dropdown.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-list {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Элементы списка */
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.selected {
|
||||||
|
background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%);
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.selected:hover {
|
||||||
|
background: linear-gradient(135deg, #667eea18 0%, #764ba218 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badge {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-members {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 13px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка добавления доски */
|
||||||
|
.dropdown-item.add-board {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: 500;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.add-board:hover {
|
||||||
|
background: linear-gradient(135deg, #667eea08 0%, #764ba208 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.add-board svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
132
play-life-web/src/components/BoardSelector.jsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import './BoardSelector.css'
|
||||||
|
|
||||||
|
function BoardSelector({
|
||||||
|
boards,
|
||||||
|
selectedBoardId,
|
||||||
|
onBoardChange,
|
||||||
|
onBoardEdit,
|
||||||
|
onAddBoard,
|
||||||
|
loading
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef(null)
|
||||||
|
|
||||||
|
const selectedBoard = boards.find(b => b.id === selectedBoardId)
|
||||||
|
|
||||||
|
// Закрытие при клике снаружи
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectBoard = (board) => {
|
||||||
|
onBoardChange(board.id)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="board-selector" ref={dropdownRef}>
|
||||||
|
<div className="board-header">
|
||||||
|
<button
|
||||||
|
className={`board-pill ${isOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{!selectedBoard?.is_owner && selectedBoard && (
|
||||||
|
<span className="shared-icon">👥</span>
|
||||||
|
)}
|
||||||
|
<span className="board-label">
|
||||||
|
{loading ? 'Загрузка...' : (selectedBoard?.name || 'Выберите доску')}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`chevron ${isOpen ? 'rotated' : ''}`}
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2.5 4.5L6 8L9.5 4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{selectedBoard && (
|
||||||
|
<button
|
||||||
|
className="board-action-btn"
|
||||||
|
onClick={onBoardEdit}
|
||||||
|
title={selectedBoard.is_owner ? 'Настройки доски' : 'Покинуть доску'}
|
||||||
|
>
|
||||||
|
{selectedBoard.is_owner ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="1.5"></circle>
|
||||||
|
<circle cx="19" cy="12" r="1.5"></circle>
|
||||||
|
<circle cx="5" cy="12" r="1.5"></circle>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||||
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`board-dropdown ${isOpen ? 'visible' : ''}`}>
|
||||||
|
<div className="dropdown-content">
|
||||||
|
{boards.length === 0 ? (
|
||||||
|
<div className="dropdown-empty">
|
||||||
|
Нет досок
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dropdown-list">
|
||||||
|
{boards.map(board => (
|
||||||
|
<button
|
||||||
|
key={board.id}
|
||||||
|
className={`dropdown-item ${board.id === selectedBoardId ? 'selected' : ''}`}
|
||||||
|
onClick={() => handleSelectBoard(board)}
|
||||||
|
>
|
||||||
|
<span className="item-name">{board.name}</span>
|
||||||
|
<div className="item-meta">
|
||||||
|
{!board.is_owner && <span className="item-badge shared">👥</span>}
|
||||||
|
{board.member_count > 0 && (
|
||||||
|
<span className="item-members">{board.member_count}</span>
|
||||||
|
)}
|
||||||
|
{board.id === selectedBoardId && (
|
||||||
|
<svg className="check-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="dropdown-item add-board" onClick={onAddBoard}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="16"></line>
|
||||||
|
<line x1="8" y1="12" x2="16" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
<span>Создать доску</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BoardSelector
|
||||||
@@ -1,37 +1,25 @@
|
|||||||
import ProjectProgressBar from './ProjectProgressBar'
|
import ProjectProgressBar from './ProjectProgressBar'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||||
|
|
||||||
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
|
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
|
||||||
// Обрабатываем данные: может быть объект с projects и total, или просто массив
|
// Обрабатываем данные: может быть объект с projects и total, или просто массив
|
||||||
const projectsData = data?.projects || (Array.isArray(data) ? data : [])
|
const projectsData = data?.projects || (Array.isArray(data) ? data : []) || []
|
||||||
|
|
||||||
// Показываем loading только если данных нет и идет загрузка
|
// Показываем loading только если данных нет и идет загрузка
|
||||||
if (loading && (!data || projectsData.length === 0)) {
|
if (loading && (!data || projectsData.length === 0)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center py-16">
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||||
<div className="flex flex-col items-center">
|
<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="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 className="text-gray-600 font-medium">Загрузка...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && (!data || projectsData.length === 0)) {
|
if (error && (!data || projectsData.length === 0)) {
|
||||||
return (
|
return <LoadingError onRetry={onRetry} />
|
||||||
<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
|
// Процент выполнения берем только из данных API
|
||||||
@@ -48,27 +36,9 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const hasProgressData = overallProgress !== 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 allProjects = getAllProjectsSorted(allProjectsData, projectsData || [])
|
||||||
|
|
||||||
const normalizePriority = (value) => {
|
const normalizePriority = (value) => {
|
||||||
if (value === null || value === undefined) return Infinity
|
if (value === null || value === undefined) return Infinity
|
||||||
@@ -77,7 +47,7 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Сортируем: сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
|
// Сортируем: сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
|
||||||
const sortedData = [...projectsData].sort((a, b) => {
|
const sortedData = (projectsData && projectsData.length > 0) ? [...projectsData].sort((a, b) => {
|
||||||
const priorityA = normalizePriority(a.priority)
|
const priorityA = normalizePriority(a.priority)
|
||||||
const priorityB = normalizePriority(b.priority)
|
const priorityB = normalizePriority(b.priority)
|
||||||
|
|
||||||
@@ -88,7 +58,7 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
|
|||||||
const minGoalA = parseFloat(a.min_goal_score) || 0
|
const minGoalA = parseFloat(a.min_goal_score) || 0
|
||||||
const minGoalB = parseFloat(b.min_goal_score) || 0
|
const minGoalB = parseFloat(b.min_goal_score) || 0
|
||||||
return minGoalB - minGoalA
|
return minGoalB - minGoalA
|
||||||
})
|
}) : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -99,10 +69,12 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm sm:text-base text-gray-600 mb-1">Выполнение целей</div>
|
<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">
|
<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'}
|
{hasProgressData && typeof overallProgress === 'number' && Number.isFinite(overallProgress)
|
||||||
|
? `${overallProgress.toFixed(1)}%`
|
||||||
|
: 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasProgressData && (
|
{hasProgressData && typeof overallProgress === 'number' && Number.isFinite(overallProgress) && (
|
||||||
<div className="w-12 h-12 sm:w-16 sm:h-16 relative flex-shrink-0">
|
<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">
|
<svg className="transform -rotate-90" viewBox="0 0 64 64">
|
||||||
<circle
|
<circle
|
||||||
@@ -121,7 +93,7 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
|
|||||||
stroke="url(#gradient)"
|
stroke="url(#gradient)"
|
||||||
strokeWidth="6"
|
strokeWidth="6"
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeDasharray={`${Math.min(overallProgress / 100, 1) * 175.93} 175.93`}
|
strokeDasharray={`${Math.min(Math.max(overallProgress / 100, 0), 1) * 175.93} 175.93`}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
{overallProgress >= 100 && (
|
{overallProgress >= 100 && (
|
||||||
@@ -178,15 +150,19 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
{sortedData.map((project, index) => {
|
{sortedData.map((project, index) => {
|
||||||
|
if (!project || !project.project_name) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const projectColor = getProjectColor(project.project_name, allProjects)
|
const projectColor = getProjectColor(project.project_name, allProjects)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<ProjectProgressBar
|
<ProjectProgressBar
|
||||||
projectName={project.project_name}
|
projectName={project.project_name}
|
||||||
totalScore={parseFloat(project.total_score)}
|
totalScore={parseFloat(project.total_score) || 0}
|
||||||
minGoalScore={parseFloat(project.min_goal_score)}
|
minGoalScore={parseFloat(project.min_goal_score) || 0}
|
||||||
maxGoalScore={parseFloat(project.max_goal_score)}
|
maxGoalScore={parseFloat(project.max_goal_score) || 0}
|
||||||
onProjectClick={onProjectClick}
|
onProjectClick={onProjectClick}
|
||||||
projectColor={projectColor}
|
projectColor={projectColor}
|
||||||
priority={project.priority}
|
priority={project.priority}
|
||||||
|
|||||||
@@ -1,269 +1,40 @@
|
|||||||
.config-selection {
|
.dictionary-list {
|
||||||
padding-top: 0;
|
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;
|
position: relative;
|
||||||
|
padding-bottom: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-config-button:hover {
|
.dictionary-close-button {
|
||||||
transform: translateY(-2px);
|
position: fixed;
|
||||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
top: 1rem;
|
||||||
background-color: rgba(52, 152, 219, 0.05);
|
right: 1rem;
|
||||||
border-color: #2980b9;
|
background: rgba(255, 255, 255, 0.9);
|
||||||
}
|
|
||||||
|
|
||||||
.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: none;
|
||||||
border-radius: 6px;
|
font-size: 1.5rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
cursor: pointer;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
border-radius: 50%;
|
||||||
font-size: 1.75rem;
|
transition: background-color 0.2s, color 0.2s;
|
||||||
color: white;
|
z-index: 1600;
|
||||||
font-weight: bold;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
transition: all 0.2s;
|
|
||||||
z-index: 10;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-menu-button:hover {
|
.dictionary-close-button:hover {
|
||||||
opacity: 0.8;
|
background-color: #ffffff;
|
||||||
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;
|
color: #2c3e50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dictionaries-section {
|
.dictionaries-grid {
|
||||||
margin-top: 2rem;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dictionary-card {
|
.dictionary-card {
|
||||||
@@ -281,13 +52,31 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dictionary-card .card-menu-button {
|
.dictionary-list .dictionary-card .dictionary-menu-button {
|
||||||
background: transparent;
|
position: absolute;
|
||||||
color: #2c3e50;
|
top: 0.5rem;
|
||||||
|
right: 0;
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
width: 40px !important;
|
||||||
|
height: 40px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
color: #2c3e50 !important;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dictionary-card .card-menu-button:hover {
|
.dictionary-list .dictionary-card .dictionary-menu-button:hover {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dictionary-card:hover {
|
.dictionary-card:hover {
|
||||||
@@ -337,11 +126,99 @@
|
|||||||
border-color: #1a252f;
|
border-color: #1a252f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-dictionary-button .add-config-icon {
|
.add-dictionary-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: bold;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
margin-bottom: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-dictionary-button .add-config-text {
|
.add-dictionary-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.dictionary-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-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: dictionaryModalSlideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dictionaryModalSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-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;
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-modal-delete:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
175
play-life-web/src/components/DictionaryList.jsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
|
import './DictionaryList.css'
|
||||||
|
|
||||||
|
const API_URL = '/api'
|
||||||
|
|
||||||
|
function DictionaryList({ onNavigate, refreshTrigger = 0 }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [dictionaries, setDictionaries] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [selectedDictionary, setSelectedDictionary] = useState(null)
|
||||||
|
const isInitializedRef = useRef(false)
|
||||||
|
const dictionariesRef = useRef([])
|
||||||
|
|
||||||
|
// Обновляем ref при изменении состояния
|
||||||
|
useEffect(() => {
|
||||||
|
dictionariesRef.current = dictionaries
|
||||||
|
}, [dictionaries])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDictionaries()
|
||||||
|
}, [refreshTrigger])
|
||||||
|
|
||||||
|
const fetchDictionaries = async () => {
|
||||||
|
try {
|
||||||
|
// Показываем загрузку только при первой инициализации или если нет данных для отображения
|
||||||
|
const isFirstLoad = !isInitializedRef.current
|
||||||
|
const hasData = !isFirstLoad && dictionariesRef.current.length > 0
|
||||||
|
if (!hasData) {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке словарей')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
|
||||||
|
setError('')
|
||||||
|
isInitializedRef.current = true
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setDictionaries([])
|
||||||
|
isInitializedRef.current = true
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDictionarySelect = (dict) => {
|
||||||
|
onNavigate?.('words', { dictionaryId: dict.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDictionaryMenuClick = (dict, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedDictionary(dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDictionaryDelete = async () => {
|
||||||
|
if (!selectedDictionary) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${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 fetchDictionaries()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err)
|
||||||
|
setError(err.message)
|
||||||
|
setSelectedDictionary(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDictionaryModal = () => {
|
||||||
|
setSelectedDictionary(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем загрузку только при первой инициализации и если нет данных для отображения
|
||||||
|
const shouldShowLoading = loading && !isInitializedRef.current && dictionaries.length === 0
|
||||||
|
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
return (
|
||||||
|
<div className="dictionary-list">
|
||||||
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="dictionary-list">
|
||||||
|
<LoadingError onRetry={fetchDictionaries} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dictionary-list">
|
||||||
|
{/* Кнопка закрытия */}
|
||||||
|
<button
|
||||||
|
className="dictionary-close-button"
|
||||||
|
onClick={() => onNavigate?.('profile')}
|
||||||
|
title="Закрыть"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="dictionaries-grid">
|
||||||
|
{dictionaries.map((dict) => (
|
||||||
|
<div
|
||||||
|
key={dict.id}
|
||||||
|
className="dictionary-card"
|
||||||
|
onClick={() => handleDictionarySelect(dict)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDictionaryMenuClick(dict, e)}
|
||||||
|
className="dictionary-menu-button"
|
||||||
|
title="Меню"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
<div className="dictionary-words-count">
|
||||||
|
{dict.wordsCount}
|
||||||
|
</div>
|
||||||
|
<div className="dictionary-name">{dict.name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate?.('words', { dictionaryId: null, isNewDictionary: true })}
|
||||||
|
className="add-dictionary-button"
|
||||||
|
>
|
||||||
|
<div className="add-dictionary-icon">+</div>
|
||||||
|
<div className="add-dictionary-text">Добавить</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDictionary && (
|
||||||
|
<div className="dictionary-modal-overlay" onClick={closeDictionaryModal}>
|
||||||
|
<div className="dictionary-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dictionary-modal-header">
|
||||||
|
<h3>{selectedDictionary.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="dictionary-modal-actions">
|
||||||
|
<button className="dictionary-modal-delete" onClick={handleDictionaryDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DictionaryList
|
||||||
|
|
||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import { Line } from 'react-chartjs-2'
|
import { Line } from 'react-chartjs-2'
|
||||||
import WeekProgressChart from './WeekProgressChart'
|
import WeekProgressChart from './WeekProgressChart'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
|
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
|
||||||
|
import './Integrations.css'
|
||||||
|
|
||||||
// Экспортируем для обратной совместимости (если используется в других местах)
|
// Экспортируем для обратной совместимости (если используется в других местах)
|
||||||
export { getProjectColorByIndex } from '../utils/projectUtils'
|
export { getProjectColorByIndex } from '../utils/projectUtils'
|
||||||
@@ -118,30 +120,17 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
|
|||||||
// Показываем loading только если данных нет и идет загрузка
|
// Показываем loading только если данных нет и идет загрузка
|
||||||
if (loading && !chartData) {
|
if (loading && !chartData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center py-16">
|
<div className="fixed inset-0 flex justify-center items-center">
|
||||||
<div className="flex flex-col items-center">
|
<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="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 className="text-gray-600 font-medium">Загрузка...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && !chartData) {
|
if (error && !chartData) {
|
||||||
return (
|
return <LoadingError onRetry={onRetry} />
|
||||||
<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 = {
|
const chartOptions = {
|
||||||
@@ -214,22 +203,17 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="max-w-2xl mx-auto">
|
||||||
{onNavigate && (
|
{onNavigate && (
|
||||||
<div className="flex justify-end mb-4">
|
<button
|
||||||
<button
|
onClick={() => onNavigate('current')}
|
||||||
onClick={() => onNavigate('current')}
|
className="close-x-button"
|
||||||
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="Закрыть"
|
||||||
title="Закрыть"
|
>
|
||||||
>
|
✕
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
</button>
|
||||||
<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' }}>
|
<div style={{ height: '550px', paddingTop: '60px' }}>
|
||||||
<Line data={chartData} options={chartOptions} />
|
<Line data={chartData} options={chartOptions} />
|
||||||
</div>
|
</div>
|
||||||
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import React, { useState } from 'react'
|
|
||||||
import TodoistIntegration from './TodoistIntegration'
|
|
||||||
import TelegramIntegration from './TelegramIntegration'
|
|
||||||
|
|
||||||
function Integrations({ onNavigate }) {
|
|
||||||
const [selectedIntegration, setSelectedIntegration] = useState(null)
|
|
||||||
|
|
||||||
const integrations = [
|
|
||||||
{ id: 'todoist', name: 'TODOist' },
|
|
||||||
{ id: 'telegram', name: 'Telegram' },
|
|
||||||
]
|
|
||||||
|
|
||||||
if (selectedIntegration) {
|
|
||||||
if (selectedIntegration === 'todoist') {
|
|
||||||
return <TodoistIntegration onBack={() => setSelectedIntegration(null)} />
|
|
||||||
} else if (selectedIntegration === 'telegram') {
|
|
||||||
return <TelegramIntegration onBack={() => setSelectedIntegration(null)} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 md:p-6">
|
|
||||||
<h1 className="text-2xl font-bold mb-6">Интеграции</h1>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{integrations.map((integration) => (
|
|
||||||
<button
|
|
||||||
key={integration.id}
|
|
||||||
onClick={() => setSelectedIntegration(integration.id)}
|
|
||||||
className="w-full p-4 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow text-left border border-gray-200 hover:border-indigo-300"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-lg font-semibold text-gray-800">
|
|
||||||
{integration.name}
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-gray-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Integrations
|
|
||||||
|
|
||||||
54
play-life-web/src/components/LoadingError.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
.loading-error-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 80px; /* Отступ для нижнего бара */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Учитываем safe-area для мобильных устройств */
|
||||||
|
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||||
|
.loading-error-container {
|
||||||
|
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-error-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-error-text {
|
||||||
|
color: #374151;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-error-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: linear-gradient(to right, #4f46e5, #9333ea);
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-error-button:hover {
|
||||||
|
background: linear-gradient(to right, #4338ca, #7e22ce);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-error-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
23
play-life-web/src/components/LoadingError.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import './LoadingError.css'
|
||||||
|
|
||||||
|
function LoadingError({ onRetry }) {
|
||||||
|
return (
|
||||||
|
<div className="loading-error-container">
|
||||||
|
<div className="loading-error-content">
|
||||||
|
<div className="loading-error-text">Ошибка, повторите позже</div>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="loading-error-button"
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingError
|
||||||
|
|
||||||
59
play-life-web/src/components/PWAUpdatePrompt.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
|
|
||||||
|
export default function PWAUpdatePrompt() {
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
|
updateServiceWorker
|
||||||
|
} = useRegisterSW({
|
||||||
|
onRegistered(r) {
|
||||||
|
console.log('SW зарегистрирован:', r)
|
||||||
|
},
|
||||||
|
onRegisterError(error) {
|
||||||
|
console.log('SW ошибка регистрации:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (needRefresh) {
|
||||||
|
setShowPrompt(true)
|
||||||
|
}
|
||||||
|
}, [needRefresh])
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
updateServiceWorker(true)
|
||||||
|
setShowPrompt(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setNeedRefresh(false)
|
||||||
|
setShowPrompt(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showPrompt) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-24 left-4 right-4 md:left-auto md:right-4 md:w-80 bg-white rounded-lg shadow-lg border border-gray-200 p-4 z-50">
|
||||||
|
<p className="text-sm text-gray-700 mb-3">
|
||||||
|
Доступна новая версия приложения
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdate}
|
||||||
|
className="flex-1 px-3 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Позже
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
138
play-life-web/src/components/Profile.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import packageJson from '../../package.json'
|
||||||
|
|
||||||
|
function Profile({ onNavigate }) {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
const integrations = [
|
||||||
|
{ id: 'todoist-integration', name: 'TODOist' },
|
||||||
|
{ id: 'telegram-integration', name: 'Telegram' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
if (window.confirm('Вы уверены, что хотите выйти?')) {
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Profile Header */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-6 mb-6 text-white shadow-lg">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center text-2xl font-bold backdrop-blur-sm">
|
||||||
|
{user?.name ? user.name.charAt(0).toUpperCase() : user?.email?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-xl font-bold">
|
||||||
|
{user?.name || 'Пользователь'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-indigo-100 text-sm">
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Функционал</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate?.('dictionaries')}
|
||||||
|
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
|
||||||
|
Словари
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Integrations Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Интеграции</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{integrations.map((integration) => (
|
||||||
|
<button
|
||||||
|
key={integration.id}
|
||||||
|
onClick={() => onNavigate?.(integration.id)}
|
||||||
|
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-800 font-medium group-hover:text-indigo-600 transition-colors">
|
||||||
|
{integration.name}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-400 group-hover:text-indigo-500 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Section */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700 mb-4 px-1">Аккаунт</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-red-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-800 font-medium group-hover:text-red-600 transition-colors">
|
||||||
|
Выйти из аккаунта
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-400 group-hover:text-red-500 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version Info */}
|
||||||
|
<div className="mt-8 text-center text-gray-400 text-sm">
|
||||||
|
<p>PlayLife v{packageJson.version}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile
|
||||||
|
|
||||||
@@ -19,17 +19,122 @@ import {
|
|||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
|
import Toast from './Toast'
|
||||||
|
import './Integrations.css'
|
||||||
|
|
||||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||||
const PROJECTS_API_URL = '/projects'
|
const PROJECTS_API_URL = '/projects'
|
||||||
const PRIORITY_UPDATE_API_URL = '/project/priority'
|
const PRIORITY_UPDATE_API_URL = '/project/priority'
|
||||||
const PROJECT_MOVE_API_URL = '/project/move'
|
const PROJECT_MOVE_API_URL = '/project/move'
|
||||||
|
const PROJECT_CREATE_API_URL = '/project/create'
|
||||||
|
|
||||||
|
// Компонент экрана добавления проекта
|
||||||
|
function AddProjectScreen({ onClose, onSuccess, onError }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [projectName, setProjectName] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [validationError, setValidationError] = useState(null)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!projectName.trim()) {
|
||||||
|
setValidationError('Введите название проекта')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setValidationError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(PROJECT_CREATE_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: projectName.trim(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(errorText || 'Ошибка при создании проекта')
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка создания проекта:', err)
|
||||||
|
if (onError) {
|
||||||
|
onError(err.message || 'Ошибка при создании проекта')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg max-w-md w-90 shadow-lg max-h-[90vh] flex flex-col">
|
||||||
|
{/* Заголовок с кнопкой закрытия */}
|
||||||
|
<div className="flex justify-end p-4 border-b border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
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 className="flex-1 overflow-y-auto p-6">
|
||||||
|
{/* Поле ввода */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Название проекта
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => setProjectName(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter' && projectName.trim() && !isSubmitting) {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Введите название проекта"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{validationError && (
|
||||||
|
<div className="mt-2 text-sm text-red-600">{validationError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка подтверждения (прибита к низу) */}
|
||||||
|
<div className="p-6 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !projectName.trim()}
|
||||||
|
className="w-full px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Обработка...' : 'Добавить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Компонент экрана переноса проекта
|
// Компонент экрана переноса проекта
|
||||||
function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
function MoveProjectScreen({ project, allProjects, onClose, onSuccess, onError }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [newProjectName, setNewProjectName] = useState('')
|
const [newProjectName, setNewProjectName] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [validationError, setValidationError] = useState(null)
|
||||||
|
|
||||||
const handleProjectClick = (projectName) => {
|
const handleProjectClick = (projectName) => {
|
||||||
setNewProjectName(projectName)
|
setNewProjectName(projectName)
|
||||||
@@ -37,16 +142,16 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!newProjectName.trim()) {
|
if (!newProjectName.trim()) {
|
||||||
setError('Введите название проекта')
|
setValidationError('Введите название проекта')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
setError(null)
|
setValidationError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectId = project.id ?? project.name
|
const projectId = project.id ?? project.name
|
||||||
const response = await fetch(PROJECT_MOVE_API_URL, {
|
const response = await authFetch(PROJECT_MOVE_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -63,7 +168,9 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
|||||||
onSuccess()
|
onSuccess()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка переноса проекта:', err)
|
console.error('Ошибка переноса проекта:', err)
|
||||||
setError(err.message || 'Ошибка при переносе проекта')
|
if (onError) {
|
||||||
|
onError(err.message || 'Ошибка при переносе проекта')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -110,8 +217,8 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
|||||||
placeholder="Введите новое название проекта"
|
placeholder="Введите новое название проекта"
|
||||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
{error && (
|
{validationError && (
|
||||||
<div className="mt-2 text-sm text-red-600">{error}</div>
|
<div className="mt-2 text-sm text-red-600">{validationError}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -173,7 +280,7 @@ function SortableProjectItem({ project, index, allProjects, onMenuClick }) {
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
data-id={project.name}
|
data-id={project.name}
|
||||||
style={{ ...style, touchAction: 'none' }}
|
style={style}
|
||||||
className={`bg-white rounded-lg p-3 border-2 border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 ${
|
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' : ''
|
isDragging ? 'border-indigo-400' : ''
|
||||||
}`}
|
}`}
|
||||||
@@ -240,7 +347,7 @@ function DroppableSlot({ containerId, isEmpty, maxItems, currentCount }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Компонент для слота приоритета
|
// Компонент для слота приоритета
|
||||||
function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = null, containerId }) {
|
function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = null, containerId, onAddClick }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="text-sm font-semibold text-gray-600 mb-2">{title}</div>
|
<div className="text-sm font-semibold text-gray-600 mb-2">{title}</div>
|
||||||
@@ -257,15 +364,25 @@ function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = nu
|
|||||||
onMenuClick={onMenuClick}
|
onMenuClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{onAddClick && containerId === 'low' && (
|
||||||
|
<button
|
||||||
|
onClick={onAddClick}
|
||||||
|
className="w-full bg-white rounded-lg p-3 border-2 border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 hover:border-indigo-400 text-gray-600 hover:text-indigo-600 font-semibold"
|
||||||
|
>
|
||||||
|
+ Добавить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
|
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [projectsLoading, setProjectsLoading] = useState(false)
|
const [projectsLoading, setProjectsLoading] = useState(false)
|
||||||
const [projectsError, setProjectsError] = useState(null)
|
const [projectsError, setProjectsError] = useState(null)
|
||||||
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
|
||||||
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
|
||||||
// Уведомляем родительский компонент об изменении состояния загрузки
|
// Уведомляем родительский компонент об изменении состояния загрузки
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -287,6 +404,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
const [activeId, setActiveId] = useState(null)
|
const [activeId, setActiveId] = useState(null)
|
||||||
const [selectedProject, setSelectedProject] = useState(null) // Для модального окна
|
const [selectedProject, setSelectedProject] = useState(null) // Для модального окна
|
||||||
const [showMoveScreen, setShowMoveScreen] = useState(false) // Для экрана переноса
|
const [showMoveScreen, setShowMoveScreen] = useState(false) // Для экрана переноса
|
||||||
|
const [showAddScreen, setShowAddScreen] = useState(false) // Для экрана добавления
|
||||||
|
|
||||||
|
|
||||||
const scrollContainerRef = useRef(null)
|
const scrollContainerRef = useRef(null)
|
||||||
@@ -298,7 +416,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 10, // Активация только после перемещения на 10px
|
distance: 15, // Увеличиваем расстояние для активации, чтобы дать больше времени для скролла
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
@@ -381,7 +499,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
}
|
}
|
||||||
setProjectsError(null)
|
setProjectsError(null)
|
||||||
|
|
||||||
const response = await fetch(PROJECTS_API_URL)
|
const response = await authFetch(PROJECTS_API_URL)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Не удалось загрузить проекты')
|
throw new Error('Не удалось загрузить проекты')
|
||||||
}
|
}
|
||||||
@@ -483,7 +601,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
const sendPriorityChanges = useCallback(async (changes) => {
|
const sendPriorityChanges = useCallback(async (changes) => {
|
||||||
if (!changes.length) return
|
if (!changes.length) return
|
||||||
try {
|
try {
|
||||||
await fetch(PRIORITY_UPDATE_API_URL, {
|
await authFetch(PRIORITY_UPDATE_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(changes),
|
body: JSON.stringify(changes),
|
||||||
@@ -723,7 +841,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const projectId = selectedProject.id ?? selectedProject.name
|
const projectId = selectedProject.id ?? selectedProject.name
|
||||||
const response = await fetch(`/project/delete`, {
|
const response = await authFetch(`/project/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: projectId }),
|
body: JSON.stringify({ id: projectId }),
|
||||||
@@ -738,7 +856,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
fetchProjects()
|
fetchProjects()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка удаления проекта:', error)
|
console.error('Ошибка удаления проекта:', error)
|
||||||
setProjectsError(error.message || 'Ошибка удаления проекта')
|
setToastMessage({ text: error.message || 'Ошибка удаления проекта', type: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,39 +873,26 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
const activeProject = allItems.find(item => item.name === activeId)
|
const activeProject = allItems.find(item => item.name === activeId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-2xl mx-auto flex flex-col h-full">
|
||||||
{onNavigate && (
|
{onNavigate && (
|
||||||
<div className="flex justify-end mb-4">
|
<button
|
||||||
<button
|
onClick={() => onNavigate('current')}
|
||||||
onClick={() => onNavigate('current')}
|
className="close-x-button"
|
||||||
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="Закрыть"
|
||||||
title="Закрыть"
|
>
|
||||||
>
|
✕
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
</button>
|
||||||
<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) && (
|
{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">
|
<LoadingError onRetry={fetchProjects} />
|
||||||
<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) ? (
|
{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 className="fixed inset-0 flex justify-center items-center">
|
||||||
Загружаем проекты...
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -797,7 +902,10 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={handleDragCancel}
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div
|
||||||
|
className="space-y-6 overflow-y-auto flex-1 min-h-0 pt-[60px]"
|
||||||
|
style={{ touchAction: 'pan-y' }}
|
||||||
|
>
|
||||||
<SortableContext items={maxPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={maxPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
||||||
<PrioritySlot
|
<PrioritySlot
|
||||||
title="Максимальный приоритет (1 проект)"
|
title="Максимальный приоритет (1 проект)"
|
||||||
@@ -827,6 +935,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
allProjects={allProjects}
|
allProjects={allProjects}
|
||||||
onMenuClick={handleMenuClick}
|
onMenuClick={handleMenuClick}
|
||||||
containerId="low"
|
containerId="low"
|
||||||
|
onAddClick={() => setShowAddScreen(true)}
|
||||||
/>
|
/>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|
||||||
@@ -901,6 +1010,31 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
setSelectedProject(null)
|
setSelectedProject(null)
|
||||||
fetchProjects()
|
fetchProjects()
|
||||||
}}
|
}}
|
||||||
|
onError={(errorMessage) => {
|
||||||
|
setToastMessage({ text: errorMessage, type: 'error' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Экран добавления проекта */}
|
||||||
|
{showAddScreen && (
|
||||||
|
<AddProjectScreen
|
||||||
|
onClose={() => setShowAddScreen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowAddScreen(false)
|
||||||
|
fetchProjects()
|
||||||
|
}}
|
||||||
|
onError={(errorMessage) => {
|
||||||
|
setToastMessage({ text: errorMessage, type: 'error' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage.text}
|
||||||
|
type={toastMessage.type}
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
319
play-life-web/src/components/TaskDetail.css
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
/* Модальное окно */
|
||||||
|
.task-detail-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-close-button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-modal-content {
|
||||||
|
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-reward-message {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border-left: 3px solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-message-text {
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-message-text strong {
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-subtasks {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtasks-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-item {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-reward-message {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-message-preview {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border-left: 3px solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-message-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-message-text {
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-message-text strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button-outline {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #6366f1;
|
||||||
|
border: 2px solid #6366f1;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button-outline:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button-outline:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-task-date-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: left;
|
||||||
|
margin-top: -0.125rem;
|
||||||
|
margin-bottom: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-info svg {
|
||||||
|
color: #6366f1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: underline;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-button:hover {
|
||||||
|
background-color: rgba(99, 102, 241, 0.1);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
724
play-life-web/src/components/TaskDetail.jsx
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
|
import Toast from './Toast'
|
||||||
|
import './TaskDetail.css'
|
||||||
|
|
||||||
|
const API_URL = '/api/tasks'
|
||||||
|
|
||||||
|
// Функция для проверки, является ли период нулевым
|
||||||
|
const isZeroPeriod = (intervalStr) => {
|
||||||
|
if (!intervalStr) return false
|
||||||
|
const trimmed = intervalStr.trim()
|
||||||
|
const parts = trimmed.split(/\s+/)
|
||||||
|
if (parts.length < 1) return false
|
||||||
|
const value = parseInt(parts[0], 10)
|
||||||
|
return !isNaN(value) && value === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для проверки, является ли repetition_date нулевым
|
||||||
|
const isZeroDate = (dateStr) => {
|
||||||
|
if (!dateStr) return false
|
||||||
|
const trimmed = dateStr.trim()
|
||||||
|
const parts = trimmed.split(/\s+/)
|
||||||
|
if (parts.length < 2) return false
|
||||||
|
const value = parts[0]
|
||||||
|
const numValue = parseInt(value, 10)
|
||||||
|
return !isNaN(numValue) && numValue === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для вычисления следующей даты по repetition_date
|
||||||
|
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
|
||||||
|
if (!repetitionDateStr) return null
|
||||||
|
|
||||||
|
const parts = repetitionDateStr.trim().split(/\s+/)
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
|
||||||
|
const value = parts[0]
|
||||||
|
const unit = parts[1].toLowerCase()
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 'week': {
|
||||||
|
// N-й день недели (1=понедельник, 7=воскресенье)
|
||||||
|
const dayOfWeek = parseInt(value, 10)
|
||||||
|
if (isNaN(dayOfWeek) || dayOfWeek < 1 || dayOfWeek > 7) return null
|
||||||
|
// JavaScript: 0=воскресенье, 1=понедельник... 6=суббота
|
||||||
|
// Наш формат: 1=понедельник... 7=воскресенье
|
||||||
|
// Конвертируем: наш 1 (Пн) -> JS 1, наш 7 (Вс) -> JS 0
|
||||||
|
const targetJsDay = dayOfWeek === 7 ? 0 : dayOfWeek
|
||||||
|
const currentJsDay = now.getDay()
|
||||||
|
// Вычисляем дни до следующего вхождения (включая сегодня, если ещё не прошло)
|
||||||
|
let daysUntil = (targetJsDay - currentJsDay + 7) % 7
|
||||||
|
// Если сегодня тот же день, берём следующую неделю
|
||||||
|
if (daysUntil === 0) daysUntil = 7
|
||||||
|
const nextDate = new Date(now)
|
||||||
|
nextDate.setDate(now.getDate() + daysUntil)
|
||||||
|
return nextDate
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
// N-й день месяца
|
||||||
|
const dayOfMonth = parseInt(value, 10)
|
||||||
|
if (isNaN(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null
|
||||||
|
|
||||||
|
// Ищем ближайшую дату с этим днём
|
||||||
|
let searchDate = new Date(now)
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const year = searchDate.getFullYear()
|
||||||
|
const month = searchDate.getMonth()
|
||||||
|
const lastDayOfMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
const actualDay = Math.min(dayOfMonth, lastDayOfMonth)
|
||||||
|
const candidateDate = new Date(year, month, actualDay)
|
||||||
|
|
||||||
|
if (candidateDate > now) {
|
||||||
|
return candidateDate
|
||||||
|
}
|
||||||
|
// Переходим к следующему месяцу
|
||||||
|
searchDate = new Date(year, month + 1, 1)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
case 'year': {
|
||||||
|
// MM-DD формат
|
||||||
|
const dateParts = value.split('-')
|
||||||
|
if (dateParts.length !== 2) return null
|
||||||
|
const monthNum = parseInt(dateParts[0], 10)
|
||||||
|
const day = parseInt(dateParts[1], 10)
|
||||||
|
if (isNaN(monthNum) || isNaN(day) || monthNum < 1 || monthNum > 12 || day < 1 || day > 31) return null
|
||||||
|
|
||||||
|
let year = now.getFullYear()
|
||||||
|
let candidateDate = new Date(year, monthNum - 1, day)
|
||||||
|
|
||||||
|
if (candidateDate <= now) {
|
||||||
|
candidateDate = new Date(year + 1, monthNum - 1, day)
|
||||||
|
}
|
||||||
|
return candidateDate
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для вычисления следующей даты по repetition_period
|
||||||
|
// Поддерживает сокращенные формы единиц времени (например, "mons" для месяцев)
|
||||||
|
const calculateNextDateFromRepetitionPeriod = (repetitionPeriodStr) => {
|
||||||
|
if (!repetitionPeriodStr) return null
|
||||||
|
|
||||||
|
const parts = repetitionPeriodStr.trim().split(/\s+/)
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
|
||||||
|
const value = parseInt(parts[0], 10)
|
||||||
|
if (isNaN(value) || value === 0) return null
|
||||||
|
|
||||||
|
const unit = parts[1].toLowerCase()
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const nextDate = new Date(now)
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 'minute':
|
||||||
|
case 'minutes':
|
||||||
|
case 'mins':
|
||||||
|
case 'min':
|
||||||
|
nextDate.setMinutes(nextDate.getMinutes() + value)
|
||||||
|
break
|
||||||
|
case 'hour':
|
||||||
|
case 'hours':
|
||||||
|
case 'hrs':
|
||||||
|
case 'hr':
|
||||||
|
nextDate.setHours(nextDate.getHours() + value)
|
||||||
|
break
|
||||||
|
case 'day':
|
||||||
|
case 'days':
|
||||||
|
// PostgreSQL может возвращать недели как дни (например, "7 days" вместо "1 week")
|
||||||
|
// Если количество дней кратно 7, обрабатываем как недели
|
||||||
|
if (value % 7 === 0 && value >= 7) {
|
||||||
|
const weeks = value / 7
|
||||||
|
nextDate.setDate(nextDate.getDate() + weeks * 7)
|
||||||
|
} else {
|
||||||
|
nextDate.setDate(nextDate.getDate() + value)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'week':
|
||||||
|
case 'weeks':
|
||||||
|
case 'wks':
|
||||||
|
case 'wk':
|
||||||
|
nextDate.setDate(nextDate.getDate() + value * 7)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
case 'months':
|
||||||
|
case 'mons':
|
||||||
|
case 'mon':
|
||||||
|
nextDate.setMonth(nextDate.getMonth() + value)
|
||||||
|
break
|
||||||
|
case 'year':
|
||||||
|
case 'years':
|
||||||
|
case 'yrs':
|
||||||
|
case 'yr':
|
||||||
|
nextDate.setFullYear(nextDate.getFullYear() + value)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование даты в YYYY-MM-DD (локальное время, без смещения в UTC)
|
||||||
|
const formatDateToLocal = (date) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование даты для отображения с понятными названиями
|
||||||
|
const formatDateForDisplay = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
|
||||||
|
// Парсим дату из формата YYYY-MM-DD
|
||||||
|
const dateParts = dateStr.split('-')
|
||||||
|
if (dateParts.length !== 3) return dateStr
|
||||||
|
|
||||||
|
const yearNum = parseInt(dateParts[0], 10)
|
||||||
|
const monthNum = parseInt(dateParts[1], 10) - 1 // месяцы в JS начинаются с 0
|
||||||
|
const dayNum = parseInt(dateParts[2], 10)
|
||||||
|
|
||||||
|
if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) return dateStr
|
||||||
|
|
||||||
|
const targetDate = new Date(yearNum, monthNum, dayNum)
|
||||||
|
targetDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const diffDays = Math.floor((targetDate - now) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
// Сегодня
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return 'Сегодня'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завтра
|
||||||
|
if (diffDays === 1) {
|
||||||
|
return 'Завтра'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вчера
|
||||||
|
if (diffDays === -1) {
|
||||||
|
return 'Вчера'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дни недели для ближайших дней из будущего (в пределах 7 дней)
|
||||||
|
if (diffDays > 0 && diffDays <= 7) {
|
||||||
|
const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
|
||||||
|
const dayOfWeek = targetDate.getDay()
|
||||||
|
return dayNames[dayOfWeek]
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||||
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||||||
|
|
||||||
|
// Если это число из того же года - только день и месяц
|
||||||
|
if (targetDate.getFullYear() === now.getFullYear()) {
|
||||||
|
const displayDay = targetDate.getDate()
|
||||||
|
const displayMonth = monthNames[targetDate.getMonth()]
|
||||||
|
return `${displayDay} ${displayMonth}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для других случаев - полная дата
|
||||||
|
const displayDay = targetDate.getDate()
|
||||||
|
const displayMonth = monthNames[targetDate.getMonth()]
|
||||||
|
const displayYear = targetDate.getFullYear()
|
||||||
|
return `${displayDay} ${displayMonth} ${displayYear}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для форматирования числа как %.4g в Go (до 4 значащих цифр)
|
||||||
|
const formatScore = (num) => {
|
||||||
|
if (num === 0) return '0'
|
||||||
|
|
||||||
|
// Используем toPrecision(4) для получения до 4 значащих цифр
|
||||||
|
let str = num.toPrecision(4)
|
||||||
|
|
||||||
|
// Убираем лишние нули в конце (но оставляем точку если есть цифры после неё)
|
||||||
|
str = str.replace(/\.?0+$/, '')
|
||||||
|
|
||||||
|
// Если получилась экспоненциальная нотация для больших чисел, конвертируем обратно
|
||||||
|
if (str.includes('e+') || str.includes('e-')) {
|
||||||
|
const numValue = parseFloat(str)
|
||||||
|
// Для чисел >= 10000 используем экспоненциальную нотацию
|
||||||
|
if (Math.abs(numValue) >= 10000) {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
// Для остальных конвертируем в обычное число
|
||||||
|
return numValue.toString().replace(/\.?0+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для формирования сообщения Telegram в реальном времени
|
||||||
|
const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progressionValue) => {
|
||||||
|
if (!task) return ''
|
||||||
|
|
||||||
|
// Вычисляем score для каждой награды основной задачи
|
||||||
|
const rewardStrings = {}
|
||||||
|
const progressionBase = task.progression_base
|
||||||
|
const hasProgression = progressionBase != null
|
||||||
|
// Если прогрессия не введена - используем progression_base
|
||||||
|
const value = progressionValue && progressionValue.trim() !== ''
|
||||||
|
? parseFloat(progressionValue)
|
||||||
|
: (hasProgression ? progressionBase : null)
|
||||||
|
|
||||||
|
rewards.forEach(reward => {
|
||||||
|
let score = reward.value
|
||||||
|
if (reward.use_progression && hasProgression) {
|
||||||
|
if (value !== null && !isNaN(value)) {
|
||||||
|
score = (value / progressionBase) * reward.value
|
||||||
|
} else {
|
||||||
|
// Если прогрессия не введена, используем progression_base (score = reward.value)
|
||||||
|
score = reward.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreStr = score >= 0
|
||||||
|
? `**${reward.project_name}+${formatScore(score)}**`
|
||||||
|
: `**${reward.project_name}-${formatScore(Math.abs(score))}**`
|
||||||
|
rewardStrings[reward.position] = scoreStr
|
||||||
|
})
|
||||||
|
|
||||||
|
// Функция для замены плейсхолдеров
|
||||||
|
const replacePlaceholders = (message, rewardStrings) => {
|
||||||
|
let result = message
|
||||||
|
// Сначала защищаем экранированные плейсхолдеры
|
||||||
|
const escapedMarkers = {}
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const escaped = `\\$${i}`
|
||||||
|
const marker = `__ESCAPED_DOLLAR_${i}__`
|
||||||
|
if (result.includes(escaped)) {
|
||||||
|
escapedMarkers[marker] = escaped
|
||||||
|
result = result.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Заменяем ${0}, ${1}, и т.д.
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const placeholder = `\${${i}}`
|
||||||
|
if (rewardStrings[i]) {
|
||||||
|
result = result.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), rewardStrings[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10)
|
||||||
|
for (let i = 99; i >= 0; i--) {
|
||||||
|
if (rewardStrings[i]) {
|
||||||
|
const searchStr = `$${i}`
|
||||||
|
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
|
||||||
|
result = result.replace(regex, rewardStrings[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Восстанавливаем экранированные
|
||||||
|
Object.entries(escapedMarkers).forEach(([marker, escaped]) => {
|
||||||
|
result = result.replace(new RegExp(marker, 'g'), escaped)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем сообщение основной задачи
|
||||||
|
let mainTaskMessage = task.reward_message && task.reward_message.trim() !== ''
|
||||||
|
? replacePlaceholders(task.reward_message, rewardStrings)
|
||||||
|
: task.name
|
||||||
|
|
||||||
|
// Формируем сообщения подзадач
|
||||||
|
const subtaskMessages = []
|
||||||
|
subtasks.forEach(subtask => {
|
||||||
|
if (!selectedSubtasks.has(subtask.task.id)) return
|
||||||
|
if (!subtask.task.reward_message || subtask.task.reward_message.trim() === '') return
|
||||||
|
|
||||||
|
// Вычисляем score для наград подзадачи
|
||||||
|
const subtaskRewardStrings = {}
|
||||||
|
subtask.rewards.forEach(reward => {
|
||||||
|
let score = reward.value
|
||||||
|
const subtaskProgressionBase = subtask.task.progression_base
|
||||||
|
if (reward.use_progression) {
|
||||||
|
if (subtaskProgressionBase != null && value !== null && !isNaN(value)) {
|
||||||
|
score = (value / subtaskProgressionBase) * reward.value
|
||||||
|
} else if (hasProgression && value !== null && !isNaN(value)) {
|
||||||
|
score = (value / progressionBase) * reward.value
|
||||||
|
} else if (subtaskProgressionBase != null) {
|
||||||
|
// Если прогрессия не введена, используем progression_base подзадачи (score = reward.value)
|
||||||
|
score = reward.value
|
||||||
|
} else if (hasProgression) {
|
||||||
|
// Если у подзадачи нет progression_base, используем основной (score = reward.value)
|
||||||
|
score = reward.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreStr = score >= 0
|
||||||
|
? `**${reward.project_name}+${formatScore(score)}**`
|
||||||
|
: `**${reward.project_name}-${formatScore(Math.abs(score))}**`
|
||||||
|
subtaskRewardStrings[reward.position] = scoreStr
|
||||||
|
})
|
||||||
|
|
||||||
|
const subtaskMessage = replacePlaceholders(subtask.task.reward_message, subtaskRewardStrings)
|
||||||
|
subtaskMessages.push(subtaskMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Формируем итоговое сообщение
|
||||||
|
let finalMessage = mainTaskMessage
|
||||||
|
subtaskMessages.forEach(subtaskMsg => {
|
||||||
|
finalMessage += '\n + ' + subtaskMsg
|
||||||
|
})
|
||||||
|
|
||||||
|
return finalMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [taskDetail, setTaskDetail] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [selectedSubtasks, setSelectedSubtasks] = useState(new Set())
|
||||||
|
const [progressionValue, setProgressionValue] = useState('')
|
||||||
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
const [wishlistInfo, setWishlistInfo] = useState(null)
|
||||||
|
|
||||||
|
const fetchTaskDetail = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const response = await authFetch(`${API_URL}/${taskId}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки задачи')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setTaskDetail(data)
|
||||||
|
|
||||||
|
// Загружаем информацию о связанном желании, если есть
|
||||||
|
if (data.task.wishlist_id) {
|
||||||
|
try {
|
||||||
|
const wishlistResponse = await authFetch(`/api/wishlist/${data.task.wishlist_id}`)
|
||||||
|
if (wishlistResponse.ok) {
|
||||||
|
const wishlistData = await wishlistResponse.json()
|
||||||
|
setWishlistInfo({
|
||||||
|
id: wishlistData.id,
|
||||||
|
name: wishlistData.name,
|
||||||
|
unlocked: wishlistData.unlocked || false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading wishlist info:', err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setWishlistInfo(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
console.error('Error fetching task detail:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId, authFetch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (taskId) {
|
||||||
|
fetchTaskDetail()
|
||||||
|
} else {
|
||||||
|
// Сбрасываем состояние при закрытии модального окна
|
||||||
|
setTaskDetail(null)
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setSelectedSubtasks(new Set())
|
||||||
|
setProgressionValue('')
|
||||||
|
}
|
||||||
|
}, [taskId, fetchTaskDetail])
|
||||||
|
|
||||||
|
const handleSubtaskToggle = (subtaskId) => {
|
||||||
|
setSelectedSubtasks(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(subtaskId)) {
|
||||||
|
newSet.delete(subtaskId)
|
||||||
|
} else {
|
||||||
|
newSet.add(subtaskId)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComplete = async (shouldDelete = false) => {
|
||||||
|
if (!taskDetail) return
|
||||||
|
|
||||||
|
// Проверяем, что желание разблокировано (если есть связанное желание)
|
||||||
|
if (wishlistInfo && !wishlistInfo.unlocked) {
|
||||||
|
setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если прогрессия не введена, используем 0 (валидация не требуется)
|
||||||
|
|
||||||
|
setIsCompleting(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
children_task_ids: Array.from(selectedSubtasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если есть прогрессия, отправляем значение (или progression_base, если не введено)
|
||||||
|
if (taskDetail.task.progression_base != null) {
|
||||||
|
if (progressionValue.trim()) {
|
||||||
|
payload.value = parseFloat(progressionValue)
|
||||||
|
if (isNaN(payload.value)) {
|
||||||
|
throw new Error('Неверное значение')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если прогрессия не введена - используем progression_base
|
||||||
|
payload.value = taskDetail.task.progression_base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем единую ручку для выполнения и удаления
|
||||||
|
const endpoint = shouldDelete
|
||||||
|
? `${API_URL}/${taskId}/complete-and-delete`
|
||||||
|
: `${API_URL}/${taskId}/complete`
|
||||||
|
|
||||||
|
const response = await authFetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || 'Ошибка при выполнении задачи')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем уведомление о выполнении
|
||||||
|
if (onTaskCompleted) {
|
||||||
|
onTaskCompleted()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем список и закрываем модальное окно
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
if (onClose) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error completing task:', err)
|
||||||
|
setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setIsCompleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskId) return null
|
||||||
|
|
||||||
|
const { task, rewards, subtasks } = taskDetail || {}
|
||||||
|
const hasProgression = task?.progression_base != null
|
||||||
|
// Кнопка активна только если желание разблокировано (или задачи нет связанного желания)
|
||||||
|
const canComplete = !wishlistInfo || wishlistInfo.unlocked
|
||||||
|
|
||||||
|
// Определяем, является ли задача одноразовой
|
||||||
|
// Одноразовая задача: когда оба поля null/undefined (из бэкенда видно, что в этом случае задача помечается как deleted)
|
||||||
|
// Бесконечная задача: когда хотя бы одно поле равно "0 day" или "0 week" и т.д.
|
||||||
|
// Повторяющаяся задача: когда есть значение (не null и не 0)
|
||||||
|
// Кнопка "Закрыть" показывается для задач, которые НЕ одноразовые (имеют повторение, даже если оно равно 0)
|
||||||
|
// Проверяем, что оба поля отсутствуют (null или undefined)
|
||||||
|
const isOneTime = (task?.repetition_period == null || task?.repetition_period === undefined) &&
|
||||||
|
(task?.repetition_date == null || task?.repetition_date === undefined)
|
||||||
|
|
||||||
|
// Вычисляем следующую дату для неодноразовых задач
|
||||||
|
const nextTaskDate = useMemo(() => {
|
||||||
|
if (!task || isOneTime) return null
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
let nextDate = null
|
||||||
|
|
||||||
|
if (task.repetition_date) {
|
||||||
|
// Для задач с repetition_date - вычисляем следующую подходящую дату
|
||||||
|
nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
|
||||||
|
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
|
||||||
|
// Для задач с repetition_period (не нулевым) - вычисляем следующую дату
|
||||||
|
nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextDate) return null
|
||||||
|
|
||||||
|
nextDate.setHours(0, 0, 0, 0)
|
||||||
|
return formatDateForDisplay(formatDateToLocal(nextDate))
|
||||||
|
}, [task, isOneTime])
|
||||||
|
|
||||||
|
// Формируем сообщение для Telegram в реальном времени
|
||||||
|
const telegramMessage = useMemo(() => {
|
||||||
|
if (!taskDetail) return ''
|
||||||
|
return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue)
|
||||||
|
}, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-detail-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="task-detail-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="task-detail-modal-header">
|
||||||
|
<h2 className="task-detail-title">
|
||||||
|
{loading ? 'Загрузка...' : error ? 'Ошибка' : taskDetail ? task.name : 'Задача'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="task-detail-close-button">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-detail-modal-content">
|
||||||
|
{loading && (
|
||||||
|
<div className="loading">Загрузка...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<LoadingError onRetry={fetchTaskDetail} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && taskDetail && (
|
||||||
|
<>
|
||||||
|
{/* Информация о связанном желании */}
|
||||||
|
{task.wishlist_id && wishlistInfo && (
|
||||||
|
<div className="task-wishlist-link">
|
||||||
|
<div className="task-wishlist-link-info">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
||||||
|
<rect x="2" y="7" width="20" height="5"></rect>
|
||||||
|
<line x1="12" y1="22" x2="12" y2="7"></line>
|
||||||
|
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
||||||
|
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
||||||
|
</svg>
|
||||||
|
<span className="task-wishlist-link-label">Связано с желанием:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onClose) onClose()
|
||||||
|
if (onNavigate && wishlistInfo) {
|
||||||
|
onNavigate('wishlist-detail', { wishlistId: wishlistInfo.id })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="task-wishlist-link-button"
|
||||||
|
>
|
||||||
|
{wishlistInfo.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Поле ввода прогрессии */}
|
||||||
|
{hasProgression && (
|
||||||
|
<div className="progression-section">
|
||||||
|
<label className="progression-label">Значение прогрессии</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={progressionValue}
|
||||||
|
onChange={(e) => setProgressionValue(e.target.value)}
|
||||||
|
placeholder={task.progression_base?.toString() || ''}
|
||||||
|
className="progression-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Список подзадач */}
|
||||||
|
{subtasks && subtasks.length > 0 && (
|
||||||
|
<div className="task-subtasks">
|
||||||
|
{subtasks.map((subtask) => {
|
||||||
|
const subtaskName = subtask.task.name || 'Подзадача'
|
||||||
|
return (
|
||||||
|
<div key={subtask.task.id} className="subtask-item">
|
||||||
|
<label className="subtask-checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSubtasks.has(subtask.task.id)}
|
||||||
|
onChange={() => handleSubtaskToggle(subtask.task.id)}
|
||||||
|
className="subtask-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="subtask-content">
|
||||||
|
<div className="subtask-name">{subtaskName}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Разделитель - показываем только если есть прогрессия или подзадачи */}
|
||||||
|
{(hasProgression || (subtasks && subtasks.length > 0)) && (
|
||||||
|
<div className="task-detail-divider"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Сообщение награды - показываем только если есть прогрессия или подзадачи */}
|
||||||
|
{(hasProgression || (subtasks && subtasks.length > 0)) && (
|
||||||
|
<div className="telegram-message-preview">
|
||||||
|
<div className="telegram-message-label">Сообщение награды:</div>
|
||||||
|
<div className="telegram-message-text" dangerouslySetInnerHTML={{
|
||||||
|
__html: telegramMessage
|
||||||
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопки действий */}
|
||||||
|
<div className="task-actions-section">
|
||||||
|
<div className="task-actions-buttons">
|
||||||
|
<button
|
||||||
|
onClick={() => handleComplete(false)}
|
||||||
|
disabled={isCompleting || !canComplete}
|
||||||
|
className="complete-button"
|
||||||
|
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : ''}
|
||||||
|
>
|
||||||
|
{!canComplete && wishlistInfo ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '0.5rem' }}>
|
||||||
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginRight: '0.5rem' }}>
|
||||||
|
<path d="M13.5 4L6 11.5L2.5 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
||||||
|
</button>
|
||||||
|
{!isOneTime && canComplete && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleComplete(true)}
|
||||||
|
disabled={isCompleting || !canComplete}
|
||||||
|
className="close-button-outline"
|
||||||
|
title="Выполнить и закрыть"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 7L7 11L15 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M3 11L7 15L15 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isOneTime && nextTaskDate && (
|
||||||
|
<div className="next-task-date-info">
|
||||||
|
Следующая: {nextTaskDate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage.text}
|
||||||
|
type={toastMessage.type}
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskDetail
|
||||||
|
|
||||||
510
play-life-web/src/components/TaskForm.css
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
.task-form {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form form {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label input[type="checkbox"] {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button-outlined {
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button-filled {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
border-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button-filled:hover {
|
||||||
|
background: #059669;
|
||||||
|
border-color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button-outlined:focus {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #6b7280 !important;
|
||||||
|
border-color: #d1d5db !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button-filled:focus {
|
||||||
|
background: #10b981 !important;
|
||||||
|
color: white !important;
|
||||||
|
border-color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button-subtask.progression-button-filled {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
border-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button-subtask.progression-button-filled:hover {
|
||||||
|
background: #059669;
|
||||||
|
border-color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progression-button-subtask.progression-button-filled:focus {
|
||||||
|
background: #10b981 !important;
|
||||||
|
color: white !important;
|
||||||
|
border-color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewards-container {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-number {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-name-input {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-item .form-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-item .reward-project-input {
|
||||||
|
flex: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-item .reward-score-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtasks-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtasks-header label {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 2rem;
|
||||||
|
line-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-subtask-button {
|
||||||
|
padding: 0.375rem;
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-subtask-button:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-form-item {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-header-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-name-input {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-rewards {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-subtask-button {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-subtask-button:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ef4444;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button,
|
||||||
|
.submit-button,
|
||||||
|
.delete-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-link-info {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-link-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #374151;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-link-text strong {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-unlink-x {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-unlink-x:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test configuration styles */
|
||||||
|
.test-config-section {
|
||||||
|
background: #f0f9ff;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-config-section > label {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3498db;
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-config-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-field-group label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionaries-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionaries-section > label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionaries-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-item:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-item input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-dictionary-count {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-no-dictionaries {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
1224
play-life-web/src/components/TaskForm.jsx
Normal file
611
play-life-web/src/components/TaskList.css
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
.task-list {
|
||||||
|
max-width: 42rem; /* max-w-2xl = 672px */
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, transparent, #e5e7eb, transparent);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkmark {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #9ca3af;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkmark:hover {
|
||||||
|
color: #6366f1;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkmark .checkmark-check {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkmark:hover .checkmark-check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-checkmark-detail:hover {
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-name-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-name-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-next-show-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-subtasks-count {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-badge-bar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-progression-icon {
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-infinite-icon {
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-onetime-icon {
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-completed-count {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal-header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-close-button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal-content {
|
||||||
|
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-quick-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-quick-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-quick-button:hover:not(:disabled) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-quick-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-display-date {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: #1f2937;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-display-date:hover {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-display-date:active {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-submit-checkmark {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
min-width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-submit-checkmark:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-submit-checkmark:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-menu-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-menu-button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-actions {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-edit,
|
||||||
|
.task-modal-delete {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-edit {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-edit:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-delete {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-delete:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-delete:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-details {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-group {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-group-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-group-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-group-title-empty {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-toggle:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-toggle-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-tasks {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-tasks .task-item {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-group {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge icons for test and wishlist tasks */
|
||||||
|
.task-test-icon {
|
||||||
|
color: #3498db;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-icon {
|
||||||
|
color: #e74c3c;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add task/test modal */
|
||||||
|
.task-add-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
max-width: 320px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
animation: modalSlideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-header {
|
||||||
|
padding: 1.25rem 1.5rem 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-buttons {
|
||||||
|
padding: 0 1.5rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button-task {
|
||||||
|
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button-task:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button-test {
|
||||||
|
background: linear-gradient(to right, #3498db, #2980b9);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-add-modal-button-test:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
|
||||||
|
}
|
||||||
932
play-life-web/src/components/TaskList.jsx
Normal file
@@ -0,0 +1,932 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import TaskDetail from './TaskDetail'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
|
import Toast from './Toast'
|
||||||
|
import './TaskList.css'
|
||||||
|
|
||||||
|
const API_URL = '/api/tasks'
|
||||||
|
|
||||||
|
function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry, onRefresh }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
// Инициализируем tasks из data, если data есть, иначе пустой массив
|
||||||
|
const [tasks, setTasks] = useState(() => data && Array.isArray(data) ? data : [])
|
||||||
|
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
|
||||||
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
|
const [expandedCompleted, setExpandedCompleted] = useState({})
|
||||||
|
const [selectedTaskForPostpone, setSelectedTaskForPostpone] = useState(null)
|
||||||
|
const [postponeDate, setPostponeDate] = useState('')
|
||||||
|
const [isPostponing, setIsPostponing] = useState(false)
|
||||||
|
const [toast, setToast] = useState(null)
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const dateInputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setTasks(data)
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
// Загрузка данных управляется из App.jsx через loadTabData
|
||||||
|
// TaskList не инициирует загрузку самостоятельно
|
||||||
|
|
||||||
|
const handleTaskClick = (task) => {
|
||||||
|
onNavigate?.('task-form', { taskId: task.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckmarkClick = async (task, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// Для задач-тестов запускаем тест вместо открытия модального окна
|
||||||
|
const isTest = task.config_id != null
|
||||||
|
if (isTest) {
|
||||||
|
if (task.config_id) {
|
||||||
|
onNavigate?.('test', { configId: task.config_id, taskId: task.id })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для обычных задач открываем диалог подтверждения
|
||||||
|
setSelectedTaskForDetail(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseDetail = () => {
|
||||||
|
setSelectedTaskForDetail(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddClick = () => {
|
||||||
|
setShowAddModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddTask = () => {
|
||||||
|
setShowAddModal(false)
|
||||||
|
onNavigate?.('task-form', { taskId: undefined, isTest: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddTest = () => {
|
||||||
|
setShowAddModal(false)
|
||||||
|
onNavigate?.('task-form', { taskId: undefined, isTest: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Функция для вычисления следующей даты по repetition_date
|
||||||
|
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
|
||||||
|
if (!repetitionDateStr) return null
|
||||||
|
|
||||||
|
const parts = repetitionDateStr.trim().split(/\s+/)
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
|
||||||
|
const value = parts[0]
|
||||||
|
const unit = parts[1].toLowerCase()
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 'week': {
|
||||||
|
// N-й день недели (1=понедельник, 7=воскресенье)
|
||||||
|
const dayOfWeek = parseInt(value, 10)
|
||||||
|
if (isNaN(dayOfWeek) || dayOfWeek < 1 || dayOfWeek > 7) return null
|
||||||
|
// JavaScript: 0=воскресенье, 1=понедельник... 6=суббота
|
||||||
|
// Наш формат: 1=понедельник... 7=воскресенье
|
||||||
|
// Конвертируем: наш 1 (Пн) -> JS 1, наш 7 (Вс) -> JS 0
|
||||||
|
const targetJsDay = dayOfWeek === 7 ? 0 : dayOfWeek
|
||||||
|
const currentJsDay = now.getDay()
|
||||||
|
// Вычисляем дни до следующего вхождения (включая сегодня, если ещё не прошло)
|
||||||
|
let daysUntil = (targetJsDay - currentJsDay + 7) % 7
|
||||||
|
// Если сегодня тот же день, берём следующую неделю
|
||||||
|
if (daysUntil === 0) daysUntil = 7
|
||||||
|
const nextDate = new Date(now)
|
||||||
|
nextDate.setDate(now.getDate() + daysUntil)
|
||||||
|
return nextDate
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
// N-й день месяца
|
||||||
|
const dayOfMonth = parseInt(value, 10)
|
||||||
|
if (isNaN(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null
|
||||||
|
|
||||||
|
// Ищем ближайшую дату с этим днём
|
||||||
|
let searchDate = new Date(now)
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const year = searchDate.getFullYear()
|
||||||
|
const month = searchDate.getMonth()
|
||||||
|
const lastDayOfMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
const actualDay = Math.min(dayOfMonth, lastDayOfMonth)
|
||||||
|
const candidateDate = new Date(year, month, actualDay)
|
||||||
|
|
||||||
|
if (candidateDate > now) {
|
||||||
|
return candidateDate
|
||||||
|
}
|
||||||
|
// Переходим к следующему месяцу
|
||||||
|
searchDate = new Date(year, month + 1, 1)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
case 'year': {
|
||||||
|
// MM-DD формат
|
||||||
|
const dateParts = value.split('-')
|
||||||
|
if (dateParts.length !== 2) return null
|
||||||
|
const monthNum = parseInt(dateParts[0], 10)
|
||||||
|
const day = parseInt(dateParts[1], 10)
|
||||||
|
if (isNaN(monthNum) || isNaN(day) || monthNum < 1 || monthNum > 12 || day < 1 || day > 31) return null
|
||||||
|
|
||||||
|
let year = now.getFullYear()
|
||||||
|
let candidateDate = new Date(year, monthNum - 1, day)
|
||||||
|
|
||||||
|
if (candidateDate <= now) {
|
||||||
|
candidateDate = new Date(year + 1, monthNum - 1, day)
|
||||||
|
}
|
||||||
|
return candidateDate
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для вычисления следующей даты по repetition_period
|
||||||
|
const calculateNextDateFromRepetitionPeriod = (repetitionPeriodStr) => {
|
||||||
|
if (!repetitionPeriodStr) return null
|
||||||
|
|
||||||
|
const parts = repetitionPeriodStr.trim().split(/\s+/)
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
|
||||||
|
const value = parseInt(parts[0], 10)
|
||||||
|
if (isNaN(value) || value === 0) return null
|
||||||
|
|
||||||
|
const unit = parts[1].toLowerCase()
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const nextDate = new Date(now)
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 'minute':
|
||||||
|
case 'minutes':
|
||||||
|
case 'mins':
|
||||||
|
case 'min':
|
||||||
|
nextDate.setMinutes(nextDate.getMinutes() + value)
|
||||||
|
break
|
||||||
|
case 'hour':
|
||||||
|
case 'hours':
|
||||||
|
case 'hrs':
|
||||||
|
case 'hr':
|
||||||
|
nextDate.setHours(nextDate.getHours() + value)
|
||||||
|
break
|
||||||
|
case 'day':
|
||||||
|
case 'days':
|
||||||
|
// PostgreSQL может возвращать недели как дни (например, "7 days" вместо "1 week")
|
||||||
|
// Если количество дней кратно 7, обрабатываем как недели
|
||||||
|
if (value % 7 === 0 && value >= 7) {
|
||||||
|
const weeks = value / 7
|
||||||
|
nextDate.setDate(nextDate.getDate() + weeks * 7)
|
||||||
|
} else {
|
||||||
|
nextDate.setDate(nextDate.getDate() + value)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'week':
|
||||||
|
case 'weeks':
|
||||||
|
case 'wks':
|
||||||
|
case 'wk':
|
||||||
|
nextDate.setDate(nextDate.getDate() + value * 7)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
case 'months':
|
||||||
|
case 'mons':
|
||||||
|
case 'mon':
|
||||||
|
nextDate.setMonth(nextDate.getMonth() + value)
|
||||||
|
break
|
||||||
|
case 'year':
|
||||||
|
case 'years':
|
||||||
|
case 'yrs':
|
||||||
|
case 'yr':
|
||||||
|
nextDate.setFullYear(nextDate.getFullYear() + value)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование даты в YYYY-MM-DD (локальное время, без смещения в UTC)
|
||||||
|
const formatDateToLocal = (date) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование даты для отображения с понятными названиями
|
||||||
|
const formatDateForDisplay = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
|
||||||
|
// Парсим дату из формата YYYY-MM-DD
|
||||||
|
const dateParts = dateStr.split('-')
|
||||||
|
if (dateParts.length !== 3) return dateStr
|
||||||
|
|
||||||
|
const yearNum = parseInt(dateParts[0], 10)
|
||||||
|
const monthNum = parseInt(dateParts[1], 10) - 1 // месяцы в JS начинаются с 0
|
||||||
|
const dayNum = parseInt(dateParts[2], 10)
|
||||||
|
|
||||||
|
if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) return dateStr
|
||||||
|
|
||||||
|
const targetDate = new Date(yearNum, monthNum, dayNum)
|
||||||
|
targetDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const diffDays = Math.floor((targetDate - now) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
// Сегодня
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return 'Сегодня'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завтра
|
||||||
|
if (diffDays === 1) {
|
||||||
|
return 'Завтра'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вчера
|
||||||
|
if (diffDays === -1) {
|
||||||
|
return 'Вчера'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дни недели для ближайших дней из будущего (в пределах 7 дней)
|
||||||
|
if (diffDays > 0 && diffDays <= 7) {
|
||||||
|
const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
|
||||||
|
const dayOfWeek = targetDate.getDay()
|
||||||
|
return dayNames[dayOfWeek]
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||||
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||||||
|
|
||||||
|
// Если это число из того же года - только день и месяц
|
||||||
|
if (targetDate.getFullYear() === now.getFullYear()) {
|
||||||
|
const displayDay = targetDate.getDate()
|
||||||
|
const displayMonth = monthNames[targetDate.getMonth()]
|
||||||
|
return `${displayDay} ${displayMonth}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для других случаев - полная дата
|
||||||
|
const displayDay = targetDate.getDate()
|
||||||
|
const displayMonth = monthNames[targetDate.getMonth()]
|
||||||
|
const displayYear = targetDate.getFullYear()
|
||||||
|
return `${displayDay} ${displayMonth} ${displayYear}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostponeClick = (task, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedTaskForPostpone(task)
|
||||||
|
|
||||||
|
// Устанавливаем дату по умолчанию
|
||||||
|
let defaultDate
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
if (task.repetition_date) {
|
||||||
|
// Для задач с repetition_date - вычисляем следующую подходящую дату
|
||||||
|
const nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
|
||||||
|
if (nextDate) {
|
||||||
|
defaultDate = nextDate
|
||||||
|
}
|
||||||
|
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
|
||||||
|
// Для задач с repetition_period (не нулевым) - вычисляем следующую дату
|
||||||
|
const nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
|
||||||
|
if (nextDate) {
|
||||||
|
defaultDate = nextDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defaultDate) {
|
||||||
|
// Без repetition_date/repetition_period или если не удалось вычислить - завтра
|
||||||
|
defaultDate = new Date(now)
|
||||||
|
defaultDate.setDate(defaultDate.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultDate.setHours(0, 0, 0, 0)
|
||||||
|
setPostponeDate(formatDateToLocal(defaultDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostponeSubmit = async () => {
|
||||||
|
if (!selectedTaskForPostpone || !postponeDate) return
|
||||||
|
await handlePostponeSubmitWithDate(postponeDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostponeClose = () => {
|
||||||
|
setSelectedTaskForPostpone(null)
|
||||||
|
setPostponeDate('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTodayClick = () => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
setPostponeDate(formatDateToLocal(today))
|
||||||
|
// Применяем дату сразу
|
||||||
|
if (selectedTaskForPostpone) {
|
||||||
|
handlePostponeSubmitWithDate(formatDateToLocal(today))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTomorrowClick = () => {
|
||||||
|
const tomorrow = new Date()
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
tomorrow.setHours(0, 0, 0, 0)
|
||||||
|
setPostponeDate(formatDateToLocal(tomorrow))
|
||||||
|
// Применяем дату сразу
|
||||||
|
if (selectedTaskForPostpone) {
|
||||||
|
handlePostponeSubmitWithDate(formatDateToLocal(tomorrow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostponeSubmitWithDate = async (dateToUse) => {
|
||||||
|
if (!selectedTaskForPostpone || !dateToUse) return
|
||||||
|
|
||||||
|
setIsPostponing(true)
|
||||||
|
try {
|
||||||
|
// Преобразуем дату в ISO формат с временем
|
||||||
|
const dateObj = new Date(dateToUse)
|
||||||
|
dateObj.setHours(0, 0, 0, 0)
|
||||||
|
const isoDate = dateObj.toISOString()
|
||||||
|
|
||||||
|
const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ next_show_at: isoDate }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || 'Ошибка при переносе задачи')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем список
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем модальное окно
|
||||||
|
setSelectedTaskForPostpone(null)
|
||||||
|
setPostponeDate('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error postponing task:', err)
|
||||||
|
setToast({ message: err.message || 'Ошибка при переносе задачи', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setIsPostponing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCompletedExpanded = (projectName) => {
|
||||||
|
setExpandedCompleted(prev => ({
|
||||||
|
...prev,
|
||||||
|
[projectName]: !prev[projectName]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем все проекты из задачи (теперь они приходят в task.project_names)
|
||||||
|
const getTaskProjects = (task) => {
|
||||||
|
if (task.project_names && Array.isArray(task.project_names)) {
|
||||||
|
return task.project_names
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для проверки, является ли период нулевым
|
||||||
|
const isZeroPeriod = (intervalStr) => {
|
||||||
|
if (!intervalStr) return false
|
||||||
|
|
||||||
|
const trimmed = intervalStr.trim()
|
||||||
|
|
||||||
|
// Проверяем формат времени "00:00:00" или "0:00:00"
|
||||||
|
if (/^\d{1,2}:\d{2}:\d{2}/.test(trimmed)) {
|
||||||
|
const timeParts = trimmed.split(':')
|
||||||
|
if (timeParts.length >= 3) {
|
||||||
|
const hours = parseInt(timeParts[0], 10)
|
||||||
|
const minutes = parseInt(timeParts[1], 10)
|
||||||
|
const seconds = parseInt(timeParts[2], 10)
|
||||||
|
return !isNaN(hours) && !isNaN(minutes) && !isNaN(seconds) &&
|
||||||
|
hours === 0 && minutes === 0 && seconds === 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL может возвращать "0 day", "0 days", "0", и т.д.
|
||||||
|
const parts = trimmed.split(/\s+/)
|
||||||
|
if (parts.length < 1) return false
|
||||||
|
|
||||||
|
const value = parseInt(parts[0], 10)
|
||||||
|
return !isNaN(value) && value === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для проверки, является ли repetition_date нулевым
|
||||||
|
const isZeroDate = (dateStr) => {
|
||||||
|
if (!dateStr) return false
|
||||||
|
|
||||||
|
const trimmed = dateStr.trim()
|
||||||
|
const parts = trimmed.split(/\s+/)
|
||||||
|
if (parts.length < 2) return false
|
||||||
|
|
||||||
|
const value = parts[0]
|
||||||
|
// Проверяем, является ли значение "0" (для формата "0 week", "0 month", "0 year")
|
||||||
|
const numValue = parseInt(value, 10)
|
||||||
|
return !isNaN(numValue) && numValue === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем задачи по проектам
|
||||||
|
const groupedTasks = useMemo(() => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const groups = {}
|
||||||
|
|
||||||
|
tasks.forEach(task => {
|
||||||
|
const projects = getTaskProjects(task)
|
||||||
|
|
||||||
|
// Если у задачи нет проектов, добавляем в группу "Без проекта"
|
||||||
|
if (projects.length === 0) {
|
||||||
|
projects.push('Без проекта')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем, в какую группу попадает задача
|
||||||
|
let isCompleted = false
|
||||||
|
let isInfinite = false
|
||||||
|
|
||||||
|
// Используем только next_show_at для группировки
|
||||||
|
if (task.next_show_at) {
|
||||||
|
const nextShowDate = new Date(task.next_show_at)
|
||||||
|
nextShowDate.setHours(0, 0, 0, 0)
|
||||||
|
isCompleted = nextShowDate.getTime() > today.getTime()
|
||||||
|
isInfinite = false
|
||||||
|
} else {
|
||||||
|
// Бесконечная задача: repetition_period == 0 И (repetition_date == 0 ИЛИ отсутствует)
|
||||||
|
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной
|
||||||
|
const hasZeroPeriod = task.repetition_period && isZeroPeriod(task.repetition_period)
|
||||||
|
const hasZeroDate = task.repetition_date && isZeroDate(task.repetition_date)
|
||||||
|
// Идеально: оба поля = 0, но для старых задач может быть только repetition_period = 0
|
||||||
|
isInfinite = (hasZeroPeriod && hasZeroDate) || (hasZeroPeriod && !task.repetition_date)
|
||||||
|
isCompleted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.forEach(projectName => {
|
||||||
|
if (!groups[projectName]) {
|
||||||
|
groups[projectName] = {
|
||||||
|
notCompleted: [],
|
||||||
|
completed: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
groups[projectName].completed.push(task)
|
||||||
|
} else {
|
||||||
|
// Бесконечные задачи теперь идут в обычный список
|
||||||
|
groups[projectName].notCompleted.push(task)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}, [tasks])
|
||||||
|
|
||||||
|
// Сортируем проекты: сначала с невыполненными задачами, потом без них
|
||||||
|
const projectNames = useMemo(() => {
|
||||||
|
const sorted = Object.keys(groupedTasks).sort((a, b) => {
|
||||||
|
const groupA = groupedTasks[a]
|
||||||
|
const groupB = groupedTasks[b]
|
||||||
|
const hasNotCompletedA = groupA.notCompleted.length > 0
|
||||||
|
const hasNotCompletedB = groupB.notCompleted.length > 0
|
||||||
|
|
||||||
|
// Если у одной группы есть невыполненные, а у другой нет - сортируем по этому признаку
|
||||||
|
if (hasNotCompletedA && !hasNotCompletedB) return -1
|
||||||
|
if (!hasNotCompletedA && hasNotCompletedB) return 1
|
||||||
|
|
||||||
|
// Если обе группы в одной категории - сортируем по алфавиту
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}, [groupedTasks])
|
||||||
|
|
||||||
|
const renderTaskItem = (task, isCompleted = false) => {
|
||||||
|
const hasProgression = task.has_progression || task.progression_base != null
|
||||||
|
const hasSubtasks = task.subtasks_count > 0
|
||||||
|
const showDetailOnCheckmark = hasProgression || hasSubtasks
|
||||||
|
const isTest = task.config_id != null
|
||||||
|
const isWishlist = task.wishlist_id != null
|
||||||
|
|
||||||
|
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
|
||||||
|
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной
|
||||||
|
const hasZeroPeriod = task.repetition_period && isZeroPeriod(task.repetition_period)
|
||||||
|
const hasZeroDate = task.repetition_date && isZeroDate(task.repetition_date)
|
||||||
|
// Бесконечная задача: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
|
||||||
|
// Не проверяем next_show_at, так как для бесконечных задач он может быть установлен при выполнении
|
||||||
|
const isInfinite = (hasZeroPeriod && hasZeroDate) || (hasZeroPeriod && !task.repetition_date)
|
||||||
|
|
||||||
|
// Одноразовая задача: когда оба поля null/undefined
|
||||||
|
const isOneTime = (task.repetition_period == null || task.repetition_period === undefined) &&
|
||||||
|
(task.repetition_date == null || task.repetition_date === undefined)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="task-item"
|
||||||
|
onClick={() => handleTaskClick(task)}
|
||||||
|
>
|
||||||
|
<div className="task-item-content">
|
||||||
|
<div
|
||||||
|
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
|
||||||
|
onClick={(e) => handleCheckmarkClick(task, e)}
|
||||||
|
title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
|
||||||
|
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="task-name-container">
|
||||||
|
<div className="task-name-wrapper">
|
||||||
|
<div className="task-name">
|
||||||
|
{task.name}
|
||||||
|
{hasSubtasks && (
|
||||||
|
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
|
||||||
|
)}
|
||||||
|
<span className="task-badge-bar">
|
||||||
|
{isWishlist && (
|
||||||
|
<svg
|
||||||
|
className="task-wishlist-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
title="Связано с желанием"
|
||||||
|
>
|
||||||
|
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
||||||
|
<rect x="2" y="7" width="20" height="5"></rect>
|
||||||
|
<line x1="12" y1="22" x2="12" y2="7"></line>
|
||||||
|
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
||||||
|
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isTest && (
|
||||||
|
<svg
|
||||||
|
className="task-test-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
title="Тест"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{hasProgression && (
|
||||||
|
<svg
|
||||||
|
className="task-progression-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
title="Задача с прогрессией"
|
||||||
|
>
|
||||||
|
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
|
||||||
|
<polyline points="17 6 23 6 23 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isInfinite && (
|
||||||
|
<svg
|
||||||
|
className="task-infinite-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
title="Бесконечная задача"
|
||||||
|
>
|
||||||
|
<path d="M12 12c0-2.5-1.5-4.5-3.5-4.5S5 9.5 5 12s1.5 4.5 3.5 4.5S12 14.5 12 12z"/>
|
||||||
|
<path d="M12 12c0 2.5 1.5 4.5 3.5 4.5S19 14.5 19 12s-1.5-4.5-3.5-4.5S12 9.5 12 12z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isOneTime && (
|
||||||
|
<svg
|
||||||
|
className="task-onetime-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
title="Одноразовая задача"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="14"></line>
|
||||||
|
<circle cx="12" cy="18" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Показываем дату только для выполненных задач */}
|
||||||
|
{isCompleted && task.next_show_at && (() => {
|
||||||
|
const showDate = new Date(task.next_show_at)
|
||||||
|
// Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени
|
||||||
|
const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate())
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||||
|
|
||||||
|
const tomorrowNormalized = new Date(todayNormalized)
|
||||||
|
tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1)
|
||||||
|
|
||||||
|
// Не показываем текст если дата равна сегодня
|
||||||
|
if (showDateNormalized.getTime() === todayNormalized.getTime()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateText
|
||||||
|
if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) {
|
||||||
|
dateText = 'Завтра'
|
||||||
|
} else {
|
||||||
|
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-next-show-date">
|
||||||
|
{dateText}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-actions">
|
||||||
|
<button
|
||||||
|
className="task-postpone-button"
|
||||||
|
onClick={(e) => handlePostponeClick(task, e)}
|
||||||
|
title="Перенести задачу"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
||||||
|
<path d="M10 5V10L13 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем загрузку только если данных нет и это не фоновая загрузка
|
||||||
|
// Проверяем наличие данных более надежно: либо в data, либо в tasks
|
||||||
|
// Важно: проверяем оба источника данных, так как они могут обновляться асинхронно
|
||||||
|
const hasDataInProps = data && Array.isArray(data) && data.length > 0
|
||||||
|
const hasDataInState = tasks && Array.isArray(tasks) && tasks.length > 0
|
||||||
|
const hasData = hasDataInProps || hasDataInState
|
||||||
|
|
||||||
|
// Показываем ошибку загрузки, если есть ошибка и нет данных
|
||||||
|
if (error && !hasData && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="task-list">
|
||||||
|
<LoadingError onRetry={onRetry} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем загрузку только если:
|
||||||
|
// 1. Идет загрузка (loading = true)
|
||||||
|
// 2. Это не фоновая загрузка (backgroundLoading = false)
|
||||||
|
// 3. Данных нет (hasData = false)
|
||||||
|
// Это предотвращает показ загрузки при переключении табов, когда данные уже есть
|
||||||
|
if (loading && !backgroundLoading && !hasData) {
|
||||||
|
return (
|
||||||
|
<div className="task-list">
|
||||||
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-list">
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type || 'success'}
|
||||||
|
onClose={() => setToast(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button onClick={handleAddClick} className="add-task-button">
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{projectNames.length === 0 && !loading && tasks.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>Задач пока нет. Добавьте задачу через кнопку "Добавить".</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projectNames.map(projectName => {
|
||||||
|
const group = groupedTasks[projectName]
|
||||||
|
const hasCompleted = group.completed.length > 0
|
||||||
|
const hasNotCompleted = group.notCompleted.length > 0
|
||||||
|
const isCompletedExpanded = expandedCompleted[projectName]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={projectName} className="project-group">
|
||||||
|
<div className="project-group-header">
|
||||||
|
<h3 className={`project-group-title ${!hasNotCompleted ? 'project-group-title-empty' : ''}`}>{projectName}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Обычные задачи (включая бесконечные) */}
|
||||||
|
{group.notCompleted.length > 0 && (
|
||||||
|
<div className="task-group">
|
||||||
|
{group.notCompleted.map(task => renderTaskItem(task, false))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Выполненные задачи */}
|
||||||
|
{hasCompleted && (
|
||||||
|
<div className="completed-section">
|
||||||
|
<button
|
||||||
|
className="completed-toggle"
|
||||||
|
onClick={() => toggleCompletedExpanded(projectName)}
|
||||||
|
>
|
||||||
|
<span className="completed-toggle-icon">
|
||||||
|
{isCompletedExpanded ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
<span>Выполненные ({group.completed.length})</span>
|
||||||
|
</button>
|
||||||
|
{isCompletedExpanded && (
|
||||||
|
<div className="task-group completed-tasks">
|
||||||
|
{group.completed.map(task => renderTaskItem(task, true))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group.notCompleted.length === 0 && !hasCompleted && (
|
||||||
|
<div className="empty-group">Нет задач в этой группе</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Модальное окно для деталей задачи */}
|
||||||
|
{selectedTaskForDetail && (
|
||||||
|
<TaskDetail
|
||||||
|
taskId={selectedTaskForDetail}
|
||||||
|
onClose={handleCloseDetail}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
onTaskCompleted={() => setToast({ message: 'Задача выполнена', type: 'success' })}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно выбора типа задачи */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="task-add-modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||||
|
<div className="task-add-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="task-add-modal-header">
|
||||||
|
<h3>Что добавить?</h3>
|
||||||
|
</div>
|
||||||
|
<div className="task-add-modal-buttons">
|
||||||
|
<button
|
||||||
|
className="task-add-modal-button task-add-modal-button-task"
|
||||||
|
onClick={handleAddTask}
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 11l3 3L22 4"></path>
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||||
|
</svg>
|
||||||
|
Задача
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="task-add-modal-button task-add-modal-button-test"
|
||||||
|
onClick={handleAddTest}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Тест
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно для переноса задачи */}
|
||||||
|
{selectedTaskForPostpone && (() => {
|
||||||
|
const todayStr = formatDateToLocal(new Date())
|
||||||
|
const tomorrow = new Date()
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
const tomorrowStr = formatDateToLocal(tomorrow)
|
||||||
|
|
||||||
|
// Проверяем next_show_at задачи, а не значение в поле ввода
|
||||||
|
let nextShowAtStr = null
|
||||||
|
if (selectedTaskForPostpone.next_show_at) {
|
||||||
|
const nextShowAtDate = new Date(selectedTaskForPostpone.next_show_at)
|
||||||
|
nextShowAtStr = formatDateToLocal(nextShowAtDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isToday = nextShowAtStr === todayStr
|
||||||
|
const isTomorrow = nextShowAtStr === tomorrowStr
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-postpone-modal-overlay" onClick={handlePostponeClose}>
|
||||||
|
<div className="task-postpone-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="task-postpone-modal-header">
|
||||||
|
<h3>{selectedTaskForPostpone.name}</h3>
|
||||||
|
<button onClick={handlePostponeClose} className="task-postpone-close-button">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="task-postpone-modal-content">
|
||||||
|
<div className="task-postpone-input-group">
|
||||||
|
<input
|
||||||
|
ref={dateInputRef}
|
||||||
|
type="date"
|
||||||
|
value={postponeDate}
|
||||||
|
onChange={(e) => setPostponeDate(e.target.value)}
|
||||||
|
className="task-postpone-input"
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="task-postpone-display-date"
|
||||||
|
onClick={() => {
|
||||||
|
// Открываем календарь при клике
|
||||||
|
if (dateInputRef.current) {
|
||||||
|
if (typeof dateInputRef.current.showPicker === 'function') {
|
||||||
|
dateInputRef.current.showPicker()
|
||||||
|
} else {
|
||||||
|
// Fallback для браузеров без showPicker
|
||||||
|
dateInputRef.current.focus()
|
||||||
|
dateInputRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{postponeDate ? formatDateForDisplay(postponeDate) : 'Выберите дату'}
|
||||||
|
</div>
|
||||||
|
{postponeDate && (
|
||||||
|
<button
|
||||||
|
onClick={handlePostponeSubmit}
|
||||||
|
disabled={isPostponing || !postponeDate}
|
||||||
|
className="task-postpone-submit-checkmark"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="task-postpone-quick-buttons">
|
||||||
|
{!isToday && (
|
||||||
|
<button
|
||||||
|
onClick={handleTodayClick}
|
||||||
|
className="task-postpone-quick-button"
|
||||||
|
disabled={isPostponing}
|
||||||
|
>
|
||||||
|
Сегодня
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isTomorrow && (
|
||||||
|
<button
|
||||||
|
onClick={handleTomorrowClick}
|
||||||
|
className="task-postpone-quick-button"
|
||||||
|
disabled={isPostponing}
|
||||||
|
>
|
||||||
|
Завтра
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskList
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
import './Integrations.css'
|
import './Integrations.css'
|
||||||
|
|
||||||
function TelegramIntegration({ onBack }) {
|
function TelegramIntegration({ onNavigate }) {
|
||||||
const [botToken, setBotToken] = useState('')
|
const { authFetch } = useAuth()
|
||||||
const [chatId, setChatId] = useState('')
|
const [integration, setIntegration] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [success, setSuccess] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchIntegration()
|
fetchIntegration()
|
||||||
@@ -16,13 +16,12 @@ function TelegramIntegration({ onBack }) {
|
|||||||
const fetchIntegration = async () => {
|
const fetchIntegration = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await fetch('/api/integrations/telegram')
|
const response = await authFetch('/api/integrations/telegram')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке интеграции')
|
throw new Error('Ошибка при загрузке интеграции')
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setBotToken(data.bot_token || '')
|
setIntegration(data)
|
||||||
setChatId(data.chat_id || '')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching integration:', error)
|
console.error('Error fetching integration:', error)
|
||||||
setError('Не удалось загрузить данные интеграции')
|
setError('Не удалось загрузить данные интеграции')
|
||||||
@@ -31,140 +30,111 @@ function TelegramIntegration({ onBack }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleOpenBot = () => {
|
||||||
if (!botToken.trim()) {
|
if (integration?.deep_link) {
|
||||||
setError('Bot Token обязателен для заполнения')
|
window.open(integration.deep_link, '_blank')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const handleRefresh = () => {
|
||||||
setSaving(true)
|
fetchIntegration()
|
||||||
setError('')
|
}
|
||||||
setSuccess('')
|
|
||||||
|
|
||||||
const response = await fetch('/api/integrations/telegram', {
|
if (loading) {
|
||||||
method: 'POST',
|
return (
|
||||||
headers: {
|
<div className="fixed inset-0 flex justify-center items-center">
|
||||||
'Content-Type': 'application/json',
|
<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>
|
||||||
body: JSON.stringify({ bot_token: botToken }),
|
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||||
})
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (error && !integration) {
|
||||||
const errorData = await response.json()
|
return (
|
||||||
throw new Error(errorData.error || 'Ошибка при сохранении')
|
<div className="p-4 md:p-6">
|
||||||
}
|
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
|
||||||
|
✕
|
||||||
const data = await response.json()
|
</button>
|
||||||
setBotToken(data.bot_token || '')
|
<LoadingError onRetry={fetchIntegration} />
|
||||||
setChatId(data.chat_id || '')
|
</div>
|
||||||
setSuccess('Bot Token успешно сохранен!')
|
)
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving integration:', error)
|
|
||||||
setError(error.message || 'Не удалось сохранить Bot Token')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<button className="close-x-button" onClick={onBack} title="Закрыть">
|
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold mb-6">Telegram интеграция</h1>
|
<h1 className="text-2xl font-bold mb-6">Telegram интеграция</h1>
|
||||||
|
|
||||||
{loading ? (
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div className="text-gray-500">Загрузка...</div>
|
<h2 className="text-lg font-semibold mb-4">Статус подключения</h2>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">Настройки</h2>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
{integration?.is_connected ? (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<div className="space-y-4">
|
||||||
Telegram Bot Token
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
</label>
|
<div className="flex items-center text-green-700">
|
||||||
<input
|
<span className="text-xl mr-2">✓</span>
|
||||||
type="text"
|
<span className="font-medium">Telegram подключен</span>
|
||||||
value={botToken}
|
</div>
|
||||||
onChange={(e) => setBotToken(e.target.value)}
|
|
||||||
placeholder="Введите Bot Token"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatId && (
|
{integration.telegram_user_id && (
|
||||||
<div className="mb-4">
|
<div className="text-sm text-gray-600">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
Telegram ID: <span className="font-mono">{integration.telegram_user_id}</span>
|
||||||
Chat ID (устанавливается автоматически)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={chatId}
|
|
||||||
readOnly
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
|
|
||||||
{success}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleOpenBot}
|
||||||
disabled={saving || !botToken.trim()}
|
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
{saving ? 'Сохранение...' : 'Сохранить Bot Token'}
|
Открыть бота
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<div className="flex items-center text-yellow-700">
|
||||||
|
<span className="text-xl mr-2">⚠</span>
|
||||||
|
<span className="font-medium">Telegram не подключен</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Нажмите кнопку ниже и отправьте команду /start в боте
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
<button
|
||||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
onClick={handleOpenBot}
|
||||||
Откуда взять Bot Token
|
disabled={!integration?.deep_link}
|
||||||
</h3>
|
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||||
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
>
|
||||||
<li>Откройте Telegram и найдите бота @BotFather</li>
|
Подключить Telegram
|
||||||
<li>Отправьте команду /newbot</li>
|
</button>
|
||||||
<li>Следуйте инструкциям для создания нового бота</li>
|
|
||||||
<li>
|
|
||||||
После создания бота BotFather предоставит вам Bot Token
|
|
||||||
</li>
|
|
||||||
<li>Скопируйте токен и вставьте его в поле выше</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
<button
|
||||||
<h3 className="text-lg font-semibold mb-3 text-yellow-900">
|
onClick={handleRefresh}
|
||||||
Что нужно сделать после сохранения Bot Token
|
className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||||
</h3>
|
>
|
||||||
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
Проверить подключение
|
||||||
<li>После сохранения Bot Token отправьте первое сообщение вашему боту в Telegram</li>
|
</button>
|
||||||
<li>
|
|
||||||
Chat ID будет автоматически сохранен после обработки первого
|
|
||||||
сообщения
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
После этого бот сможет отправлять вам ответные сообщения
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 text-blue-900">Инструкция</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
||||||
|
<li>Нажмите кнопку "Подключить Telegram"</li>
|
||||||
|
<li>В открывшемся Telegram нажмите "Start" или отправьте /start</li>
|
||||||
|
<li>Вернитесь сюда и нажмите "Проверить подключение"</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TelegramIntegration
|
export default TelegramIntegration
|
||||||
|
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
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', { dictionaryId: null, 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
|
|
||||||
|
|
||||||
@@ -16,30 +16,6 @@
|
|||||||
flex-direction: column;
|
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 {
|
.test-duration-selection {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -110,9 +86,13 @@
|
|||||||
.test-progress {
|
.test-progress {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-text {
|
.test-progress .progress-text {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -259,6 +239,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
align-content: start;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 4rem 1rem 1rem 1rem;
|
padding: 4rem 1rem 1rem 1rem;
|
||||||
@@ -347,7 +328,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: start;
|
align-content: start;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 4rem 1rem 1rem 1rem;
|
padding: 4rem 1rem 1rem 1rem;
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
import './TestWords.css'
|
import './TestWords.css'
|
||||||
|
import './Integrations.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
const DEFAULT_TEST_WORD_COUNT = 10
|
const DEFAULT_TEST_WORD_COUNT = 10
|
||||||
|
|
||||||
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
|
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards, taskId: initialTaskId }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||||
const configId = initialConfigId || null
|
const configId = initialConfigId || null
|
||||||
const maxCards = initialMaxCards || null
|
const maxCards = initialMaxCards || null
|
||||||
|
const taskId = initialTaskId || null
|
||||||
|
|
||||||
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
||||||
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentWord, setCurrentWord] = useState(null) // Текущее слово, которое показывается (уже удалено из пула)
|
||||||
const [flippedCards, setFlippedCards] = useState(new Set())
|
const [flippedCards, setFlippedCards] = useState(new Set())
|
||||||
const [wordStats, setWordStats] = useState({}) // Локальная статистика
|
const [wordStats, setWordStats] = useState({}) // Локальная статистика
|
||||||
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
|
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
|
||||||
@@ -25,16 +30,191 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const isFinishingRef = useRef(false)
|
const isFinishingRef = useRef(false)
|
||||||
const wordStatsRef = useRef({})
|
const wordStatsRef = useRef({})
|
||||||
const processingRef = useRef(false)
|
const processingRef = useRef(false)
|
||||||
|
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
|
||||||
|
|
||||||
|
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
|
||||||
|
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
|
||||||
|
const redistributeWordsEvenly = (currentPool, allWords, excludeFirstWordId = null) => {
|
||||||
|
if (currentPool.length === 0 || allWords.length === 0) {
|
||||||
|
return currentPool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем, сколько раз каждое слово встречается в текущем пуле
|
||||||
|
const wordCounts = {}
|
||||||
|
currentPool.forEach(word => {
|
||||||
|
wordCounts[word.id] = (wordCounts[word.id] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем список уникальных слов, которые есть в пуле
|
||||||
|
const uniqueWordIds = Object.keys(wordCounts).map(id => parseInt(id))
|
||||||
|
const uniqueWords = allWords.filter(word => uniqueWordIds.includes(word.id))
|
||||||
|
|
||||||
|
if (uniqueWords.length === 0) {
|
||||||
|
return currentPool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, есть ли в пуле слова, отличные от исключаемого
|
||||||
|
const hasOtherWords = uniqueWords.some(w => w.id !== excludeFirstWordId)
|
||||||
|
const effectiveExcludeId = hasOtherWords ? excludeFirstWordId : null
|
||||||
|
|
||||||
|
// Создаём массив всех экземпляров слов для распределения
|
||||||
|
const allInstances = []
|
||||||
|
for (const word of uniqueWords) {
|
||||||
|
const count = wordCounts[word.id]
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
allInstances.push({ ...word })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перемешиваем экземпляры
|
||||||
|
for (let i = allInstances.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[allInstances[i], allInstances[j]] = [allInstances[j], allInstances[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем жадный алгоритм: на каждую позицию выбираем слово,
|
||||||
|
// которое максимально далеко от своего последнего появления
|
||||||
|
const totalSlots = currentPool.length
|
||||||
|
const newPool = new Array(totalSlots).fill(null)
|
||||||
|
const lastPosition = {} // Последняя позиция каждого слова
|
||||||
|
|
||||||
|
for (let pos = 0; pos < totalSlots; pos++) {
|
||||||
|
let bestWord = null
|
||||||
|
let bestWordIndex = -1
|
||||||
|
let bestDistance = -1
|
||||||
|
|
||||||
|
for (let i = 0; i < allInstances.length; i++) {
|
||||||
|
const word = allInstances[i]
|
||||||
|
|
||||||
|
// Для позиции 0: не выбираем исключаемое слово, если есть альтернативы
|
||||||
|
if (pos === 0 && word.id === effectiveExcludeId) {
|
||||||
|
// Проверяем, есть ли другие слова
|
||||||
|
const hasAlternative = allInstances.some(w => w.id !== effectiveExcludeId)
|
||||||
|
if (hasAlternative) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем расстояние от последнего появления этого слова
|
||||||
|
const lastPos = lastPosition[word.id]
|
||||||
|
const distance = lastPos === undefined ? totalSlots : (pos - lastPos)
|
||||||
|
|
||||||
|
// Выбираем слово с максимальным расстоянием
|
||||||
|
if (distance > bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestWord = word
|
||||||
|
bestWordIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestWord !== null) {
|
||||||
|
newPool[pos] = bestWord
|
||||||
|
lastPosition[bestWord.id] = pos
|
||||||
|
allInstances.splice(bestWordIndex, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Финальная проверка: если на позиции 0 оказалось исключаемое слово, меняем его с ближайшим другим
|
||||||
|
if (effectiveExcludeId !== null && newPool[0] && newPool[0].id === effectiveExcludeId) {
|
||||||
|
for (let i = 1; i < newPool.length; i++) {
|
||||||
|
if (newPool[i] && newPool[i].id !== effectiveExcludeId) {
|
||||||
|
;[newPool[0], newPool[i]] = [newPool[i], newPool[0]]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пост-обработка: исправляем последовательные дубликаты (одинаковые слова подряд)
|
||||||
|
let iterations = 0
|
||||||
|
const maxIterations = totalSlots * 2 // Предотвращаем бесконечный цикл
|
||||||
|
let hasConsecutiveDuplicates = true
|
||||||
|
|
||||||
|
while (hasConsecutiveDuplicates && iterations < maxIterations) {
|
||||||
|
hasConsecutiveDuplicates = false
|
||||||
|
iterations++
|
||||||
|
|
||||||
|
for (let i = 0; i < newPool.length - 1; i++) {
|
||||||
|
if (newPool[i] && newPool[i + 1] && newPool[i].id === newPool[i + 1].id) {
|
||||||
|
// Нашли последовательные дубликаты на позициях i и i+1
|
||||||
|
// Ищем слово для обмена (не то же самое и не соседнее с дубликатом после обмена)
|
||||||
|
let swapped = false
|
||||||
|
|
||||||
|
for (let j = i + 2; j < newPool.length && !swapped; j++) {
|
||||||
|
if (!newPool[j]) continue
|
||||||
|
|
||||||
|
// Проверяем, что слово на позиции j отличается от дубликата
|
||||||
|
if (newPool[j].id === newPool[i].id) continue
|
||||||
|
|
||||||
|
// Проверяем, что после обмена не создадим новые дубликаты
|
||||||
|
// Позиция j-1 (если существует) не должна иметь тот же id, что и newPool[i+1]
|
||||||
|
// Позиция j+1 (если существует) не должна иметь тот же id, что и newPool[i+1]
|
||||||
|
const wouldCreateDuplicateBefore = j > 0 && newPool[j - 1] && newPool[j - 1].id === newPool[i + 1].id
|
||||||
|
const wouldCreateDuplicateAfter = j < newPool.length - 1 && newPool[j + 1] && newPool[j + 1].id === newPool[i + 1].id
|
||||||
|
|
||||||
|
if (!wouldCreateDuplicateBefore && !wouldCreateDuplicateAfter) {
|
||||||
|
// Меняем местами
|
||||||
|
;[newPool[i + 1], newPool[j]] = [newPool[j], newPool[i + 1]]
|
||||||
|
swapped = true
|
||||||
|
hasConsecutiveDuplicates = true // Нужна ещё одна итерация для проверки
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не нашли подходящую позицию справа, ищем слева
|
||||||
|
if (!swapped) {
|
||||||
|
for (let j = 0; j < i && !swapped; j++) {
|
||||||
|
if (!newPool[j]) continue
|
||||||
|
if (newPool[j].id === newPool[i].id) continue
|
||||||
|
|
||||||
|
// Для позиции 0: не меняем на исключаемое слово
|
||||||
|
if (j === 0 && newPool[i + 1].id === effectiveExcludeId) continue
|
||||||
|
|
||||||
|
const wouldCreateDuplicateBefore = j > 0 && newPool[j - 1] && newPool[j - 1].id === newPool[i + 1].id
|
||||||
|
const wouldCreateDuplicateAfter = j < newPool.length - 1 && newPool[j + 1] && newPool[j + 1].id === newPool[i + 1].id
|
||||||
|
|
||||||
|
if (!wouldCreateDuplicateBefore && !wouldCreateDuplicateAfter) {
|
||||||
|
;[newPool[i + 1], newPool[j]] = [newPool[j], newPool[i + 1]]
|
||||||
|
swapped = true
|
||||||
|
hasConsecutiveDuplicates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ещё раз проверяем позицию 0 после всех обменов
|
||||||
|
if (effectiveExcludeId !== null && newPool[0] && newPool[0].id === effectiveExcludeId) {
|
||||||
|
for (let i = 1; i < newPool.length; i++) {
|
||||||
|
if (newPool[i] && newPool[i].id !== effectiveExcludeId) {
|
||||||
|
// Проверяем, не создаст ли обмен дубликат на позиции 1
|
||||||
|
if (i === 1 || (newPool[1] && newPool[1].id !== newPool[i].id)) {
|
||||||
|
;[newPool[0], newPool[i]] = [newPool[i], newPool[0]]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполняем null-позиции (не должно происходить, но на всякий случай)
|
||||||
|
for (let i = 0; i < newPool.length; i++) {
|
||||||
|
if (newPool[i] === null && currentPool[i]) {
|
||||||
|
newPool[i] = currentPool[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPool
|
||||||
|
}
|
||||||
|
|
||||||
// Загрузка слов при монтировании
|
// Загрузка слов при монтировании
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWords([])
|
setWords([])
|
||||||
setTestWords([])
|
setTestWords([])
|
||||||
setCurrentIndex(0)
|
setCurrentWord(null)
|
||||||
setFlippedCards(new Set())
|
setFlippedCards(new Set())
|
||||||
setWordStats({})
|
setWordStats({})
|
||||||
wordStatsRef.current = {}
|
wordStatsRef.current = {}
|
||||||
setCardsShown(0)
|
setCardsShown(0)
|
||||||
|
cardsShownRef.current = 0 // Сбрасываем синхронный счётчик
|
||||||
setTotalAnswers(0)
|
setTotalAnswers(0)
|
||||||
setError('')
|
setError('')
|
||||||
setShowPreview(false) // Сбрасываем экран предпросмотра
|
setShowPreview(false) // Сбрасываем экран предпросмотра
|
||||||
@@ -49,7 +229,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
throw new Error('config_id обязателен для запуска теста')
|
throw new Error('config_id обязателен для запуска теста')
|
||||||
}
|
}
|
||||||
const url = `${API_URL}/test/words?config_id=${configId}`
|
const url = `${API_URL}/test/words?config_id=${configId}`
|
||||||
const response = await fetch(url)
|
const response = await authFetch(url)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке слов')
|
throw new Error('Ошибка при загрузке слов')
|
||||||
}
|
}
|
||||||
@@ -79,16 +259,13 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
||||||
|
|
||||||
// Создаем пул, где каждое слово повторяется n раз
|
// Создаем пул, где каждое слово повторяется n раз
|
||||||
const wordPool = []
|
let wordPool = []
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
wordPool.push(...data)
|
wordPool.push(...data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перемешиваем пул случайным образом
|
// Равномерно распределяем слова в пуле
|
||||||
for (let i = wordPool.length - 1; i > 0; i--) {
|
wordPool = redistributeWordsEvenly(wordPool, data)
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[wordPool[i], wordPool[j]] = [wordPool[j], wordPool[i]]
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestWords(wordPool)
|
setTestWords(wordPool)
|
||||||
setWordStats(stats)
|
setWordStats(stats)
|
||||||
@@ -109,16 +286,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
loadWords()
|
loadWords()
|
||||||
}, [wordCount, configId])
|
}, [wordCount, configId])
|
||||||
|
|
||||||
const getCurrentWord = () => {
|
// Правый счётчик: текущий размер пула + показанные карточки, но не больше maxCards
|
||||||
if (currentIndex < testWords.length && testWords.length > 0) {
|
|
||||||
return testWords[currentIndex]
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Правый счётчик: кол-во полученных ответов + кол-во слов в пуле (не больше maxCards)
|
|
||||||
const getRightCounter = () => {
|
const getRightCounter = () => {
|
||||||
const total = totalAnswers + testWords.length
|
const total = testWords.length + cardsShown
|
||||||
if (maxCards !== null && maxCards > 0) {
|
if (maxCards !== null && maxCards > 0) {
|
||||||
return Math.min(total, maxCards)
|
return Math.min(total, maxCards)
|
||||||
}
|
}
|
||||||
@@ -126,7 +296,15 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCardFlip = (wordId) => {
|
const handleCardFlip = (wordId) => {
|
||||||
setFlippedCards(prev => new Set(prev).add(wordId))
|
setFlippedCards(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(wordId)) {
|
||||||
|
newSet.delete(wordId)
|
||||||
|
} else {
|
||||||
|
newSet.add(wordId)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Завершение теста
|
// Завершение теста
|
||||||
@@ -176,7 +354,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
requestBody
|
requestBody
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/test/progress`, {
|
const response = await authFetch(`${API_URL}/test/progress`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
@@ -189,40 +367,98 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
|
|
||||||
const responseData = await response.json().catch(() => ({}))
|
const responseData = await response.json().catch(() => ({}))
|
||||||
console.log('Test progress saved successfully:', responseData)
|
console.log('Test progress saved successfully:', responseData)
|
||||||
|
|
||||||
|
// Если есть taskId, выполняем задачу
|
||||||
|
if (taskId) {
|
||||||
|
try {
|
||||||
|
const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (completeResponse.ok) {
|
||||||
|
console.log('Task completed successfully')
|
||||||
|
} else {
|
||||||
|
console.error('Failed to complete task:', await completeResponse.text())
|
||||||
|
}
|
||||||
|
} catch (taskErr) {
|
||||||
|
console.error('Failed to complete task:', taskErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save progress:', err)
|
console.error('Failed to save progress:', err)
|
||||||
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка условий завершения и показ следующей карточки
|
// Берём карточку из пула (getAndDelete) и показываем её
|
||||||
const showNextCardOrFinish = (newTestWords, currentCardsShown) => {
|
const showNextCard = () => {
|
||||||
// Проверяем, не завершился ли тест (на случай если finishTest уже был вызван)
|
// Проверяем, не завершился ли тест
|
||||||
if (isFinishingRef.current || showResults) {
|
if (isFinishingRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Условие 1: Достигли максимума карточек
|
// Используем функциональное обновление для получения актуального состояния пула
|
||||||
if (maxCards !== null && maxCards > 0 && currentCardsShown >= maxCards) {
|
setTestWords(prevPool => {
|
||||||
finishTest()
|
// Повторная проверка внутри callback (на случай если состояние изменилось)
|
||||||
return
|
if (isFinishingRef.current) {
|
||||||
}
|
return prevPool
|
||||||
|
}
|
||||||
// Условие 2: Пул слов пуст
|
|
||||||
if (newTestWords.length === 0) {
|
// Используем ref для синхронного доступа к счётчику
|
||||||
finishTest()
|
const nextCardsShown = cardsShownRef.current + 1
|
||||||
return
|
|
||||||
}
|
// Условие 1: Достигли максимума карточек
|
||||||
|
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
||||||
// Показываем следующую карточку и увеличиваем левый счётчик
|
finishTest()
|
||||||
// Но сначала проверяем, не достигнем ли мы максимума после увеличения
|
return prevPool
|
||||||
const nextCardsShown = currentCardsShown + 1
|
}
|
||||||
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
|
||||||
finishTest()
|
// Условие 2: Пул слов пуст
|
||||||
return
|
if (prevPool.length === 0) {
|
||||||
}
|
finishTest()
|
||||||
|
return prevPool
|
||||||
setCardsShown(nextCardsShown)
|
}
|
||||||
|
|
||||||
|
// getAndDelete: берём слово из пула и удаляем его
|
||||||
|
const nextWord = prevPool[0]
|
||||||
|
|
||||||
|
// Условие 3: Первое слово в пуле null/undefined (не должно происходить, но на всякий случай)
|
||||||
|
if (!nextWord) {
|
||||||
|
// Ищем первое не-null слово в пуле
|
||||||
|
const validWordIndex = prevPool.findIndex(w => w !== null && w !== undefined)
|
||||||
|
if (validWordIndex === -1) {
|
||||||
|
// Нет валидных слов - завершаем тест
|
||||||
|
finishTest()
|
||||||
|
return prevPool
|
||||||
|
}
|
||||||
|
// Берём валидное слово
|
||||||
|
const validWord = prevPool[validWordIndex]
|
||||||
|
const updatedPool = [...prevPool.slice(0, validWordIndex), ...prevPool.slice(validWordIndex + 1)]
|
||||||
|
|
||||||
|
// Синхронно обновляем ref
|
||||||
|
cardsShownRef.current = nextCardsShown
|
||||||
|
|
||||||
|
setCurrentWord(validWord)
|
||||||
|
setCardsShown(nextCardsShown)
|
||||||
|
setFlippedCards(new Set())
|
||||||
|
|
||||||
|
return updatedPool
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPool = prevPool.slice(1)
|
||||||
|
|
||||||
|
// Синхронно обновляем ref ПЕРЕД установкой state
|
||||||
|
cardsShownRef.current = nextCardsShown
|
||||||
|
|
||||||
|
// showCard: показываем карточку
|
||||||
|
setCurrentWord(nextWord)
|
||||||
|
setCardsShown(nextCardsShown)
|
||||||
|
setFlippedCards(new Set())
|
||||||
|
|
||||||
|
return updatedPool
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSuccess = (wordId) => {
|
const handleSuccess = (wordId) => {
|
||||||
@@ -255,25 +491,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const newTotalAnswers = totalAnswers + 1
|
const newTotalAnswers = totalAnswers + 1
|
||||||
setTotalAnswers(newTotalAnswers)
|
setTotalAnswers(newTotalAnswers)
|
||||||
|
|
||||||
// Убираем только один экземпляр слова из пула (по текущему индексу)
|
// onSuccess: просто повторяем (showNextCard)
|
||||||
const newTestWords = [...testWords]
|
// Карточка уже удалена из пула при показе, просто показываем следующую
|
||||||
newTestWords.splice(currentIndex, 1)
|
showNextCard()
|
||||||
|
|
||||||
// Обновляем индекс: если удалили последний элемент, переходим к предыдущему
|
|
||||||
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
|
// Если тест завершился, не сбрасываем processingRef
|
||||||
if (isFinishingRef.current) {
|
if (isFinishingRef.current) {
|
||||||
@@ -313,18 +533,20 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const newTotalAnswers = totalAnswers + 1
|
const newTotalAnswers = totalAnswers + 1
|
||||||
setTotalAnswers(newTotalAnswers)
|
setTotalAnswers(newTotalAnswers)
|
||||||
|
|
||||||
// Слово остаётся в пуле, переходим к следующему
|
// onFailure: возвращаем карточку в пул, сортируем, повторяем
|
||||||
let newIndex = currentIndex + 1
|
setTestWords(prevPool => {
|
||||||
if (newIndex >= testWords.length) {
|
// cards.add(currentCard): возвращаем слово обратно в пул
|
||||||
newIndex = 0
|
let newTestWords = [...prevPool, word]
|
||||||
}
|
|
||||||
|
// cards.sort(): равномерно перераспределяем слова в пуле
|
||||||
|
// Передаём wordId, чтобы текущая карточка не оказалась первой (следующей для показа)
|
||||||
|
newTestWords = redistributeWordsEvenly(newTestWords, words, wordId)
|
||||||
|
|
||||||
|
return newTestWords
|
||||||
|
})
|
||||||
|
|
||||||
setCurrentIndex(newIndex)
|
// repeat(): показываем следующую карточку
|
||||||
setFlippedCards(new Set())
|
showNextCard()
|
||||||
|
|
||||||
// Проверяем условия завершения или показываем следующую карточку
|
|
||||||
// При failure пул не изменяется
|
|
||||||
showNextCardOrFinish(testWords, cardsShown)
|
|
||||||
|
|
||||||
// Если тест завершился, не сбрасываем processingRef
|
// Если тест завершился, не сбрасываем processingRef
|
||||||
if (isFinishingRef.current) {
|
if (isFinishingRef.current) {
|
||||||
@@ -335,17 +557,17 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onNavigate?.('test-config')
|
onNavigate?.('tasks')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStartTest = () => {
|
const handleStartTest = () => {
|
||||||
setShowPreview(false)
|
setShowPreview(false)
|
||||||
// Показываем первую карточку и увеличиваем левый счётчик
|
// Показываем первую карточку (берём из пула)
|
||||||
setCardsShown(1)
|
showNextCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFinish = () => {
|
const handleFinish = () => {
|
||||||
onNavigate?.('test-config')
|
onNavigate?.('tasks')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRandomSide = (word) => {
|
const getRandomSide = (word) => {
|
||||||
@@ -354,7 +576,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="test-container test-container-fullscreen">
|
<div className="test-container test-container-fullscreen">
|
||||||
<button className="test-close-x-button" onClick={handleClose}>
|
<button className="close-x-button" onClick={handleClose}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
@@ -416,13 +638,73 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
) : (
|
) : (
|
||||||
<div className="test-screen">
|
<div className="test-screen">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="test-loading">Загрузка слов...</div>
|
<div className="fixed inset-0 flex justify-center items-center">
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="test-error">{error}</div>
|
<LoadingError onRetry={() => {
|
||||||
|
setError('')
|
||||||
|
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 authFetch(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)
|
||||||
|
|
||||||
|
const wordsCount = data.length
|
||||||
|
const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount
|
||||||
|
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
||||||
|
|
||||||
|
let wordPool = []
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
wordPool.push(...data)
|
||||||
|
}
|
||||||
|
|
||||||
|
wordPool = redistributeWordsEvenly(wordPool, data)
|
||||||
|
|
||||||
|
setTestWords(wordPool)
|
||||||
|
setWordStats(stats)
|
||||||
|
wordStatsRef.current = stats
|
||||||
|
setShowPreview(true)
|
||||||
|
setCardsShown(0)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadWords()
|
||||||
|
}} />
|
||||||
)}
|
)}
|
||||||
{!loading && !error && !isFinishingRef.current && getCurrentWord() && (() => {
|
{!loading && !error && !isFinishingRef.current && currentWord && (() => {
|
||||||
const word = getCurrentWord()
|
const word = currentWord
|
||||||
const isFlipped = flippedCards.has(word.id)
|
const isFlipped = flippedCards.has(word.id)
|
||||||
const showSide = getRandomSide(word)
|
const showSide = getRandomSide(word)
|
||||||
|
|
||||||
@@ -430,7 +712,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
<div className="test-card-container" key={word.id}>
|
<div className="test-card-container" key={word.id}>
|
||||||
<div
|
<div
|
||||||
className={`test-card ${isFlipped ? 'flipped' : ''}`}
|
className={`test-card ${isFlipped ? 'flipped' : ''}`}
|
||||||
onClick={() => !isFlipped && handleCardFlip(word.id)}
|
onClick={() => handleCardFlip(word.id)}
|
||||||
>
|
>
|
||||||
<div className="test-card-front">
|
<div className="test-card-front">
|
||||||
<div className="test-card-content">
|
<div className="test-card-content">
|
||||||
|
|||||||
47
play-life-web/src/components/Toast.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(100px);
|
||||||
|
z-index: 1000;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
min-width: 250px;
|
||||||
|
max-width: 400px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-visible {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error .toast-message {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
30
play-life-web/src/components/Toast.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import './Toast.css'
|
||||||
|
|
||||||
|
function Toast({ message, onClose, duration = 3000, type = 'success' }) {
|
||||||
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose?.()
|
||||||
|
}, 300) // Ждем завершения анимации
|
||||||
|
}, duration)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [duration, onClose])
|
||||||
|
|
||||||
|
if (!isVisible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`toast toast-${type} ${isVisible ? 'toast-visible' : ''}`}>
|
||||||
|
<div className="toast-content">
|
||||||
|
<span className="toast-message">{message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Toast
|
||||||
|
|
||||||
@@ -1,90 +1,210 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
|
import Toast from './Toast'
|
||||||
import './Integrations.css'
|
import './Integrations.css'
|
||||||
|
|
||||||
function TodoistIntegration({ onBack }) {
|
function TodoistIntegration({ onNavigate }) {
|
||||||
const [webhookURL, setWebhookURL] = useState('')
|
const { authFetch } = useAuth()
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [todoistEmail, setTodoistEmail] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [copied, setCopied] = useState(false)
|
const [error, setError] = useState('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
const [isLoadingError, setIsLoadingError] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWebhookURL()
|
checkStatus()
|
||||||
|
// Проверяем URL параметры для сообщений
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const integration = params.get('integration')
|
||||||
|
const status = params.get('status')
|
||||||
|
if (integration === 'todoist') {
|
||||||
|
if (status === 'connected') {
|
||||||
|
setMessage('✅ Todoist успешно подключен!')
|
||||||
|
// Очищаем URL параметры
|
||||||
|
window.history.replaceState({}, '', window.location.pathname)
|
||||||
|
} else if (status === 'error') {
|
||||||
|
const errorMsg = params.get('message') || 'Произошла ошибка'
|
||||||
|
setToastMessage({ text: errorMsg, type: 'error' })
|
||||||
|
window.history.replaceState({}, '', window.location.pathname)
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fetchWebhookURL = async () => {
|
const checkStatus = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await fetch('/api/integrations/todoist/webhook-url')
|
setError('')
|
||||||
|
const response = await authFetch('/api/integrations/todoist/status')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке URL webhook')
|
throw new Error('Ошибка при проверке статуса')
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setWebhookURL(data.webhook_url)
|
setConnected(data.connected || false)
|
||||||
|
if (data.connected && data.todoist_email) {
|
||||||
|
setTodoistEmail(data.todoist_email)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching webhook URL:', error)
|
console.error('Error checking status:', error)
|
||||||
|
setError(error.message || 'Не удалось проверить статус')
|
||||||
|
setIsLoadingError(true)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
const handleConnect = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(webhookURL)
|
setLoading(true)
|
||||||
setCopied(true)
|
setError('')
|
||||||
setTimeout(() => setCopied(false), 2000)
|
// Получаем URL для редиректа через авторизованный запрос
|
||||||
|
const response = await authFetch('/api/integrations/todoist/oauth/connect')
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.error || 'Ошибка при подключении Todoist')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.auth_url) {
|
||||||
|
// Делаем редирект на Todoist OAuth
|
||||||
|
window.location.href = data.auth_url
|
||||||
|
} else {
|
||||||
|
throw new Error('URL для авторизации не получен')
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error copying to clipboard:', error)
|
console.error('Error connecting Todoist:', error)
|
||||||
|
setToastMessage({ text: error.message || 'Не удалось подключить Todoist', type: 'error' })
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
if (!window.confirm('Вы уверены, что хотите отключить Todoist?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
const response = await authFetch('/api/integrations/todoist/disconnect', {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.error || 'Ошибка при отключении')
|
||||||
|
}
|
||||||
|
setConnected(false)
|
||||||
|
setTodoistEmail('')
|
||||||
|
setToastMessage({ text: 'Todoist отключен', type: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error disconnecting:', error)
|
||||||
|
setToastMessage({ text: error.message || 'Не удалось отключить Todoist', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingError && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6">
|
||||||
|
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<LoadingError onRetry={checkStatus} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<button className="close-x-button" onClick={onBack} title="Закрыть">
|
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold mb-6">TODOist интеграция</h1>
|
<h1 className="text-2xl font-bold mb-6">Todoist интеграция</h1>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
{loading ? (
|
||||||
<h2 className="text-lg font-semibold mb-4">Webhook URL</h2>
|
<div className="fixed inset-0 flex justify-center items-center">
|
||||||
{loading ? (
|
<div className="flex flex-col items-center">
|
||||||
<div className="text-gray-500">Загрузка...</div>
|
<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 className="flex items-center gap-2">
|
</div>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
) : connected ? (
|
||||||
value={webhookURL}
|
<div>
|
||||||
readOnly
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-sm"
|
<h2 className="text-lg font-semibold mb-4">Статус подключения</h2>
|
||||||
/>
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-green-600 font-semibold">✅ Todoist подключен</span>
|
||||||
|
</div>
|
||||||
|
{todoistEmail && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Email: </span>
|
||||||
|
<span className="font-medium">{todoistEmail}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
||||||
|
Как это работает
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-700 mb-2">
|
||||||
|
✅ Todoist подключен! Закрывайте задачи в Todoist — они автоматически
|
||||||
|
появятся в Play Life.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Никаких дополнительных настроек не требуется. Просто закрывайте задачи
|
||||||
|
в Todoist, и они будут обработаны автоматически.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Отключить Todoist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Подключение Todoist</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Подключите свой Todoist аккаунт для автоматической обработки закрытых задач.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={copyToClipboard}
|
onClick={handleConnect}
|
||||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors whitespace-nowrap"
|
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold"
|
||||||
>
|
>
|
||||||
{copied ? 'Скопировано!' : 'Копировать'}
|
Подключить Todoist
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
||||||
Как использовать в приложении TODOist
|
Что нужно сделать
|
||||||
</h3>
|
</h3>
|
||||||
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
||||||
<li>Откройте приложение TODOist на вашем устройстве</li>
|
<li>Нажмите кнопку "Подключить Todoist"</li>
|
||||||
<li>Перейдите в настройки проекта или задачи</li>
|
<li>Авторизуйтесь в Todoist</li>
|
||||||
<li>Найдите раздел "Интеграции" или "Webhooks"</li>
|
<li>Готово! Закрытые задачи будут автоматически обрабатываться</li>
|
||||||
<li>Вставьте скопированный URL webhook в соответствующее поле</li>
|
</ol>
|
||||||
<li>Сохраните настройки</li>
|
</div>
|
||||||
<li>
|
</div>
|
||||||
Теперь при закрытии задач в TODOist они будут автоматически
|
)}
|
||||||
обрабатываться системой
|
{toastMessage && (
|
||||||
</li>
|
<Toast
|
||||||
</ol>
|
message={toastMessage.text}
|
||||||
</div>
|
type={toastMessage.type}
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TodoistIntegration
|
export default TodoistIntegration
|
||||||
|
|
||||||
|
|||||||
340
play-life-web/src/components/Wishlist.css
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
.wishlist {
|
||||||
|
max-width: 42rem; /* max-w-2xl = 672px */
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-wishlist-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px dashed #6b8dd6;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0;
|
||||||
|
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;
|
||||||
|
aspect-ratio: 5 / 6;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-wishlist-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(107, 141, 214, 0.2);
|
||||||
|
background-color: rgba(107, 141, 214, 0.05);
|
||||||
|
border-color: #5b7fc7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-wishlist-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #6b8dd6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist .completed-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist .completed-toggle:hover {
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist .completed-toggle-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-completed {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-card .card-image {
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-card.faded {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist .card-menu-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #000000;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist .card-menu-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #333333;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image {
|
||||||
|
aspect-ratio: 5 / 6;
|
||||||
|
background: #f0f0f0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image .placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ccc;
|
||||||
|
background: white;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
padding: 0.6rem 0 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-price {
|
||||||
|
padding: 0;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-condition-wrapper {
|
||||||
|
padding: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-condition-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-condition {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-condition .lock-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-conditions {
|
||||||
|
padding-left: calc(12px + 0.25rem);
|
||||||
|
margin-top: -0.15rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-edit,
|
||||||
|
.wishlist-modal-copy,
|
||||||
|
.wishlist-modal-complete,
|
||||||
|
.wishlist-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-edit {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-edit:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-copy {
|
||||||
|
background-color: #9b59b6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-copy:hover {
|
||||||
|
background-color: #8e44ad;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-complete {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-complete:hover {
|
||||||
|
background-color: #229954;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-delete {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-modal-delete:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
698
play-life-web/src/components/Wishlist.jsx
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import BoardSelector from './BoardSelector'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
|
import './Wishlist.css'
|
||||||
|
|
||||||
|
const API_URL = '/api/wishlist'
|
||||||
|
const BOARDS_CACHE_KEY = 'wishlist_boards_cache'
|
||||||
|
const ITEMS_CACHE_KEY = 'wishlist_items_cache'
|
||||||
|
const SELECTED_BOARD_KEY = 'wishlist_selected_board_id'
|
||||||
|
|
||||||
|
function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [boards, setBoards] = useState([])
|
||||||
|
|
||||||
|
// Восстанавливаем выбранную доску из localStorage или используем initialBoardId
|
||||||
|
const getInitialBoardId = () => {
|
||||||
|
if (initialBoardId) return initialBoardId
|
||||||
|
return getSavedBoardId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получает сохранённую доску из localStorage
|
||||||
|
const getSavedBoardId = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(SELECTED_BOARD_KEY)
|
||||||
|
if (saved) {
|
||||||
|
const boardId = parseInt(saved, 10)
|
||||||
|
if (!isNaN(boardId)) return boardId
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading selected board from cache:', err)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId)
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [completed, setCompleted] = useState([])
|
||||||
|
const [completedCount, setCompletedCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [boardsLoading, setBoardsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [completedExpanded, setCompletedExpanded] = useState(false)
|
||||||
|
const [completedLoading, setCompletedLoading] = useState(false)
|
||||||
|
const [selectedItem, setSelectedItem] = useState(null)
|
||||||
|
const fetchingRef = useRef(false)
|
||||||
|
const fetchingCompletedRef = useRef(false)
|
||||||
|
const initialFetchDoneRef = useRef(false)
|
||||||
|
const prevIsActiveRef = useRef(isActive)
|
||||||
|
|
||||||
|
// Обёртка для setSelectedBoardId с сохранением в localStorage
|
||||||
|
const setSelectedBoardId = (boardId) => {
|
||||||
|
setSelectedBoardIdState(boardId)
|
||||||
|
try {
|
||||||
|
if (boardId) {
|
||||||
|
localStorage.setItem(SELECTED_BOARD_KEY, String(boardId))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(SELECTED_BOARD_KEY)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving selected board to cache:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка досок из кэша
|
||||||
|
const loadBoardsFromCache = () => {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(BOARDS_CACHE_KEY)
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached)
|
||||||
|
setBoards(data.boards || [])
|
||||||
|
// Проверяем, что сохранённая доска существует в списке
|
||||||
|
if (selectedBoardId) {
|
||||||
|
const boardExists = data.boards?.some(b => b.id === selectedBoardId)
|
||||||
|
if (!boardExists && data.boards?.length > 0) {
|
||||||
|
setSelectedBoardId(data.boards[0].id)
|
||||||
|
}
|
||||||
|
} else if (data.boards?.length > 0) {
|
||||||
|
// Пытаемся восстановить из localStorage
|
||||||
|
const savedBoardId = getSavedBoardId()
|
||||||
|
if (savedBoardId && data.boards.some(b => b.id === savedBoardId)) {
|
||||||
|
setSelectedBoardId(savedBoardId)
|
||||||
|
} else {
|
||||||
|
setSelectedBoardId(data.boards[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading boards from cache:', err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение досок в кэш
|
||||||
|
const saveBoardsToCache = (boardsData) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({
|
||||||
|
boards: boardsData,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving boards to cache:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка желаний из кэша (по board_id)
|
||||||
|
const loadItemsFromCache = (boardId) => {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${boardId}`)
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached)
|
||||||
|
setItems(data.items || [])
|
||||||
|
setCompletedCount(data.completedCount || 0)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading items from cache:', err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение желаний в кэш
|
||||||
|
const saveItemsToCache = (boardId, itemsData, count) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify({
|
||||||
|
items: itemsData,
|
||||||
|
completedCount: count,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving items to cache:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка списка досок
|
||||||
|
const fetchBoards = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/boards`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setBoards(data || [])
|
||||||
|
saveBoardsToCache(data || [])
|
||||||
|
|
||||||
|
// Проверяем, что выбранная доска существует в списке
|
||||||
|
if (selectedBoardId) {
|
||||||
|
const boardExists = data?.some(b => b.id === selectedBoardId)
|
||||||
|
if (!boardExists && data?.length > 0) {
|
||||||
|
// Сохранённая доска не существует, выбираем первую
|
||||||
|
setSelectedBoardId(data[0].id)
|
||||||
|
}
|
||||||
|
} else if (data?.length > 0) {
|
||||||
|
// Пытаемся восстановить из localStorage
|
||||||
|
const savedBoardId = getSavedBoardId()
|
||||||
|
if (savedBoardId && data.some(b => b.id === savedBoardId)) {
|
||||||
|
setSelectedBoardId(savedBoardId)
|
||||||
|
} else {
|
||||||
|
setSelectedBoardId(data[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching boards:', err)
|
||||||
|
} finally {
|
||||||
|
setBoardsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка желаний выбранной доски
|
||||||
|
const fetchItems = async () => {
|
||||||
|
if (!selectedBoardId || fetchingRef.current) return
|
||||||
|
fetchingRef.current = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasDataInState = items.length > 0 || completedCount > 0
|
||||||
|
if (!hasDataInState) {
|
||||||
|
const cacheLoaded = loadItemsFromCache(selectedBoardId)
|
||||||
|
if (!cacheLoaded) {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/items`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке желаний')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
|
||||||
|
const count = data.completed_count || 0
|
||||||
|
|
||||||
|
setItems(allItems)
|
||||||
|
setCompletedCount(count)
|
||||||
|
saveItemsToCache(selectedBoardId, allItems, count)
|
||||||
|
setError('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
if (!loadItemsFromCache(selectedBoardId)) {
|
||||||
|
setItems([])
|
||||||
|
setCompletedCount(0)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
fetchingRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка завершённых для текущей доски
|
||||||
|
const fetchCompleted = async () => {
|
||||||
|
if (fetchingCompletedRef.current || !selectedBoardId) return
|
||||||
|
fetchingCompletedRef.current = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCompletedLoading(true)
|
||||||
|
// Используем новый API для получения завершённых на доске
|
||||||
|
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/completed`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке завершённых желаний')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const completedData = Array.isArray(data) ? data : []
|
||||||
|
setCompleted(completedData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching completed items:', err)
|
||||||
|
setCompleted([])
|
||||||
|
} finally {
|
||||||
|
setCompletedLoading(false)
|
||||||
|
fetchingCompletedRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первая инициализация
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialFetchDoneRef.current) {
|
||||||
|
initialFetchDoneRef.current = true
|
||||||
|
|
||||||
|
// Загружаем доски из кэша
|
||||||
|
const boardsCacheLoaded = loadBoardsFromCache()
|
||||||
|
if (boardsCacheLoaded) {
|
||||||
|
setBoardsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем доски с сервера
|
||||||
|
fetchBoards()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Загружаем желания при смене доски
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBoardId) {
|
||||||
|
// Сбрасываем состояние
|
||||||
|
setItems([])
|
||||||
|
setCompletedCount(0)
|
||||||
|
setCompleted([])
|
||||||
|
setCompletedExpanded(false)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Пробуем загрузить из кэша
|
||||||
|
const cacheLoaded = loadItemsFromCache(selectedBoardId)
|
||||||
|
if (cacheLoaded) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем свежие данные
|
||||||
|
fetchItems()
|
||||||
|
}
|
||||||
|
}, [selectedBoardId])
|
||||||
|
|
||||||
|
// Обновление при активации таба
|
||||||
|
useEffect(() => {
|
||||||
|
const wasActive = prevIsActiveRef.current
|
||||||
|
prevIsActiveRef.current = isActive
|
||||||
|
|
||||||
|
if (!initialFetchDoneRef.current) return
|
||||||
|
|
||||||
|
if (isActive && !wasActive) {
|
||||||
|
fetchBoards()
|
||||||
|
if (selectedBoardId) {
|
||||||
|
fetchItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isActive])
|
||||||
|
|
||||||
|
// Обновление при refreshTrigger
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger > 0 && selectedBoardId) {
|
||||||
|
// Очищаем кэш для текущей доски, чтобы загрузить свежие данные
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error clearing cache:', err)
|
||||||
|
}
|
||||||
|
fetchBoards()
|
||||||
|
fetchItems()
|
||||||
|
if (completedExpanded && completedCount > 0) {
|
||||||
|
fetchCompleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [refreshTrigger, selectedBoardId])
|
||||||
|
|
||||||
|
// Обновление при initialBoardId (когда создана новая доска или переход по ссылке)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialBoardId && initialBoardId !== selectedBoardId) {
|
||||||
|
// Сбрасываем флаг загрузки, чтобы не блокировать новую загрузку
|
||||||
|
fetchingRef.current = false
|
||||||
|
|
||||||
|
// Обновляем список досок (чтобы новая доска появилась)
|
||||||
|
fetchBoards().then(() => {
|
||||||
|
// Переключаемся на новую доску после обновления списка
|
||||||
|
// Это вызовет useEffect для selectedBoardId, который загрузит данные
|
||||||
|
setSelectedBoardId(initialBoardId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [initialBoardId])
|
||||||
|
|
||||||
|
// Обработка удаления доски - выбираем первую доступную
|
||||||
|
useEffect(() => {
|
||||||
|
if (boardDeleted && boards.length > 0) {
|
||||||
|
// Очищаем текущие данные
|
||||||
|
setItems([])
|
||||||
|
setCompletedCount(0)
|
||||||
|
setCompleted([])
|
||||||
|
setCompletedExpanded(false)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Обновляем список досок и выбираем первую
|
||||||
|
fetchBoards().then(() => {
|
||||||
|
// fetchBoards обновит boards, но мы уже в этом useEffect
|
||||||
|
// selectedBoardId обновится автоматически в useEffect ниже
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [boardDeleted])
|
||||||
|
|
||||||
|
// Если текущая доска больше не существует в списке - выбираем первую
|
||||||
|
useEffect(() => {
|
||||||
|
if (boards.length > 0 && selectedBoardId) {
|
||||||
|
const boardExists = boards.some(b => b.id === selectedBoardId)
|
||||||
|
if (!boardExists) {
|
||||||
|
setSelectedBoardId(boards[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [boards, selectedBoardId])
|
||||||
|
|
||||||
|
const handleBoardChange = (boardId) => {
|
||||||
|
setSelectedBoardId(boardId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBoardEdit = () => {
|
||||||
|
const board = boards.find(b => b.id === selectedBoardId)
|
||||||
|
if (board?.is_owner) {
|
||||||
|
onNavigate?.('board-form', { boardId: selectedBoardId })
|
||||||
|
} else {
|
||||||
|
// Показать подтверждение выхода
|
||||||
|
handleLeaveBoard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLeaveBoard = async () => {
|
||||||
|
if (!window.confirm('Отвязаться от этой доски? Вы больше не будете видеть её желания.')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/leave`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Убираем доску из списка
|
||||||
|
const newBoards = boards.filter(b => b.id !== selectedBoardId)
|
||||||
|
setBoards(newBoards)
|
||||||
|
saveBoardsToCache(newBoards)
|
||||||
|
|
||||||
|
// Выбираем первую доску
|
||||||
|
if (newBoards.length > 0) {
|
||||||
|
setSelectedBoardId(newBoards[0].id)
|
||||||
|
} else {
|
||||||
|
setSelectedBoardId(null)
|
||||||
|
setItems([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error leaving board:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddBoard = () => {
|
||||||
|
onNavigate?.('board-form', { boardId: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleCompleted = () => {
|
||||||
|
const newExpanded = !completedExpanded
|
||||||
|
setCompletedExpanded(newExpanded)
|
||||||
|
|
||||||
|
if (newExpanded && completedCount > 0) {
|
||||||
|
fetchCompleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddClick = () => {
|
||||||
|
onNavigate?.('wishlist-form', { wishlistId: undefined, boardId: selectedBoardId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleItemClick = (item) => {
|
||||||
|
onNavigate?.('wishlist-detail', { wishlistId: item.id, boardId: selectedBoardId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuClick = (item, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (selectedItem) {
|
||||||
|
onNavigate?.('wishlist-form', { wishlistId: selectedItem.id, boardId: selectedBoardId })
|
||||||
|
setSelectedItem(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedItem) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/${selectedItem.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при удалении')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedItem(null)
|
||||||
|
await fetchItems()
|
||||||
|
if (completedExpanded) {
|
||||||
|
await fetchCompleted()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setSelectedItem(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!selectedItem) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/${selectedItem.id}/copy`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при копировании')
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem = await response.json()
|
||||||
|
|
||||||
|
setSelectedItem(null)
|
||||||
|
onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setSelectedItem(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
const findFirstUnmetCondition = (item) => {
|
||||||
|
if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const condition of item.unlock_conditions) {
|
||||||
|
let isMet = false
|
||||||
|
|
||||||
|
if (condition.type === 'task_completion') {
|
||||||
|
isMet = condition.task_completed === true
|
||||||
|
} else if (condition.type === 'project_points') {
|
||||||
|
const currentPoints = condition.current_points || 0
|
||||||
|
const requiredPoints = condition.required_points || 0
|
||||||
|
isMet = currentPoints >= requiredPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMet) {
|
||||||
|
return condition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderUnlockCondition = (item) => {
|
||||||
|
if (item.completed) return null
|
||||||
|
|
||||||
|
const condition = findFirstUnmetCondition(item)
|
||||||
|
if (!condition) return null
|
||||||
|
|
||||||
|
let conditionText = ''
|
||||||
|
if (condition.type === 'task_completion') {
|
||||||
|
conditionText = condition.task_name || 'Задача'
|
||||||
|
} else {
|
||||||
|
const points = condition.required_points || 0
|
||||||
|
const project = condition.project_name || 'Проект'
|
||||||
|
let dateText = ''
|
||||||
|
if (condition.start_date) {
|
||||||
|
const date = new Date(condition.start_date + 'T00:00:00')
|
||||||
|
dateText = ` с ${date.toLocaleDateString('ru-RU')}`
|
||||||
|
} else {
|
||||||
|
dateText = ' за всё время'
|
||||||
|
}
|
||||||
|
conditionText = `${points} в ${project}${dateText}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="unlock-condition-wrapper">
|
||||||
|
<div className="unlock-condition-line">
|
||||||
|
<div className="unlock-condition">
|
||||||
|
<svg className="lock-icon" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="condition-text">{conditionText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = (item) => {
|
||||||
|
const isFaded = (!item.unlocked && !item.completed) || item.completed
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`wishlist-card ${isFaded ? 'faded' : ''}`}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="card-menu-button"
|
||||||
|
onClick={(e) => handleMenuClick(item, e)}
|
||||||
|
title="Меню"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="card-image">
|
||||||
|
{item.image_url ? (
|
||||||
|
<img src={item.image_url} alt={item.name} />
|
||||||
|
) : (
|
||||||
|
<div className="placeholder">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-name">{item.name}</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const unmetCondition = findFirstUnmetCondition(item)
|
||||||
|
if (unmetCondition && !item.completed) {
|
||||||
|
return renderUnlockCondition(item)
|
||||||
|
}
|
||||||
|
if (item.price) {
|
||||||
|
return <div className="card-price">{formatPrice(item.price)}</div>
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем loading только если и доски и желания грузятся
|
||||||
|
if (boardsLoading && loading) {
|
||||||
|
return (
|
||||||
|
<div className="wishlist">
|
||||||
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="wishlist">
|
||||||
|
<BoardSelector
|
||||||
|
boards={boards}
|
||||||
|
selectedBoardId={selectedBoardId}
|
||||||
|
onBoardChange={handleBoardChange}
|
||||||
|
onBoardEdit={handleBoardEdit}
|
||||||
|
onAddBoard={handleAddBoard}
|
||||||
|
loading={boardsLoading}
|
||||||
|
/>
|
||||||
|
<LoadingError onRetry={() => fetchItems()} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wishlist">
|
||||||
|
{/* Селектор доски */}
|
||||||
|
<BoardSelector
|
||||||
|
boards={boards}
|
||||||
|
selectedBoardId={selectedBoardId}
|
||||||
|
onBoardChange={handleBoardChange}
|
||||||
|
onBoardEdit={handleBoardEdit}
|
||||||
|
onAddBoard={handleAddBoard}
|
||||||
|
loading={boardsLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Основной список */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="wishlist-loading">
|
||||||
|
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="wishlist-grid">
|
||||||
|
{items.map(renderItem)}
|
||||||
|
<button
|
||||||
|
onClick={handleAddClick}
|
||||||
|
className="add-wishlist-button"
|
||||||
|
>
|
||||||
|
<div className="add-wishlist-icon">+</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Завершённые */}
|
||||||
|
{completedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-divider">
|
||||||
|
<button
|
||||||
|
className="completed-toggle"
|
||||||
|
onClick={handleToggleCompleted}
|
||||||
|
>
|
||||||
|
<span className="completed-toggle-icon">
|
||||||
|
{completedExpanded ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
<span>Завершённые</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{completedExpanded && (
|
||||||
|
<>
|
||||||
|
{completedLoading ? (
|
||||||
|
<div className="loading-completed">
|
||||||
|
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="wishlist-grid">
|
||||||
|
{completed.map(renderItem)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно для действий */}
|
||||||
|
{selectedItem && (
|
||||||
|
<div className="wishlist-modal-overlay" onClick={() => setSelectedItem(null)}>
|
||||||
|
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="wishlist-modal-header">
|
||||||
|
<h3>{selectedItem.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="wishlist-modal-actions">
|
||||||
|
<button className="wishlist-modal-edit" onClick={handleEdit}>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button className="wishlist-modal-copy" onClick={handleCopy}>
|
||||||
|
Копировать
|
||||||
|
</button>
|
||||||
|
<button className="wishlist-modal-delete" onClick={handleDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Wishlist
|
||||||
328
play-life-web/src/components/WishlistDetail.css
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
.wishlist-detail {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 5 / 6;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-price {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-link {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-link a {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-link a:hover {
|
||||||
|
color: #2980b9;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-conditions {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-section-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-condition {
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-condition.met {
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-condition.not-met {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-progress {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-left: calc(16px + 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #3498db;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-condition.met .progress-fill {
|
||||||
|
background-color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-remaining {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-edit-button,
|
||||||
|
.wishlist-detail-complete-button,
|
||||||
|
.wishlist-detail-uncomplete-button,
|
||||||
|
.wishlist-detail-delete-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-edit-button {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-edit-button:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-complete-button {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-complete-button:hover:not(:disabled) {
|
||||||
|
background-color: #229954;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-complete-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-create-task-button {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #27ae60;
|
||||||
|
border: 2px solid #27ae60;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-create-task-button:hover {
|
||||||
|
background-color: rgba(39, 174, 96, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-task-label-header {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-item {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-item-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-name-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-unlink-button {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-unlink-button:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-uncomplete-button {
|
||||||
|
background-color: #f39c12;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-uncomplete-button:hover:not(:disabled) {
|
||||||
|
background-color: #e67e22;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-uncomplete-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-delete-button {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-delete-button:hover:not(:disabled) {
|
||||||
|
background-color: #c0392b;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-delete-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
526
play-life-web/src/components/WishlistDetail.jsx
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import TaskDetail from './TaskDetail'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
|
import Toast from './Toast'
|
||||||
|
import './WishlistDetail.css'
|
||||||
|
import './TaskList.css'
|
||||||
|
|
||||||
|
const API_URL = '/api/wishlist'
|
||||||
|
|
||||||
|
function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
|
||||||
|
const { authFetch, user } = useAuth()
|
||||||
|
const [wishlistItem, setWishlistItem] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingWishlist, setLoadingWishlist] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
|
||||||
|
|
||||||
|
const fetchWishlistDetail = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoadingWishlist(true)
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const response = await authFetch(`${API_URL}/${wishlistId}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки желания')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setWishlistItem(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
console.error('Error fetching wishlist detail:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setLoadingWishlist(false)
|
||||||
|
}
|
||||||
|
}, [wishlistId, authFetch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wishlistId) {
|
||||||
|
fetchWishlistDetail()
|
||||||
|
} else {
|
||||||
|
setWishlistItem(null)
|
||||||
|
setLoading(true)
|
||||||
|
setLoadingWishlist(true)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [wishlistId, fetchWishlistDetail])
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
onNavigate?.('wishlist-form', { wishlistId: wishlistId, boardId: boardId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
if (!wishlistItem || !wishlistItem.unlocked) return
|
||||||
|
|
||||||
|
setIsCompleting(true)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/${wishlistId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при завершении')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate('wishlist')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error completing wishlist:', err)
|
||||||
|
setToastMessage({ text: err.message || 'Ошибка при завершении', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setIsCompleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUncomplete = async () => {
|
||||||
|
if (!wishlistItem || !wishlistItem.completed) return
|
||||||
|
|
||||||
|
setIsCompleting(true)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/${wishlistId}/uncomplete`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при отмене завершения')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
fetchWishlistDetail()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error uncompleting wishlist:', err)
|
||||||
|
setToastMessage({ text: err.message || 'Ошибка при отмене завершения', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setIsCompleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!wishlistItem) return
|
||||||
|
|
||||||
|
if (!window.confirm('Вы уверены, что хотите удалить это желание?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/${wishlistId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка при удалении')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate('wishlist')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting wishlist:', err)
|
||||||
|
setToastMessage({ text: err.message || 'Ошибка при удалении', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateTask = () => {
|
||||||
|
if (!wishlistItem || !wishlistItem.unlocked || wishlistItem.completed) return
|
||||||
|
onNavigate?.('task-form', { wishlistId: wishlistId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskCheckmarkClick = (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (wishlistItem?.linked_task) {
|
||||||
|
setSelectedTaskForDetail(wishlistItem.linked_task.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskItemClick = () => {
|
||||||
|
if (wishlistItem?.linked_task) {
|
||||||
|
onNavigate?.('task-form', { taskId: wishlistItem.linked_task.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseDetail = () => {
|
||||||
|
setSelectedTaskForDetail(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskCompleted = () => {
|
||||||
|
setToastMessage({ text: 'Задача выполнена', type: 'success' })
|
||||||
|
// После выполнения задачи желание тоже завершается, перенаправляем на список
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate('wishlist')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnlinkTask = async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!wishlistItem?.linked_task) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Загружаем текущую задачу
|
||||||
|
const taskResponse = await authFetch(`/api/tasks/${wishlistItem.linked_task.id}`)
|
||||||
|
if (!taskResponse.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке задачи')
|
||||||
|
}
|
||||||
|
const taskData = await taskResponse.json()
|
||||||
|
const task = taskData.task
|
||||||
|
|
||||||
|
// Формируем payload для обновления задачи
|
||||||
|
const payload = {
|
||||||
|
name: task.name,
|
||||||
|
reward_message: task.reward_message || null,
|
||||||
|
progression_base: task.progression_base || null,
|
||||||
|
repetition_period: task.repetition_period || null,
|
||||||
|
repetition_date: task.repetition_date || null,
|
||||||
|
wishlist_id: null, // Отвязываем от желания
|
||||||
|
rewards: (task.rewards || []).map(r => ({
|
||||||
|
position: r.position,
|
||||||
|
project_name: r.project_name,
|
||||||
|
value: r.value,
|
||||||
|
use_progression: r.use_progression || false
|
||||||
|
})),
|
||||||
|
subtasks: (task.subtasks || []).map(st => ({
|
||||||
|
id: st.id,
|
||||||
|
name: st.name || null,
|
||||||
|
reward_message: st.reward_message || null,
|
||||||
|
rewards: (st.rewards || []).map(r => ({
|
||||||
|
position: r.position,
|
||||||
|
project_name: r.project_name,
|
||||||
|
value: r.value,
|
||||||
|
use_progression: r.use_progression || false
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем задачу, отвязывая от желания
|
||||||
|
const updateResponse = await authFetch(`/api/tasks/${wishlistItem.linked_task.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
const errorData = await updateResponse.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || errorData.error || 'Ошибка при отвязке задачи')
|
||||||
|
}
|
||||||
|
|
||||||
|
setToastMessage({ text: 'Задача отвязана от желания', type: 'success' })
|
||||||
|
// Обновляем данные желания
|
||||||
|
fetchWishlistDetail()
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error unlinking task:', err)
|
||||||
|
setToastMessage({ text: err.message || 'Ошибка при отвязке задачи', type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const formatPrice = (price) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderUnlockConditions = () => {
|
||||||
|
if (!wishlistItem || !wishlistItem.unlock_conditions || wishlistItem.unlock_conditions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wishlist-detail-conditions">
|
||||||
|
<h3 className="wishlist-detail-section-title">Цели:</h3>
|
||||||
|
{wishlistItem.unlock_conditions.map((condition, index) => {
|
||||||
|
let conditionText = ''
|
||||||
|
let progress = null
|
||||||
|
|
||||||
|
if (condition.type === 'task_completion') {
|
||||||
|
conditionText = condition.task_name || 'Задача'
|
||||||
|
const isCompleted = condition.task_completed === true
|
||||||
|
progress = {
|
||||||
|
type: 'task',
|
||||||
|
completed: isCompleted
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const requiredPoints = condition.required_points || 0
|
||||||
|
const currentPoints = condition.current_points || 0
|
||||||
|
const project = condition.project_name || 'Проект'
|
||||||
|
let dateText = ''
|
||||||
|
if (condition.start_date) {
|
||||||
|
const date = new Date(condition.start_date + 'T00:00:00')
|
||||||
|
dateText = ` с ${date.toLocaleDateString('ru-RU')}`
|
||||||
|
} else {
|
||||||
|
dateText = ' за всё время'
|
||||||
|
}
|
||||||
|
conditionText = `${requiredPoints} в ${project}${dateText}`
|
||||||
|
const remaining = Math.max(0, requiredPoints - currentPoints)
|
||||||
|
progress = {
|
||||||
|
type: 'points',
|
||||||
|
current: currentPoints,
|
||||||
|
required: requiredPoints,
|
||||||
|
remaining: remaining,
|
||||||
|
percentage: requiredPoints > 0 ? Math.min(100, (currentPoints / requiredPoints) * 100) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем каждое условие индивидуально
|
||||||
|
let isMet = false
|
||||||
|
if (progress?.type === 'task') {
|
||||||
|
isMet = progress.completed === true
|
||||||
|
} else if (progress?.type === 'points') {
|
||||||
|
isMet = progress.current >= progress.required
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`wishlist-detail-condition ${isMet ? 'met' : 'not-met'}`}
|
||||||
|
>
|
||||||
|
<div className="condition-header">
|
||||||
|
<svg className="condition-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
{isMet ? (
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||||
|
) : (
|
||||||
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<span className="condition-text">{conditionText}</span>
|
||||||
|
</div>
|
||||||
|
{progress && progress.type === 'points' && !isMet && (
|
||||||
|
<div className="condition-progress">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{ width: `${progress.percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="progress-text">
|
||||||
|
<span>{Math.round(progress.current)} / {Math.round(progress.required)}</span>
|
||||||
|
{progress.remaining > 0 && (
|
||||||
|
<span className="progress-remaining">Осталось: {Math.round(progress.remaining)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingWishlist) {
|
||||||
|
return (
|
||||||
|
<div className="wishlist-detail">
|
||||||
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wishlist-detail">
|
||||||
|
<button className="close-x-button" onClick={() => onNavigate?.('wishlist')}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<h2>{wishlistItem ? wishlistItem.name : 'Желание'}</h2>
|
||||||
|
|
||||||
|
<div className="wishlist-detail-content">
|
||||||
|
{error && (
|
||||||
|
<LoadingError onRetry={fetchWishlistDetail} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && wishlistItem && (
|
||||||
|
<>
|
||||||
|
{/* Изображение */}
|
||||||
|
{wishlistItem.image_url && (
|
||||||
|
<div className="wishlist-detail-image">
|
||||||
|
<img src={wishlistItem.image_url} alt={wishlistItem.name} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Цена */}
|
||||||
|
{wishlistItem.price && (
|
||||||
|
<div className="wishlist-detail-price">
|
||||||
|
{formatPrice(wishlistItem.price)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ссылка */}
|
||||||
|
{wishlistItem.link && (() => {
|
||||||
|
try {
|
||||||
|
const url = new URL(wishlistItem.link)
|
||||||
|
const host = url.host.replace(/^www\./, '') // Убираем www. если есть
|
||||||
|
return (
|
||||||
|
<div className="wishlist-detail-link">
|
||||||
|
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
|
||||||
|
{host}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Если URL некорректный, показываем оригинальный текст
|
||||||
|
return (
|
||||||
|
<div className="wishlist-detail-link">
|
||||||
|
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
|
||||||
|
Открыть ссылку
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Условия разблокировки */}
|
||||||
|
{renderUnlockConditions()}
|
||||||
|
|
||||||
|
{/* Связанная задача или кнопки действий */}
|
||||||
|
{wishlistItem.unlocked && !wishlistItem.completed && (
|
||||||
|
<>
|
||||||
|
{wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? (
|
||||||
|
<div className="wishlist-detail-linked-task">
|
||||||
|
<div className="linked-task-label-header">Связанная задача:</div>
|
||||||
|
<div
|
||||||
|
className="task-item"
|
||||||
|
onClick={handleTaskItemClick}
|
||||||
|
>
|
||||||
|
<div className="task-item-content">
|
||||||
|
<div
|
||||||
|
className="task-checkmark"
|
||||||
|
onClick={handleTaskCheckmarkClick}
|
||||||
|
title="Выполнить задачу"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
|
||||||
|
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="task-name-container">
|
||||||
|
<div className="task-name-wrapper">
|
||||||
|
<div className="task-name">
|
||||||
|
{wishlistItem.linked_task.name}
|
||||||
|
</div>
|
||||||
|
{/* Показываем дату только для выполненных задач (next_show_at > сегодня) */}
|
||||||
|
{wishlistItem.linked_task.next_show_at && (() => {
|
||||||
|
const showDate = new Date(wishlistItem.linked_task.next_show_at)
|
||||||
|
// Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени
|
||||||
|
const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate())
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||||
|
|
||||||
|
// Показываем только если дата > сегодня
|
||||||
|
if (showDateNormalized.getTime() <= todayNormalized.getTime()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tomorrowNormalized = new Date(todayNormalized)
|
||||||
|
tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1)
|
||||||
|
|
||||||
|
let dateText
|
||||||
|
if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) {
|
||||||
|
dateText = 'Завтра'
|
||||||
|
} else {
|
||||||
|
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-next-show-date">
|
||||||
|
{dateText}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-actions">
|
||||||
|
<button
|
||||||
|
className="task-unlink-button"
|
||||||
|
onClick={handleUnlinkTask}
|
||||||
|
title="Отвязать от желания"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="wishlist-detail-actions">
|
||||||
|
<button
|
||||||
|
onClick={handleComplete}
|
||||||
|
disabled={isCompleting}
|
||||||
|
className="wishlist-detail-complete-button"
|
||||||
|
>
|
||||||
|
{isCompleting ? 'Завершение...' : 'Завершить'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateTask}
|
||||||
|
className="wishlist-detail-create-task-button"
|
||||||
|
title="Создать задачу"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 11l3 3L22 4"></path>
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage.text}
|
||||||
|
type={toastMessage.type}
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно для деталей задачи */}
|
||||||
|
{selectedTaskForDetail && (
|
||||||
|
<TaskDetail
|
||||||
|
taskId={selectedTaskForDetail}
|
||||||
|
onClose={handleCloseDetail}
|
||||||
|
onRefresh={() => {
|
||||||
|
fetchWishlistDetail()
|
||||||
|
if (onRefresh) onRefresh()
|
||||||
|
}}
|
||||||
|
onTaskCompleted={handleTaskCompleted}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WishlistDetail
|
||||||
|
|
||||||
588
play-life-web/src/components/WishlistForm.css
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
.wishlist-form {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-form h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-form form {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
aspect-ratio: 5 / 6;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-image-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: rgba(231, 76, 60, 0.9);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-image-button:hover {
|
||||||
|
background: rgba(192, 57, 43, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
height: 450px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-controls {
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-controls label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-controls input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-actions button:first-child {
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-actions button:first-child:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-actions button:last-child {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-actions button:last-child:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions-list {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-item-text {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-item-text:hover {
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-condition-button {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-condition-button:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-condition-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px dashed #9ca3af;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #374151;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-condition-button:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-form-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-form {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-form h3 {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover:not(:disabled) {
|
||||||
|
background: #2980b9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #e74c3c;
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link input with pull button */
|
||||||
|
.link-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-input-wrapper .form-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-metadata-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-metadata-button:hover:not(:disabled) {
|
||||||
|
background: #2980b9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-metadata-button:disabled {
|
||||||
|
background: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-metadata-button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date Selector Styles (аналогично task-postpone-input-group) */
|
||||||
|
.date-selector-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-display-date {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: #1f2937;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-display-date:hover {
|
||||||
|
border-color: #3498db;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-display-date:active {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-clear-button {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #6b7280;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-clear-button:hover {
|
||||||
|
background: #d1d5db;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-selector-clear-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Autocomplete Styles */
|
||||||
|
.task-autocomplete {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 36px 12px 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-clear:hover {
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка создания */
|
||||||
|
.create-task-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-task-button:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown список */
|
||||||
|
.task-autocomplete-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 52px; /* Учитываем ширину кнопки + gap */
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-item {
|
||||||
|
padding: 12px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-item:hover,
|
||||||
|
.task-autocomplete-item.highlighted {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-item.selected {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4f46e5;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-autocomplete-item.selected.highlighted {
|
||||||
|
background: #e0e7ff;
|
||||||
|
}
|
||||||
|
|
||||||
1231
play-life-web/src/components/WishlistForm.jsx
Normal file
@@ -1,9 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import LoadingError from './LoadingError'
|
||||||
import './WordList.css'
|
import './WordList.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
|
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [words, setWords] = useState([])
|
const [words, setWords] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -44,7 +47,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
|
|
||||||
const fetchDictionary = async (dictId) => {
|
const fetchDictionary = async (dictId) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/dictionaries`)
|
const response = await authFetch(`${API_URL}/dictionaries`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке словарей')
|
throw new Error('Ошибка при загрузке словарей')
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const url = `${API_URL}/words?dictionary_id=${dictId}`
|
const url = `${API_URL}/words?dictionary_id=${dictId}`
|
||||||
const response = await fetch(url)
|
const response = await authFetch(url)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке слов')
|
throw new Error('Ошибка при загрузке слов')
|
||||||
}
|
}
|
||||||
@@ -102,7 +105,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
try {
|
try {
|
||||||
if (!hasValidDictionary(currentDictionaryId)) {
|
if (!hasValidDictionary(currentDictionaryId)) {
|
||||||
// Create new dictionary
|
// Create new dictionary
|
||||||
const response = await fetch(`${API_URL}/dictionaries`, {
|
const response = await authFetch(`${API_URL}/dictionaries`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -131,7 +134,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
onNavigate?.('words', { dictionaryId: newDictionaryId })
|
onNavigate?.('words', { dictionaryId: newDictionaryId })
|
||||||
} else if (hasValidDictionary(currentDictionaryId)) {
|
} else if (hasValidDictionary(currentDictionaryId)) {
|
||||||
// Update existing dictionary (rename)
|
// Update existing dictionary (rename)
|
||||||
const response = await fetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -161,7 +164,12 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="word-list">
|
<div className="word-list">
|
||||||
<div className="loading">Загрузка...</div>
|
<div className="fixed inset-0 flex justify-center items-center">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -169,7 +177,11 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="word-list">
|
<div className="word-list">
|
||||||
<div className="error-message">{error}</div>
|
<LoadingError onRetry={() => {
|
||||||
|
if (hasValidDictionary(currentDictionaryId)) {
|
||||||
|
fetchWordsForDictionary(currentDictionaryId)
|
||||||
|
}
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -177,7 +189,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
return (
|
return (
|
||||||
<div className="word-list">
|
<div className="word-list">
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate?.('test-config')}
|
onClick={() => onNavigate?.('dictionaries')}
|
||||||
className="close-x-button"
|
className="close-x-button"
|
||||||
title="Закрыть"
|
title="Закрыть"
|
||||||
>
|
>
|
||||||
|
|||||||
353
play-life-web/src/components/auth/AuthContext.jsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'access_token'
|
||||||
|
const REFRESH_TOKEN_KEY = 'refresh_token'
|
||||||
|
const USER_KEY = 'user'
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
// Ref для синхронизации параллельных refresh-запросов
|
||||||
|
const refreshPromiseRef = useRef(null)
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
setUser(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Внутренняя функция для выполнения refresh
|
||||||
|
const doRefreshToken = useCallback(async () => {
|
||||||
|
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||||
|
|
||||||
|
if (!refresh) {
|
||||||
|
console.warn('[Auth] No refresh token in localStorage')
|
||||||
|
return { success: false, isNetworkError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Auth] Attempting refresh with token:', refresh.substring(0, 10) + '...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout (increased)
|
||||||
|
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refresh_token: refresh }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Логируем тело ответа для диагностики
|
||||||
|
let errorBody = ''
|
||||||
|
try {
|
||||||
|
errorBody = await response.text()
|
||||||
|
} catch (e) {
|
||||||
|
errorBody = 'Could not read error body'
|
||||||
|
}
|
||||||
|
console.error('[Auth] Refresh failed:', response.status, errorBody)
|
||||||
|
|
||||||
|
// 401 means invalid token (real auth error)
|
||||||
|
// Other errors might be temporary (503, 502, etc.)
|
||||||
|
const isAuthError = response.status === 401
|
||||||
|
return { success: false, isNetworkError: !isAuthError }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Проверяем что токены действительно пришли
|
||||||
|
if (!data.access_token || !data.refresh_token) {
|
||||||
|
console.error('[Auth] Refresh response missing tokens:', Object.keys(data))
|
||||||
|
return { success: false, isNetworkError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Auth] Refresh successful, saving new tokens')
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
||||||
|
setUser(data.user)
|
||||||
|
|
||||||
|
return { success: true, isNetworkError: false }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Refresh error:', err.name, err.message)
|
||||||
|
// Network errors should be treated as temporary
|
||||||
|
if (err.name === 'AbortError' ||
|
||||||
|
(err.name === 'TypeError' && (err.message.includes('fetch') || err.message.includes('Failed to fetch')))) {
|
||||||
|
console.warn('[Auth] Refresh token network error, keeping session')
|
||||||
|
return { success: false, isNetworkError: true }
|
||||||
|
}
|
||||||
|
// Other errors might be auth related
|
||||||
|
return { success: false, isNetworkError: false }
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Синхронизированная функция refresh - предотвращает race condition
|
||||||
|
// Если refresh уже выполняется, все вызовы ждут его завершения
|
||||||
|
const refreshToken = useCallback(async () => {
|
||||||
|
// Если refresh уже выполняется, ждём его завершения
|
||||||
|
if (refreshPromiseRef.current) {
|
||||||
|
console.log('[Auth] Refresh already in progress, waiting...')
|
||||||
|
return refreshPromiseRef.current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём promise для refresh и сохраняем его
|
||||||
|
console.log('[Auth] Starting token refresh...')
|
||||||
|
refreshPromiseRef.current = doRefreshToken().finally(() => {
|
||||||
|
// Очищаем ref после завершения (успешного или нет)
|
||||||
|
refreshPromiseRef.current = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return refreshPromiseRef.current
|
||||||
|
}, [doRefreshToken])
|
||||||
|
|
||||||
|
// Initialize from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
const savedUser = localStorage.getItem(USER_KEY)
|
||||||
|
|
||||||
|
console.log('[Auth] Initializing auth, token exists:', !!token, 'user exists:', !!savedUser)
|
||||||
|
|
||||||
|
if (token && savedUser) {
|
||||||
|
try {
|
||||||
|
const parsedUser = JSON.parse(savedUser)
|
||||||
|
setUser(parsedUser) // Set user immediately from localStorage
|
||||||
|
console.log('[Auth] User restored from localStorage:', parsedUser.email)
|
||||||
|
|
||||||
|
// Verify token is still valid with timeout
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout
|
||||||
|
|
||||||
|
const response = await fetch('/api/auth/me', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setUser(data.user)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
||||||
|
console.log('[Auth] Token verified successfully')
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
// Try to refresh token
|
||||||
|
console.log('[Auth] Access token expired, attempting refresh...')
|
||||||
|
const result = await refreshToken()
|
||||||
|
if (!result.success && !result.isNetworkError) {
|
||||||
|
// Only logout on real auth errors, not network errors
|
||||||
|
console.warn('[Auth] Refresh failed with auth error, logging out')
|
||||||
|
logout()
|
||||||
|
} else if (!result.success) {
|
||||||
|
// Network error - keep session, backend might be starting up
|
||||||
|
console.warn('[Auth] Token refresh failed due to network error, keeping session. User remains logged in.')
|
||||||
|
// User is already set from localStorage above, so they stay logged in
|
||||||
|
} else {
|
||||||
|
console.log('[Auth] Token refreshed successfully')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other errors (like 503, 502, network errors), don't clear auth
|
||||||
|
// Just log the error and keep the user logged in
|
||||||
|
console.warn('[Auth] Auth check failed with status:', response.status, 'but keeping session. User remains logged in.')
|
||||||
|
// User is already set from localStorage above, so they stay logged in
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Network errors (e.g., backend not ready) should not clear auth
|
||||||
|
// Only clear if it's a real auth error
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
// Timeout - backend might be starting up, keep auth state
|
||||||
|
console.warn('[Auth] Auth check timeout, backend might be starting up. Keeping session. User remains logged in.')
|
||||||
|
// User is already set from localStorage above, so they stay logged in
|
||||||
|
} else if (err.name === 'TypeError' && (err.message.includes('fetch') || err.message.includes('Failed to fetch'))) {
|
||||||
|
// Network error - backend might be starting up, keep auth state
|
||||||
|
console.warn('[Auth] Network error during auth check, keeping session:', err.message, 'User remains logged in.')
|
||||||
|
// User is already set from localStorage above, so they stay logged in
|
||||||
|
} else {
|
||||||
|
// Other errors - might be auth related
|
||||||
|
console.error('[Auth] Auth init error:', err)
|
||||||
|
// Don't automatically logout on unknown errors
|
||||||
|
// User is already set from localStorage above, so they stay logged in
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Auth] No saved auth data found')
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
initAuth()
|
||||||
|
}, [refreshToken, logout])
|
||||||
|
|
||||||
|
const login = useCallback(async (email, password) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Ошибка входа')
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
||||||
|
setUser(data.user)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const register = useCallback(async (email, password, name) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password, name: name || undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Ошибка регистрации')
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token)
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
|
||||||
|
setUser(data.user)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getToken = useCallback(() => {
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Fetch wrapper that handles auth
|
||||||
|
const authFetch = useCallback(async (url, options = {}) => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY)
|
||||||
|
|
||||||
|
// Не устанавливаем Content-Type для FormData - браузер сделает это автоматически
|
||||||
|
const isFormData = options.body instanceof FormData
|
||||||
|
const headers = {}
|
||||||
|
|
||||||
|
if (!isFormData && !options.headers?.['Content-Type']) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем пользовательские заголовки
|
||||||
|
if (options.headers) {
|
||||||
|
Object.assign(headers, options.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = await fetch(url, { ...options, headers })
|
||||||
|
|
||||||
|
// If 401, try to refresh token and retry
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.log('[Auth] Got 401 for', url, '- attempting token refresh')
|
||||||
|
const result = await refreshToken()
|
||||||
|
if (result.success) {
|
||||||
|
console.log('[Auth] Token refreshed, retrying request to', url)
|
||||||
|
const newToken = localStorage.getItem(TOKEN_KEY)
|
||||||
|
headers['Authorization'] = `Bearer ${newToken}`
|
||||||
|
response = await fetch(url, { ...options, headers })
|
||||||
|
console.log('[Auth] Retry response status:', response.status)
|
||||||
|
} else if (!result.isNetworkError) {
|
||||||
|
// Only logout if refresh failed due to auth error (not network error)
|
||||||
|
console.warn('[Auth] Refresh failed with auth error, logging out')
|
||||||
|
logout()
|
||||||
|
} else {
|
||||||
|
console.warn('[Auth] Refresh failed with network error, keeping session but request failed')
|
||||||
|
}
|
||||||
|
// If network error, don't logout - let the caller handle the 401
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (err) {
|
||||||
|
// Network errors should not trigger logout
|
||||||
|
// Let the caller handle the error
|
||||||
|
console.error('[Auth] Fetch error for', url, ':', err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [refreshToken, logout])
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
getToken,
|
||||||
|
authFetch,
|
||||||
|
isAuthenticated: !!user
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthContext
|
||||||
|
|
||||||
16
play-life-web/src/components/auth/AuthScreen.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import LoginForm from './LoginForm'
|
||||||
|
import RegisterForm from './RegisterForm'
|
||||||
|
|
||||||
|
function AuthScreen() {
|
||||||
|
const [mode, setMode] = useState('login') // 'login' or 'register'
|
||||||
|
|
||||||
|
if (mode === 'register') {
|
||||||
|
return <RegisterForm onSwitchToLogin={() => setMode('login')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LoginForm onSwitchToRegister={() => setMode('register')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthScreen
|
||||||
|
|
||||||
112
play-life-web/src/components/auth/LoginForm.jsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
|
function LoginForm({ onSwitchToRegister }) {
|
||||||
|
const { login, error } = useAuth()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [localError, setLocalError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLocalError('')
|
||||||
|
|
||||||
|
if (!email.trim()) {
|
||||||
|
setLocalError('Введите email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
setLocalError('Введите пароль')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
const success = await login(email, password)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setLocalError(error || 'Ошибка входа')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Play Life</h1>
|
||||||
|
<p className="text-gray-300">Войдите в свой аккаунт</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(localError || error) && (
|
||||||
|
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200 text-sm">
|
||||||
|
{localError || error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-semibold rounded-xl shadow-lg transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Вход...
|
||||||
|
</span>
|
||||||
|
) : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Нет аккаунта?{' '}
|
||||||
|
<button
|
||||||
|
onClick={onSwitchToRegister}
|
||||||
|
className="text-purple-400 hover:text-purple-300 font-medium transition"
|
||||||
|
>
|
||||||
|
Зарегистрироваться
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginForm
|
||||||
|
|
||||||
150
play-life-web/src/components/auth/RegisterForm.jsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
|
function RegisterForm({ onSwitchToLogin }) {
|
||||||
|
const { register, error } = useAuth()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [localError, setLocalError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLocalError('')
|
||||||
|
|
||||||
|
if (!email.trim()) {
|
||||||
|
setLocalError('Введите email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
setLocalError('Введите пароль')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setLocalError('Пароль должен быть не менее 6 символов')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setLocalError('Пароли не совпадают')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
const success = await register(email, password, name || undefined)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setLocalError(error || 'Ошибка регистрации')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white/10 backdrop-blur-lg rounded-2xl shadow-2xl p-8 border border-white/20">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Play Life</h1>
|
||||||
|
<p className="text-gray-300">Создайте аккаунт</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Имя <span className="text-gray-500">(необязательно)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="Ваше имя"
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="Минимум 6 символов"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-200 mb-2">
|
||||||
|
Подтвердите пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition"
|
||||||
|
placeholder="Повторите пароль"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(localError || error) && (
|
||||||
|
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200 text-sm">
|
||||||
|
{localError || error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-semibold rounded-xl shadow-lg transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Регистрация...
|
||||||
|
</span>
|
||||||
|
) : 'Зарегистрироваться'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Уже есть аккаунт?{' '}
|
||||||
|
<button
|
||||||
|
onClick={onSwitchToLogin}
|
||||||
|
className="text-purple-400 hover:text-purple-300 font-medium transition"
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterForm
|
||||||
|
|
||||||
@@ -6,13 +6,15 @@ html {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
min-height: 100dvh;
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
@@ -22,8 +24,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
min-height: 100dvh; /* Dynamic viewport height для мобильных устройств */
|
height: 100dvh; /* Dynamic viewport height для мобильных устройств */
|
||||||
|
overflow: hidden;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
@@ -12,7 +13,100 @@ export default defineConfig(({ mode }) => {
|
|||||||
const env = { ...rootEnv, ...localEnv, ...process.env }
|
const env = { ...rootEnv, ...localEnv, ...process.env }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'favicon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'PlayLife - Статистика и задачи',
|
||||||
|
short_name: 'PlayLife',
|
||||||
|
description: 'Трекер продуктивности и изучения слов',
|
||||||
|
theme_color: '#4f46e5',
|
||||||
|
background_color: '#f3f4f6',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-maskable-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-maskable-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
// Кэширование статики
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
||||||
|
|
||||||
|
// Стратегии для API
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// Кэширование данных текущей недели
|
||||||
|
urlPattern: /\/playlife-feed$/,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-current-week',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 1,
|
||||||
|
maxAgeSeconds: 60 * 60 // 1 час
|
||||||
|
},
|
||||||
|
networkTimeoutSeconds: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Кэширование полной статистики
|
||||||
|
urlPattern: /\/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b$/,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-full-statistics',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 1,
|
||||||
|
maxAgeSeconds: 60 * 60 // 1 час
|
||||||
|
},
|
||||||
|
networkTimeoutSeconds: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Кэширование списка задач
|
||||||
|
urlPattern: /\/api\/tasks$/,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-tasks',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 1,
|
||||||
|
maxAgeSeconds: 60 * 60 // 1 час
|
||||||
|
},
|
||||||
|
networkTimeoutSeconds: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Остальные API запросы - только сеть (не кэшировать)
|
||||||
|
urlPattern: /\/api\/.*/,
|
||||||
|
handler: 'NetworkOnly'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: parseInt(env.VITE_PORT || '3000', 10),
|
port: parseInt(env.VITE_PORT || '3000', 10),
|
||||||
|
|||||||
@@ -47,14 +47,32 @@ DB_USER=${DB_USER:-playeng}
|
|||||||
DB_PASSWORD=${DB_PASSWORD:-playeng}
|
DB_PASSWORD=${DB_PASSWORD:-playeng}
|
||||||
DB_NAME=${DB_NAME:-playeng}
|
DB_NAME=${DB_NAME:-playeng}
|
||||||
|
|
||||||
# Проверяем наличие дампа
|
# Если используется .env (по умолчанию), всегда восстанавливаем в локальную базу
|
||||||
|
# Переопределяем DB_HOST для локального подключения
|
||||||
|
if [ "$ENV_FILE" = "$DEFAULT_ENV_FILE" ] || [ "$ENV_FILE" = ".env" ]; then
|
||||||
|
DB_HOST=localhost
|
||||||
|
echo "📋 Восстановление в локальную базу (DB_HOST=localhost, DB_PORT=$DB_PORT)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Если дамп не указан, выбираем самый свежий
|
||||||
if [ -z "$DUMP_FILE" ]; then
|
if [ -z "$DUMP_FILE" ]; then
|
||||||
echo "❌ Ошибка: Укажите имя дампа"
|
DUMP_DIR="database-dumps"
|
||||||
echo "Использование: ./restore-db.sh [--env-file FILE] [имя_дампа.sql.gz]"
|
if [ ! -d "$DUMP_DIR" ]; then
|
||||||
|
echo "❌ Ошибка: Директория дампов не найдена: $DUMP_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ищем самый свежий дамп
|
||||||
|
LATEST_DUMP=$(ls -t "$DUMP_DIR"/*.{sql.gz,sql} 2>/dev/null | head -n 1)
|
||||||
|
|
||||||
|
if [ -z "$LATEST_DUMP" ]; then
|
||||||
|
echo "❌ Ошибка: Дампы не найдены в директории $DUMP_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DUMP_FILE=$(basename "$LATEST_DUMP")
|
||||||
|
echo "📦 Автоматически выбран самый свежий дамп: $DUMP_FILE"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Доступные дампы:"
|
|
||||||
ls -lh database-dumps/*.sql.gz 2>/dev/null | awk '{print " " $9}' | sed "s|database-dumps/||g" || echo " (нет дампов)"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Определяем полный путь к файлу
|
# Определяем полный путь к файлу
|
||||||
@@ -86,7 +104,11 @@ if [ ! -f "$FULL_DUMP_PATH" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "⚠️ ВНИМАНИЕ: Это действие удалит все данные в базе $DB_NAME!"
|
echo "⚠️ ВНИМАНИЕ: Это действие удалит все данные в базе $DB_NAME!"
|
||||||
echo " Хост: $DB_HOST:$DB_PORT"
|
if [ "$ENV_FILE" = "$DEFAULT_ENV_FILE" ] || [ "$ENV_FILE" = ".env" ]; then
|
||||||
|
echo " Восстановление в локальную базу: $DB_HOST:$DB_PORT"
|
||||||
|
else
|
||||||
|
echo " Хост: $DB_HOST:$DB_PORT"
|
||||||
|
fi
|
||||||
echo " Пользователь: $DB_USER"
|
echo " Пользователь: $DB_USER"
|
||||||
read -p " Продолжить? (yes/no): " confirm
|
read -p " Продолжить? (yes/no): " confirm
|
||||||
|
|
||||||
@@ -97,21 +119,37 @@ fi
|
|||||||
|
|
||||||
echo "🔄 Восстановление базы данных из дампа..."
|
echo "🔄 Восстановление базы данных из дампа..."
|
||||||
echo " База: $DB_NAME"
|
echo " База: $DB_NAME"
|
||||||
echo " Хост: $DB_HOST:$DB_PORT"
|
if [ "$ENV_FILE" = "$DEFAULT_ENV_FILE" ] || [ "$ENV_FILE" = ".env" ]; then
|
||||||
|
echo " Восстановление в локальную базу: $DB_HOST:$DB_PORT"
|
||||||
|
else
|
||||||
|
echo " Хост: $DB_HOST:$DB_PORT"
|
||||||
|
fi
|
||||||
echo " Файл: $FULL_DUMP_PATH"
|
echo " Файл: $FULL_DUMP_PATH"
|
||||||
|
|
||||||
# Распаковываем, если сжат
|
# Распаковываем и модифицируем дамп
|
||||||
TEMP_DUMP="/tmp/restore_$$.sql"
|
TEMP_DUMP="/tmp/restore_$$.sql"
|
||||||
if [[ "$FULL_DUMP_PATH" == *.gz ]]; then
|
if [[ "$FULL_DUMP_PATH" == *.gz ]]; then
|
||||||
echo " Распаковка дампа..."
|
echo " Распаковка и модификация дампа..."
|
||||||
gunzip -c "$FULL_DUMP_PATH" > "$TEMP_DUMP"
|
gunzip -c "$FULL_DUMP_PATH" | \
|
||||||
|
sed 's/n8n_user/'"$DB_USER"'/g' | \
|
||||||
|
sed '/^\\restrict/d' | \
|
||||||
|
sed '/^\\unrestrict/d' > "$TEMP_DUMP"
|
||||||
else
|
else
|
||||||
cp "$FULL_DUMP_PATH" "$TEMP_DUMP"
|
echo " Модификация дампа..."
|
||||||
|
cat "$FULL_DUMP_PATH" | \
|
||||||
|
sed 's/n8n_user/'"$DB_USER"'/g' | \
|
||||||
|
sed '/^\\restrict/d' | \
|
||||||
|
sed '/^\\unrestrict/d' > "$TEMP_DUMP"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo " Владелец таблиц в дампе заменён на: $DB_USER"
|
||||||
|
|
||||||
# Восстанавливаем через docker-compose, если контейнер запущен
|
# Восстанавливаем через docker-compose, если контейнер запущен
|
||||||
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
||||||
echo " Используется docker-compose..."
|
echo " Используется docker-compose..."
|
||||||
|
# Завершаем все активные подключения к базе данных
|
||||||
|
echo " Завершение активных подключений к базе $DB_NAME..."
|
||||||
|
docker-compose exec -T db psql -U "$DB_USER" -d postgres -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '$DB_NAME' AND pid <> pg_backend_pid();" 2>/dev/null || true
|
||||||
# Очищаем базу и восстанавливаем
|
# Очищаем базу и восстанавливаем
|
||||||
docker-compose exec -T db psql -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
docker-compose exec -T db psql -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
||||||
docker-compose exec -T db psql -U "$DB_USER" -d postgres -c "CREATE DATABASE $DB_NAME;"
|
docker-compose exec -T db psql -U "$DB_USER" -d postgres -c "CREATE DATABASE $DB_NAME;"
|
||||||
@@ -119,6 +157,10 @@ if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
|||||||
elif command -v psql &> /dev/null; then
|
elif command -v psql &> /dev/null; then
|
||||||
# Или напрямую через psql
|
# Или напрямую через psql
|
||||||
echo " Используется локальный psql..."
|
echo " Используется локальный psql..."
|
||||||
|
# Завершаем все активные подключения к базе данных
|
||||||
|
echo " Завершение активных подключений к базе $DB_NAME..."
|
||||||
|
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '$DB_NAME' AND pid <> pg_backend_pid();" 2>/dev/null || true
|
||||||
|
# Очищаем базу и восстанавливаем
|
||||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
||||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "CREATE DATABASE $DB_NAME;"
|
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "CREATE DATABASE $DB_NAME;"
|
||||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" < "$TEMP_DUMP"
|
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" < "$TEMP_DUMP"
|
||||||
@@ -132,5 +174,33 @@ fi
|
|||||||
# Удаляем временный файл
|
# Удаляем временный файл
|
||||||
rm -f "$TEMP_DUMP"
|
rm -f "$TEMP_DUMP"
|
||||||
|
|
||||||
echo "✅ База данных успешно восстановлена из дампа!"
|
echo ""
|
||||||
|
echo "📦 Применение миграций для добавления user_id..."
|
||||||
|
|
||||||
|
# Определяем путь к миграциям
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
MIGRATIONS_DIR="$SCRIPT_DIR/play-life-backend/migrations"
|
||||||
|
|
||||||
|
if [ -d "$MIGRATIONS_DIR" ]; then
|
||||||
|
# Применяем миграцию 009 для добавления user_id
|
||||||
|
MIGRATION_FILE="$MIGRATIONS_DIR/009_add_users_and_multitenancy.sql"
|
||||||
|
if [ -f "$MIGRATION_FILE" ]; then
|
||||||
|
echo " Применяем миграцию: 009_add_users_and_multitenancy.sql"
|
||||||
|
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
||||||
|
docker-compose exec -T db psql -U "$DB_USER" -d "$DB_NAME" < "$MIGRATION_FILE" 2>/dev/null || true
|
||||||
|
elif command -v psql &> /dev/null; then
|
||||||
|
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" < "$MIGRATION_FILE" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo " ✅ Миграция применена"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ⚠️ Директория миграций не найдена: $MIGRATIONS_DIR"
|
||||||
|
echo " Миграции будут применены при запуске бэкенда"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ База данных успешно восстановлена из дампа!"
|
||||||
|
echo ""
|
||||||
|
echo "📌 ВАЖНО: После первой регистрации/входа пользователя все данные"
|
||||||
|
echo " будут автоматически привязаны к этому пользователю."
|
||||||
|
|
||||||
|
|||||||
77
run.sh
Executable file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для перезапуска уже настроенного приложения
|
||||||
|
# Использование: ./run.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Проверка наличия .env файла
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo -e "${RED}❌ Файл .env не найден!${NC}"
|
||||||
|
echo " Создайте файл .env на основе env.example"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Загружаем переменные окружения
|
||||||
|
export $(cat .env | grep -v '^#' | grep -v '^$' | xargs)
|
||||||
|
|
||||||
|
# Значения по умолчанию
|
||||||
|
DB_USER=${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD=${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME=${DB_NAME:-playeng}
|
||||||
|
DB_PORT=${DB_PORT:-5432}
|
||||||
|
PORT=${PORT:-8080}
|
||||||
|
WEB_PORT=${WEB_PORT:-3001}
|
||||||
|
|
||||||
|
echo -e "${GREEN}🔄 Перезапуск Play Life...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем, запущены ли контейнеры
|
||||||
|
if docker-compose ps | grep -q "Up"; then
|
||||||
|
echo -e "${YELLOW}Перезапуск существующих контейнеров...${NC}"
|
||||||
|
echo " - Backend сервер (с пересборкой)"
|
||||||
|
echo " - Frontend приложение (с пересборкой)"
|
||||||
|
echo " - База данных"
|
||||||
|
# Пересобираем и перезапускаем веб-сервер с новыми изменениями
|
||||||
|
echo -e "${BLUE}Пересборка веб-приложения...${NC}"
|
||||||
|
docker-compose build play-life-web
|
||||||
|
docker-compose up -d play-life-web
|
||||||
|
# Пересобираем и перезапускаем бэкенд с новыми изменениями
|
||||||
|
echo -e "${BLUE}Пересборка бэкенда...${NC}"
|
||||||
|
docker-compose build backend
|
||||||
|
docker-compose up -d --force-recreate backend
|
||||||
|
# Перезапускаем базу данных
|
||||||
|
docker-compose restart db
|
||||||
|
echo -e "${GREEN}✅ Контейнеры перезапущены${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Запуск контейнеров...${NC}"
|
||||||
|
echo " - База данных PostgreSQL 15 (порт: $DB_PORT)"
|
||||||
|
echo " - Backend сервер (порт: $PORT)"
|
||||||
|
echo " - Frontend приложение (порт: $WEB_PORT)"
|
||||||
|
docker-compose up -d --build
|
||||||
|
echo -e "${GREEN}✅ Контейнеры запущены${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}📋 Статус сервисов:${NC}"
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Готово!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}ℹ️ Используются креденшелы из .env:${NC}"
|
||||||
|
echo " - DB_USER: $DB_USER"
|
||||||
|
echo " - DB_NAME: $DB_NAME"
|
||||||
|
echo " - DB_PORT: $DB_PORT (внешний порт)"
|
||||||
|
echo " - Внутри Docker-сети: DB_HOST=db, DB_PORT=5432"
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon=true
|
nodaemon=true
|
||||||
logfile=/var/log/supervisor/supervisord.log
|
logfile=/dev/stdout
|
||||||
|
logfile_maxbytes=0
|
||||||
pidfile=/var/run/supervisord.pid
|
pidfile=/var/run/supervisord.pid
|
||||||
user=root
|
user=root
|
||||||
|
|
||||||
@@ -17,8 +18,11 @@ command=/app/backend/main
|
|||||||
directory=/app/backend
|
directory=/app/backend
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
stderr_logfile=/var/log/supervisor/backend.err.log
|
# Логи идут в stdout/stderr контейнера для docker logs
|
||||||
stdout_logfile=/var/log/supervisor/backend.out.log
|
stderr_logfile=/dev/stderr
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
priority=20
|
priority=20
|
||||||
# Переменные окружения будут переданы из docker run --env-file
|
# Переменные окружения будут переданы из docker run --env-file
|
||||||
# PORT по умолчанию 8080 внутри контейнера
|
# PORT по умолчанию 8080 внутри контейнера
|
||||||
|
|||||||