112 Commits
v1.1.1 ... main

Author SHA1 Message Date
poignatov
e3c81a36de Исправлен доступ к желаниям на досках
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-01-14 18:51:03 +03:00
poignatov
c654a01116 Исправление отображения баллов в условиях желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-14 18:30:43 +03:00
poignatov
7200cdfda9 Исправления списка словарей: крестик и отступы
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-14 18:03:29 +03:00
poignatov
0e509dd61a Замена иконки словарей на стрелочку
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-14 17:57:20 +03:00
poignatov
1f423e1ed3 Исправление двойного скролла (3.14.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-14 17:55:10 +03:00
poignatov
f13838d91a Отключены подзадачи для задач-тестов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m27s
2026-01-14 17:50:14 +03:00
poignatov
f9928c6470 Доски желаний и политика награждения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m0s
2026-01-13 22:35:01 +03:00
poignatov
5ebb55510e v3.12.2: удален лог
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-13 21:01:50 +03:00
poignatov
81e8ebdf66 Кнопка добавления в списке желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 51s
2026-01-13 21:00:20 +03:00
poignatov
ce7e0e584a Оптимизация wishlist: раздельные запросы и копирование
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
2026-01-13 20:55:44 +03:00
poignatov
db3b2640a8 Рефакторинг тестов: интеграция с задачами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-13 18:22:02 +03:00
poignatov
cfd9339e48 Версия 3.10.8: исправление стилей счётчика
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-13 17:22:02 +03:00
poignatov
0783602fe8 Улучшена загрузка метаданных wishlist
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-13 17:19:00 +03:00
poignatov
22995b654d Исправление ошибки добавления проекта
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-13 17:07:20 +03:00
poignatov
b8ae0bb17a Исправление ошибки authFetch при переносе проекта
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-13 17:00:47 +03:00
poignatov
441f872f33 Сортировка групп задач и серый заголовок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-13 16:58:03 +03:00
poignatov
0e53dfbdf7 Исправления формы желаний, очистка кода
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-13 16:52:08 +03:00
poignatov
a54c9983d4 Исправление скролла табов, версия 3.10.2
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-13 16:46:58 +03:00
poignatov
22bafd8c28 v3.10.1: упрощение заголовков отчётов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m18s
2026-01-13 16:44:22 +03:00
poignatov
f56278c670 Уточнение: commit message на русском
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
2026-01-12 18:59:52 +03:00
poignatov
72a6a3caf9 Добавлена связь задач с желаниями
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-01-12 18:58:52 +03:00
poignatov
9fbe2081ed Add cursor rule for version bump and push
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10s
2026-01-12 17:49:16 +03:00
poignatov
705eb2400e v3.9.5: Добавлена возможность копирования желаний, исправлена замена изображений
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-12 17:42:51 +03:00
poignatov
3cf3cd4edb fix(auth): improve token refresh with better logging and error handling
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 49s
- Add detailed logging for token refresh process
- Increase refresh timeout from 5s to 10s
- Log response body on refresh failure for diagnostics
- Verify tokens are present in refresh response
- Improve authFetch logging during retry

Version: 3.9.4
2026-01-12 17:05:19 +03:00
poignatov
b3a83e1e8f feat: замена period_type на start_date в wishlist, обновление UI формы условий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
- Добавлена миграция 020 для замены period_type на start_date в score_conditions
- Обновлена функция подсчёта баллов: calculateProjectPointsFromDate вместо calculateProjectPointsForPeriod
- Добавлен компонент DateSelector для выбора даты начала подсчёта
- По умолчанию выбран тип условия 'Баллы'
- Переименованы опции: 'Баллы' и 'Задача'
- Версия: 3.9.3
2026-01-12 17:02:33 +03:00
poignatov
d368929a4a fix: add migrations folder to Docker image for wishlist tables
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 52s
- Added COPY play-life-backend/migrations /migrations to Dockerfile
- Fixed 'relation wishlist_items does not exist' error on production
- Bump version to 3.9.2
2026-01-11 21:36:28 +03:00
poignatov
f19ed9cb81 fix: исправлены конфликты стилей между экранами - выпадающие заголовки задач, многоточие в тестах и словарях (v3.9.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-11 21:31:42 +03:00
poignatov
e2059ef555 feat: добавлено автозаполнение полей wishlist из ссылки (v3.9.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
- Добавлен эндпоинт /api/wishlist/metadata для извлечения метаданных из URL
- Реализовано извлечение Open Graph тегов (title, image, description)
- Добавлена кнопка Pull для ручной загрузки информации из ссылки
- Автоматическое заполнение полей: название, цена, картинка
- Обновлена версия до 3.9.0
2026-01-11 21:12:26 +03:00
poignatov
932dba8682 Унификация отображения ошибок: LoadingError для загрузки, Toast для действий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-01-11 15:51:28 +03:00
poignatov
8023fb9108 Bump version to 3.8.9: Add version display to profile
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-11 15:41:10 +03:00
poignatov
08f81887b0 Унифицировать отображение загрузки на всех экранах
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
- Добавлен единый стиль загрузки с лоадером и текстом 'Загрузка...'
- Центрирование по вертикали с учетом наличия табов
- Обновлены все 10 экранов: CurrentWeek, TestConfigSelection, TaskList, FullStatistics, WordList, TaskForm, TestWords, TodoistIntegration, TelegramIntegration, ProjectPriorityManager
- Версия: 3.8.8
2026-01-11 15:32:31 +03:00
poignatov
64d192796c fix: исправлен скроллинг нижнего бара при малом контенте (v3.8.7)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 49s
2026-01-11 15:11:15 +03:00
poignatov
f3a7d1c503 fix: исправлен расчет даты переноса задач с периодами повторения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 54s
- Добавлена поддержка сокращенных форм единиц времени (mons, min, hrs, wks, yrs и т.д.)
- Исправлена обработка недель, которые PostgreSQL возвращает как дни (7 days вместо 1 week)
- Добавлено приведение repetition_period к тексту при чтении из БД
- Обновлена версия до 3.8.6
2026-01-11 15:09:32 +03:00
poignatov
29cf05a3c3 fix: обновлен расчет общего процента выполнения - отсутствующие группы приоритетов считаются как 100%
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s
- Изменена функция calculateOverallProgress: теперь всегда учитываются 3 группы приоритетов (1, 2, 0)
- Если группа отсутствует, она считается как 100%
- Общий процент всегда вычисляется как среднее из 3 групп: (группа1 + группа2 + группа0) / 3
- Изменения применяются для API и ежедневных Telegram отчетов
- Версия обновлена до 3.8.5
2026-01-11 15:00:20 +03:00
poignatov
a8cb7c2730 fix: перегенерированы maskable иконки с исправленным фоном (v3.8.4)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-01-11 14:56:36 +03:00
poignatov
374d03cdfd fix: исправлены maskable иконки для Android - убран прозрачный фон (v3.8.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-01-11 14:55:11 +03:00
poignatov
d5e4699bcf fix: добавлена maskable иконка 192x192 для Android (v3.8.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-10 21:52:43 +03:00
poignatov
5ccb214c04 fix: исправлен бэкстек при переключении между табами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-10 21:49:20 +03:00
poignatov
11e0d0074c feat: добавлена поддержка PWA (v3.8.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
- Установлен vite-plugin-pwa для поддержки Progressive Web App
- Созданы иконки приложения для всех платформ (iOS, Android, Desktop)
- Настроен Service Worker с кэшированием статики и API данных
- Добавлен компонент PWAUpdatePrompt для уведомлений об обновлениях
- Обновлены конфигурации nginx для корректной работы Service Worker
- Добавлены PWA meta-теги в index.html
- Создан скрипт generate-icons.cjs для генерации иконок
2026-01-10 21:46:54 +03:00
poignatov
dde8858d7d Bump version to 3.7.0: Add next task date info in task completion dialog
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-01-10 19:27:36 +03:00
poignatov
cc7c6a905e v3.6.0: Улучшено модальное окно переноса задачи - нередактируемое поле с понятным форматированием даты
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s
2026-01-10 19:17:03 +03:00
poignatov
3d3fa13f41 fix: исправлена проблема с обновлением refresh token (race condition)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 50s
- Добавлена синхронизация параллельных refresh-запросов
- Исправлена проблема сброса авторизации на следующий день
- Версия обновлена до 3.5.7
2026-01-10 18:38:15 +03:00
poignatov
cbdcecea45 Fix: унифицировать горизонтальные отступы на всех экранах (v3.5.6)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 32s
2026-01-09 14:52:14 +03:00
poignatov
6cf4be65b2 fix: исправлена логика распределения слов в тесте и race condition
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
- Переписан алгоритм redistributeWordsEvenly с жадным подходом
- Добавлена пост-обработка для исправления последовательных дубликатов
- Исключаемое слово (текущее) теперь корректно не появляется первым
- Исправлен race condition с cardsShown через использование ref
- Добавлена проверка на null/undefined слова в пуле

v3.5.5
2026-01-09 14:40:45 +03:00
poignatov
ef59781633 v3.5.4: Скрыть дату под заголовком для задач в общем списке
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-01-09 14:17:17 +03:00
poignatov
97c031eda4 v3.5.3: Убрана группировка бесконечных задач, добавлена иконка бесконечности, улучшен UI модального окна переноса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-01-09 14:12:06 +03:00
poignatov
1097a84d06 Обновление модального окна переноса задачи (v3.5.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-01-09 13:51:50 +03:00
poignatov
b57b0bc901 Изменить логику выставления next_show_at: для repetition_period - сегодняшняя дата, для repetition_date - следующая подходящая дата
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-09 13:36:10 +03:00
Play Life Bot
60a6f4deb4 feat: improved navigation and unified close buttons - version 3.5.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-08 00:02:06 +03:00
poignatov
b1cfea22e6 Bump version to 3.4.2: improve date comparison in TaskList and enhance CI notifications
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-07 15:43:20 +03:00
poignatov
2f16876185 Bump version to 3.4.1 and add version logging on startup
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-07 15:31:40 +03:00
poignatov
b9133f60dc Bump version to 3.4.0 and add Telegram notifications to CI
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-01-07 15:06:58 +03:00
poignatov
db74626068 Fix regex panic in task completion (v3.3.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
- Replace unsupported lookahead regex with manual string replacement
- Fix 502 error when completing tasks
- Update version to 3.3.1
2026-01-06 16:50:11 +03:00
poignatov
b41f6e7cdc Add repetition_date support for tasks (v3.3.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 42s
- Add repetition_date field to tasks table (migration 018)
- Support pattern-based repetition: day of week, day of month, specific date
- Add 'Через'/'Каждое' mode selector in task form
- Auto-calculate next_show_at from repetition_date on create/complete
- Show calculated next date in postpone dialog for repetition_date tasks
- Update version to 3.3.0
2026-01-06 16:41:54 +03:00
poignatov
508355dcb3 feat: добавлена функциональность откладывания задач (next_show_at)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-01-06 15:56:52 +03:00
poignatov
1da35aaea4 fix: исправлен отступ между словами в списке перед и после теста
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 27s
2026-01-06 15:31:43 +03:00
poignatov
d9db42a598 v3.1.4: Улучшено равномерное распределение карточек в тесте
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
- Добавлен учёт текущей карточки при перераспределении (не показывается следующей)
- Исправлен алгоритм равномерного распределения для предотвращения подряд идущих одинаковых карточек
- Исправлен правый счётчик: показывает текущий размер пула + показанные карточки (не больше maxCards)
2026-01-06 15:30:30 +03:00
poignatov
28a45ab81e fix: использовать приоритет только из weekly_goals, без fallback на projects.priority (v3.1.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
2026-01-06 15:09:58 +03:00
poignatov
9e5790f70e fix: очистка формы после добавления задачи (v3.1.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
2026-01-06 15:02:55 +03:00
poignatov
7df258da15 feat: добавлена поддержка шаблонов $0 и \$0 для наград в задачах (v3.1.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-06 15:00:42 +03:00
poignatov
0ea531889d v3.1.0: Оптимизация загрузки списка задач - все данные в одном запросе, добавлены индикаторы подзадач и прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
2026-01-06 14:54:37 +03:00
poignatov
28d8148665 fix: убрать текст 'Выполнение...' из кнопки выполнения задачи с длительностью
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 29s
2026-01-06 14:38:16 +03:00
poignatov
a7bc912db3 v3.0.0: Добавлен обратный поворот карточки в тесте по клику
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
2026-01-06 14:34:28 +03:00
poignatov
647c549ec9 feat: добавлен раздел 'Бесконечные' для задач с периодичностью 0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-06 14:31:00 +03:00
poignatov
a6065d7ff1 fix: исправлен импорт TaskForm с явным расширением .jsx, версия 2.9.1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-01-04 19:42:29 +03:00
poignatov
79430ba7f0 v2.9.0: Улучшения экрана списка задач - оптимизация загрузки, toast уведомления, исправление центрирования
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 44s
2026-01-04 19:37:59 +03:00
poignatov
6d7d59d2ae Add init/run scripts and Cursor/VSCode configurations
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
- Add init.sh: initializes app with Docker, creates prod backup, restores to local DB
- Add run.sh: restarts all containers
- Update restore-db.sh: auto-selects latest dump, terminates active connections before restore
- Add .cursor/commands.json: Cursor commands (init, run, backupFromProd, restoreToLocal)
- Add .vscode/tasks.json: VSCode tasks for running scripts
- Add .vscode/launch.json: launch configurations for restarting server
- Remove play-life-backend/env.example (unified .env in root)
2026-01-03 17:08:42 +03:00
poignatov
2b9a024d3e fix: исправлен расчет общего процента выполнения в ежедневном отчете и унифицирована логика
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-03 16:13:28 +03:00
Play Life Bot
4767f5975c Revert add dictionary button color to black (v2.8.5)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
2026-01-02 18:27:25 +03:00
Play Life Bot
bacb605a0c Change add dictionary button color to white (v2.8.4)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
2026-01-02 18:24:39 +03:00
Play Life Bot
3bdad682b3 Improve scroll handling in project priorities list (v2.8.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22s
2026-01-02 18:11:57 +03:00
Play Life Bot
01e8b3468c Fix scroll calculation for project priorities list (v2.8.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22s
2026-01-02 18:05:02 +03:00
Play Life Bot
ac34f480be Fix scroll issue in project priorities list (v2.8.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s
2026-01-02 17:52:54 +03:00
Play Life Bot
27befeb92b Refactor group 2 word selection: use (failure+1)/(success+1) ratio, bump version to 2.8.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-01-02 16:50:40 +03:00
Play Life Bot
9e50a718d8 chore: bump version to 2.7.4
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22s
2026-01-02 16:41:20 +03:00
Play Life Bot
08c5422d35 fix: отображение блока процента выполнения даже при отсутствии проектов (v2.7.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 16:30:08 +03:00
Play Life Bot
bf539c6e91 Fix: Improve auth persistence on container restart - distinguish network errors from auth errors (v2.7.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 21s
2026-01-02 16:19:54 +03:00
Play Life Bot
2326a774ad Fix: Prevent auth state loss on container restart (v2.7.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 21s
2026-01-02 16:15:42 +03:00
Play Life Bot
1cfaaa9506 chore: bump version to 2.7.1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 19s
2026-01-02 16:11:48 +03:00
Play Life Bot
ecc61c2a5f fix: add project/create endpoint to nginx config
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 16:11:35 +03:00
Play Life Bot
a5ce0de236 feat: добавлена возможность создания проектов через UI - версия 2.7.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-01-02 16:09:16 +03:00
Play Life Bot
ccb365c95c Remove integration icons from profile list
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
2026-01-02 16:08:32 +03:00
Play Life Bot
1b2c79a8f2 Fix profile layout: align list items and add integration icons
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 30s
2026-01-02 16:04:22 +03:00
Play Life Bot
d012f39be8 feat: увеличить время жизни access token до 24 часов, сделать refresh token бессрочным
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-02 16:00:54 +03:00
Play Life Bot
8b66e5fd6e Remove emoji from logout button (v2.4.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 21s
2026-01-02 15:59:24 +03:00
Play Life Bot
4ca6eb4fd5 Remove emojis from integrations list in profile (v2.4.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20s
2026-01-02 15:55:56 +03:00
Play Life Bot
3a256dc290 Remove Floor from priority 0 calculation formula, bump version to 2.3.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-02 15:52:15 +03:00
Play Life Bot
38f640e38e chore: bump version to 2.2.5
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-02 15:46:49 +03:00
Play Life Bot
8f7acee60c fix: handle string ID in Todoist user info response
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:46:20 +03:00
Play Life Bot
bcea4b2bf5 fix: improve Todoist user info API call with better logging and error handling
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:45:54 +03:00
Play Life Bot
bd6dfd968c chore: bump version to 2.2.4
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-02 15:43:28 +03:00
Play Life Bot
7547058507 fix: use App.jwtSecret instead of env for OAuth state token
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:42:59 +03:00
Play Life Bot
53e3f23422 chore: bump version to 2.2.3
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-01-02 15:40:34 +03:00
Play Life Bot
713f6020f6 fix: use authFetch for Todoist OAuth connect to send auth header
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:40:06 +03:00
Play Life Bot
72002a2b4f chore: bump version to 2.2.2
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 32s
2026-01-02 15:37:56 +03:00
Play Life Bot
bc73160e1a fix: remove duplicate code in todoistWebhookHandler
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:37:40 +03:00
Play Life Bot
9206b73b33 chore: bump version to 2.2.1
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 27s
2026-01-02 15:36:06 +03:00
Play Life Bot
e74c4cf599 fix: use correct jwt/v5 API for OAuth state token
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:35:37 +03:00
Play Life Bot
a7128703fe feat: refactor Todoist integration to single app with OAuth
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 32s
- Single webhook URL for all users

- OAuth authorization flow

- Removed individual webhook tokens

- User identification by todoist_user_id

- Added OAuth endpoints: connect, callback, status, disconnect

- Updated frontend with OAuth flow

- DB migration 013: removed webhook_token, added todoist_user_id, todoist_email, access_token

Version: 2.2.0
2026-01-02 15:34:01 +03:00
Play Life Bot
8ba7e8fd45 feat: Переделка Telegram интеграции на единого бота (v2.1.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 54s
- Единый бот для всех пользователей (токен из .env)
- Deep link для подключения через /start команду
- Отдельная таблица todoist_integrations для Todoist webhook
- Персональные отчеты для каждого пользователя
- Автоматическое применение миграции 012 при старте
- Обновлен Frontend: кнопка подключения вместо поля ввода токена
2026-01-02 14:47:51 +03:00
poignatov
4df054536a Fix dictionary_id type error by removing COALESCE from prepared statement (v2.0.8)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-01-01 19:33:29 +03:00
poignatov
cf4d5d40c3 Fix dictionary_id type mismatch in addWordsHandler (v2.0.7)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-01-01 19:25:04 +03:00
poignatov
d96bb2ce8d v2.0.6: Fix addWords handler - add user_id and improve error handling
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
- Added user_id to words insertion (was missing, causing 500 errors)
- Fixed default dictionary query (removed incorrect id=0 condition)
- Added dictionary ownership validation before inserting words
- Added comprehensive logging for debugging addWords operations
2026-01-01 19:13:37 +03:00
poignatov
6f77f0643c v2.0.5: Fix transaction errors and webhook parsing
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
- Fixed transaction abort error in insertMessageData (replaced ON CONFLICT with SELECT check)
- Fixed double body reading in setupTelegramWebhook (use json.Unmarshal)
- Fixed Todoist webhook JSON parsing (use json.Unmarshal from bodyBytes)
- Improved error handling in webhook responses
- Added user_id to nodes insertion
2026-01-01 18:57:30 +03:00
poignatov
edc29fbd97 v2.0.4: Fix webhook error handling and logging
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
- Webhooks now return 200 OK even on errors (to prevent retries)
- Improved error handling with proper JSON responses
- Enhanced logging for webhook debugging
- Supervisor logs now visible in docker logs (stdout/stderr)
- Fixed TodoistIntegration error display in UI
2026-01-01 18:50:55 +03:00
poignatov
7704de334c v2.0.3: Webhook user identification by URL token
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 42s
- Added webhook_token to telegram_integrations
- Webhooks now identify users by token in URL (/webhook/telegram/{token}, /webhook/todoist/{token})
- Webhook automatically configured for all users on backend startup
- Migration 011: Add webhook_token column
2026-01-01 18:38:28 +03:00
poignatov
ad1caceda0 v2.0.2: Update version
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-01 18:25:20 +03:00
poignatov
91d9b52524 fix: downgrade golang.org/x/crypto to v0.28.0 for Go 1.21 compatibility
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-01 18:24:49 +03:00
poignatov
914998980e v2.0.1: Fix Go version for Docker build
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 12s
2026-01-01 18:24:00 +03:00
poignatov
b709192447 fix: go version 1.21 for Docker build
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-01 18:23:10 +03:00
poignatov
4a06ceb7f6 v2.0.0: Multi-user authentication with JWT
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 16s
Features:
- User registration and login with JWT tokens
- All data is now user-specific (multi-tenancy)
- Profile page with integrations and logout
- Automatic migration of existing data to first user

Backend changes:
- Added users and refresh_tokens tables
- Added user_id to all data tables (projects, entries, nodes, dictionaries, words, progress, configs, telegram_integrations, weekly_goals)
- JWT authentication middleware
- claimOrphanedData() for data migration

Frontend changes:
- AuthContext for state management
- Login/Register forms
- Profile page (replaced Integrations)
- All API calls use authFetch with Bearer token

Migration notes:
- On first deploy, backend automatically adds user_id columns
- First user to login claims all existing data
2026-01-01 18:21:18 +03:00
poignatov
6015b62d29 Исправлена логика dump-db.sh для работы с удаленными хостами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 15s
2026-01-01 17:04:10 +03:00
98 changed files with 28826 additions and 2442 deletions

33
.cursor/commands.json Normal file
View 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}"
}
]
}

View 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` в корне проекта.

View 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 версию
- "Подними версию и запушь" → спросить какой тип версии поднять

View File

@@ -1,63 +0,0 @@
# ============================================
# Единый файл конфигурации для всех проектов
# Backend и Play-Life-Web
# ============================================
# ============================================
# Database Configuration
# ============================================
DB_HOST=localhost
DB_PORT=5432
DB_USER=playeng
DB_PASSWORD=playeng
DB_NAME=playeng
# ============================================
# Backend Server Configuration
# ============================================
# Порт для backend сервера (по умолчанию: 8080)
# В production всегда используется порт 8080 внутри контейнера
PORT=8080
# ============================================
# Play Life Web Configuration
# ============================================
# Порт для frontend приложения play-life-web
WEB_PORT=3001
# ============================================
# Telegram Bot Configuration (optional)
# ============================================
# Get token from @BotFather in Telegram: https://t.me/botfather
# To get chat ID: send a message to your bot, then visit: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
# Look for "chat":{"id":123456789} - that number is your chat ID
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_CHAT_ID=123456789
# Base URL для автоматической настройки webhook
# Примеры:
# - Для production с HTTPS: https://your-domain.com
# - Для локальной разработки с ngrok: https://abc123.ngrok.io
# - Для прямого доступа на нестандартном порту: http://your-server:8080
# Webhook будет настроен автоматически при старте сервера на: <TELEGRAM_WEBHOOK_BASE_URL>/webhook/telegram
# Если не указан, webhook нужно настраивать вручную
TELEGRAM_WEBHOOK_BASE_URL=https://your-domain.com
# ============================================
# Todoist Webhook Configuration (optional)
# ============================================
# Секрет для проверки подлинности webhook от Todoist
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
# Оставьте пустым, если не хотите использовать проверку секрета
TODOIST_WEBHOOK_SECRET=
# ============================================
# Scheduler Configuration
# ============================================
# Часовой пояс для планировщика задач (например: Europe/Moscow, America/New_York, UTC)
# Используется для:
# - Автоматической фиксации целей на неделю каждый понедельник в 6:00
# - Отправки ежедневного отчёта в 23:59
# ВАЖНО: Укажите правильный часовой пояс, иначе задачи будут срабатывать в UTC!
# Список доступных часовых поясов: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
TIMEZONE=Europe/Moscow

View File

@@ -43,14 +43,67 @@ jobs:
echo "${{ secrets.GIT_TOKEN }}" | docker login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
- name: Build and Push
id: build
if: steps.version_check.outputs.changed == 'true'
run: |
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
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 .
# Пушим оба тега
echo "Pushing image to registry..."
docker push $REGISTRY:latest
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
View File

@@ -11,3 +11,6 @@ node_modules/
database-dumps/*.sql
database-dumps/*.sql.gz
!database-dumps/.gitkeep
# Uploaded files
uploads/

4
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"version": "0.2.0",
"configurations": []
}

82
.vscode/tasks.json vendored Normal file
View 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": "Восстановление базы данных из самого свежего дампа в локальную базу (автоматически выбирает последний дамп)"
}
]
}

View File

@@ -33,12 +33,20 @@ RUN apk --no-cache add \
# Создаем директории
WORKDIR /app
# Создаем директорию для загруженных файлов
RUN mkdir -p /app/uploads/wishlist && \
chmod 755 /app/uploads
# Копируем собранный frontend
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
# Копируем собранный backend
COPY --from=backend-builder /app/backend/main /app/backend/main
COPY play-life-backend/admin.html /app/backend/admin.html
# Копируем миграции для применения при запуске
COPY play-life-backend/migrations /migrations
# Копируем файл версии
COPY VERSION /app/VERSION
# Копируем конфигурацию nginx
COPY nginx.conf /etc/nginx/nginx.conf

727
TODOIST_REFACTOR_PLAN.md Normal file
View 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

View File

@@ -1,2 +1 @@
1.1.1
3.14.5

View File

@@ -41,6 +41,7 @@ services:
condition: service_healthy
volumes:
- ./play-life-backend/migrations:/migrations
- ./uploads:/app/uploads
env_file:
- .env

View File

@@ -65,15 +65,33 @@ echo " Хост: $DB_HOST:$DB_PORT"
echo " Пользователь: $DB_USER"
echo " Файл: $DUMP_PATH"
# Создаем дамп через docker-compose, если контейнер запущен
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
# Создаем дамп через docker-compose, если контейнер запущен И хост локальный
if [ "$DB_HOST" = "localhost" ] || [ "$DB_HOST" = "127.0.0.1" ] || [ -z "$DB_HOST" ]; then
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
echo " Используется docker-compose..."
docker-compose exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
elif command -v pg_dump &> /dev/null; then
elif command -v pg_dump &> /dev/null; then
# Или напрямую через pg_dump, если БД доступна локально
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
elif command -v docker &> /dev/null; then
# Используем Docker образ postgres для создания дампа
echo " Используется Docker (postgres:latest)..."
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-compose не запущен и Docker недоступен"
echo " Установите PostgreSQL клиент или Docker"
exit 1
fi
else
# Для удаленных хостов используем pg_dump или Docker
if command -v pg_dump &> /dev/null; then
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)..."
@@ -82,10 +100,11 @@ elif command -v docker &> /dev/null; then
-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-compose не запущен и Docker недоступен"
else
echo "❌ Ошибка: pg_dump не найден и Docker недоступен"
echo " Установите PostgreSQL клиент или Docker"
exit 1
fi
fi
# Сжимаем дамп

View File

@@ -28,8 +28,10 @@ WEB_PORT=3001
# ============================================
# 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
# Примеры:
# - Для production с HTTPS: https://your-domain.com
@@ -40,13 +42,35 @@ WEB_PORT=3001
WEBHOOK_BASE_URL=https://your-domain.com
# ============================================
# Todoist Webhook Configuration (optional)
# Todoist Integration Configuration
# ============================================
# Секрет для проверки подлинности webhook от Todoist
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
# Оставьте пустым, если не хотите использовать проверку секрета
# Единое Todoist приложение для всех пользователей Play Life
# Настроить в: 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=
# ============================================
# Authentication Configuration
# ============================================
# Секретный ключ для подписи JWT токенов
# ВАЖНО: Обязательно задайте свой уникальный секретный ключ для production!
# Если не задан, будет использован случайно сгенерированный (не рекомендуется для production)
# Можно сгенерировать с помощью: openssl rand -base64 32
JWT_SECRET=your-super-secret-jwt-key-change-in-production
# ============================================
# Scheduler Configuration
# ============================================

160
init.sh Executable file
View 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

View File

@@ -50,7 +50,7 @@ server {
}
# 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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -62,6 +62,31 @@ server {
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)
location / {
try_files $uri $uri/ /index.html;

View File

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

View File

@@ -4,8 +4,15 @@ go 1.21
require (
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/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
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
)

View File

@@ -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/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/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
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/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=
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=

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -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 при первом подключении';

View File

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

View File

@@ -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.';

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

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

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

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

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

View File

@@ -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;

View File

@@ -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.';

View File

@@ -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.';

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

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

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

View File

@@ -2,8 +2,18 @@
<html lang="ru">
<head>
<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" />
<!-- 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>
</head>
<body>

View File

@@ -24,7 +24,7 @@ server {
}
# 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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -36,6 +36,30 @@ server {
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)
location / {
try_files $uri $uri/ /index.html;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "1.1.1",
"version": "3.14.5",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,7 +14,8 @@
"chart.js": "^4.4.0",
"react": "^18.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": {
"@types/react": "^18.2.43",
@@ -22,7 +23,9 @@
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"sharp": "^0.34.5",
"tailwindcss": "^3.3.6",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -4,16 +4,46 @@ import FullStatistics from './components/FullStatistics'
import ProjectPriorityManager from './components/ProjectPriorityManager'
import WordList from './components/WordList'
import AddWords from './components/AddWords'
import TestConfigSelection from './components/TestConfigSelection'
import AddConfig from './components/AddConfig'
import DictionaryList from './components/DictionaryList'
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)
const CURRENT_WEEK_API_URL = '/playlife-feed'
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 [selectedProject, setSelectedProject] = useState(null)
const [loadedTabs, setLoadedTabs] = useState({
@@ -22,10 +52,18 @@ function App() {
full: false,
words: false,
'add-words': false,
'test-config': false,
'add-config': false,
dictionaries: 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,
words: false,
'add-words': false,
'test-config': false,
'add-config': false,
dictionaries: 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 [fullStatisticsData, setFullStatisticsData] = useState(null)
const [tasksData, setTasksData] = useState(null)
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
const [tasksLoading, setTasksLoading] = useState(false)
// Состояния фоновой загрузки (не показываются визуально)
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
const [tasksBackgroundLoading, setTasksBackgroundLoading] = useState(false)
// Ошибки
const [currentWeekError, setCurrentWeekError] = useState(null)
const [fullStatisticsError, setFullStatisticsError] = useState(null)
const [prioritiesError, setPrioritiesError] = useState(null)
const [tasksError, setTasksError] = useState(null)
// Состояние для кнопки Refresh (если она есть)
const [isRefreshing, setIsRefreshing] = useState(false)
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
// Восстанавливаем последний выбранный таб после перезагрузки
const [isInitialized, setIsInitialized] = useState(false)
// Инициализация из URL (только для глубоких табов) или localStorage
useEffect(() => {
if (isInitialized) return
try {
// Проверяем путь /invite/:token для присоединения к доске
const path = window.location.pathname
if (path.startsWith('/invite/')) {
const token = path.replace('/invite/', '')
if (token) {
setActiveTab('board-join')
setLoadedTabs(prev => ({ ...prev, 'board-join': 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')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'integrations']
if (savedTab && validTabs.includes(savedTab)) {
setActiveTab(savedTab)
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
setIsInitialized(true)
} else {
setIsInitialized(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) {
console.warn('Не удалось прочитать активный таб из localStorage', err)
console.warn('Не удалось прочитать активный таб', err)
setIsInitialized(true)
}
}, [isInitialized])
@@ -95,6 +199,91 @@ function App() {
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) => {
try {
if (isBackground) {
@@ -103,8 +292,7 @@ function App() {
setCurrentWeekLoading(true)
}
setCurrentWeekError(null)
console.log('Fetching current week data from:', CURRENT_WEEK_API_URL)
const response = await fetch(CURRENT_WEEK_API_URL)
const response = await authFetch(CURRENT_WEEK_API_URL)
if (!response.ok) {
throw new Error('Ошибка загрузки данных')
}
@@ -149,7 +337,7 @@ function App() {
setCurrentWeekLoading(false)
}
}
}, [])
}, [authFetch])
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
try {
@@ -159,7 +347,7 @@ function App() {
setFullStatisticsLoading(true)
}
setFullStatisticsError(null)
const response = await fetch(FULL_STATISTICS_API_URL)
const response = await authFetch(FULL_STATISTICS_API_URL)
if (!response.ok) {
throw new Error('Ошибка загрузки данных')
}
@@ -175,7 +363,33 @@ function App() {
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 для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
const tabsInitializedRef = useRef({
@@ -184,16 +398,20 @@ function App() {
full: false,
words: false,
'add-words': false,
'test-config': false,
'add-config': false,
dictionaries: false,
test: false,
integrations: false,
tasks: false,
'task-form': false,
profile: false,
'todoist-integration': false,
'telegram-integration': false,
})
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
const cacheRef = useRef({
current: null,
full: null,
tasks: null,
})
// Обновляем ref при изменении данных
@@ -205,6 +423,10 @@ function App() {
cacheRef.current.full = fullStatisticsData
}, [fullStatisticsData])
useEffect(() => {
cacheRef.current.tasks = tasksData
}, [tasksData])
// Функция для загрузки данных таба
const loadTabData = useCallback((tab, isBackground = false) => {
if (tab === 'current') {
@@ -246,20 +468,33 @@ function App() {
// Возврат на таб - фоновая загрузка
setPrioritiesRefreshTrigger(prev => prev + 1)
}
} else if (tab === 'test-config') {
const isInitialized = tabsInitializedRef.current['test-config']
} else if (tab === 'dictionaries') {
const isInitialized = tabsInitializedRef.current['dictionaries']
if (!isInitialized) {
// Первая загрузка таба
setTestConfigRefreshTrigger(prev => prev + 1)
tabsInitializedRef.current['test-config'] = true
setTabsInitialized(prev => ({ ...prev, 'test-config': true }))
setDictionariesRefreshTrigger(prev => prev + 1)
tabsInitializedRef.current['dictionaries'] = true
setTabsInitialized(prev => ({ ...prev, 'dictionaries': true }))
} 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, если она есть)
const refreshAllData = useCallback(async () => {
@@ -280,6 +515,82 @@ function App() {
setIsRefreshing(false)
}, [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(() => {
const handleFocus = () => {
@@ -301,6 +612,8 @@ function App() {
const handleProjectClick = (projectName) => {
setSelectedProject(projectName)
markTabAsLoaded('full')
setTabParams({ selectedProject: projectName })
updateUrl('full', { selectedProject: projectName }, activeTab)
setActiveTab('full')
}
@@ -308,14 +621,46 @@ function App() {
if (tab === 'full' && activeTab === 'full') {
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
setSelectedProject(null)
} else if (tab !== activeTab) {
markTabAsLoaded(tab)
// Сбрасываем tabParams при переходе с add-config на другой таб
if (activeTab === 'add-config' && tab !== 'add-config') {
setTabParams({})
updateUrl('full', {}, activeTab)
} else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form') {
// Для task-form и wishlist-form всегда обновляем параметры, даже если это тот же таб
markTabAsLoaded(tab)
// Определяем, является ли текущий таб глубоким
const isCurrentTabDeep = deepTabs.includes(activeTab)
const isNewTabDeep = deepTabs.includes(tab)
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)
if (tab === 'current') {
setSelectedProject(null)
@@ -324,6 +669,21 @@ function App() {
if (activeTab === 'add-words' && tab === 'words') {
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
}
}
@@ -376,12 +736,37 @@ function App() {
}, [activeTab])
// Определяем, нужно ли скрывать нижнюю панель (для 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 (
<div className="flex flex-col min-h-screen min-h-dvh">
<div className={`flex-1 ${isFullscreenTab ? 'pb-0' : 'pb-20'}`}>
<div className={`max-w-7xl mx-auto ${isFullscreenTab ? 'p-0' : 'p-4 md:p-6'}`}>
<div className="flex flex-col h-screen h-dvh overflow-hidden">
<div className={`flex-1 overflow-y-auto ${isFullscreenTab ? 'pb-0' : 'pb-20'}`}>
<div className={`max-w-7xl mx-auto ${getContainerPadding()}`}>
{loadedTabs.current && (
<div className={activeTab === 'current' ? 'block' : 'hidden'}>
<CurrentWeek
@@ -414,7 +799,11 @@ function App() {
<div className={activeTab === 'full' ? 'block' : 'hidden'}>
<FullStatistics
selectedProject={selectedProject}
onClearSelection={() => setSelectedProject(null)}
onClearSelection={() => {
setSelectedProject(null)
setTabParams({})
replaceUrl('full', {})
}}
data={fullStatisticsData}
loading={fullStatisticsLoading}
error={fullStatisticsError}
@@ -446,21 +835,11 @@ function App() {
</div>
)}
{loadedTabs['test-config'] && (
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}>
<TestConfigSelection
{loadedTabs.dictionaries && (
<div className={activeTab === 'dictionaries' ? 'block' : 'hidden'}>
<DictionaryList
onNavigate={handleNavigate}
refreshTrigger={testConfigRefreshTrigger}
/>
</div>
)}
{loadedTabs['add-config'] && (
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
<AddConfig
key={tabParams.config?.id || 'new'}
onNavigate={handleNavigate}
editingConfig={tabParams.config}
refreshTrigger={dictionariesRefreshTrigger}
/>
</div>
)}
@@ -472,20 +851,118 @@ function App() {
wordCount={tabParams.wordCount}
configId={tabParams.configId}
maxCards={tabParams.maxCards}
taskId={tabParams.taskId}
/>
</div>
)}
{loadedTabs.integrations && (
<div className={activeTab === 'integrations' ? 'block' : 'hidden'}>
<Integrations onNavigate={handleNavigate} />
{loadedTabs.tasks && (
<div className={activeTab === 'tasks' ? 'block' : 'hidden'}>
<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>
{!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">
<button
onClick={() => handleTabChange('current')}
@@ -509,43 +986,62 @@ function App() {
)}
</button>
<button
onClick={() => handleTabChange('test-config')}
onClick={() => handleTabChange('tasks')}
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-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Тест"
title="Задачи"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
<path d="M8 7h6"></path>
<path d="M8 11h4"></path>
<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>
</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>
)}
</button>
<button
onClick={() => handleTabChange('integrations')}
onClick={() => handleTabChange('wishlist')}
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-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Интеграции"
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="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="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
<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>
{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>
)}
</button>
@@ -556,6 +1052,15 @@ function App() {
)
}
function App() {
return (
<AuthProvider>
<AppContent />
<PWAUpdatePrompt />
</AuthProvider>
)
}
export default App

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import React, { useState } from 'react'
import { useAuth } from './auth/AuthContext'
import './AddWords.css'
const API_URL = '/api'
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
const { authFetch } = useAuth()
const [markdownText, setMarkdownText] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
@@ -81,7 +83,7 @@ function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
}))
const response = await fetch(`${API_URL}/words`, {
const response = await authFetch(`${API_URL}/words`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

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

View 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

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

View 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

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

View 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

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

View 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

View File

@@ -1,37 +1,25 @@
import ProjectProgressBar from './ProjectProgressBar'
import LoadingError from './LoadingError'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
// Обрабатываем данные: может быть объект с projects и total, или просто массив
const projectsData = data?.projects || (Array.isArray(data) ? data : [])
const projectsData = data?.projects || (Array.isArray(data) ? data : []) || []
// Показываем loading только если данных нет и идет загрузка
if (loading && (!data || projectsData.length === 0)) {
return (
<div className="flex justify-center items-center py-16">
<div className="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 className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
)
}
if (error && (!data || projectsData.length === 0)) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
<div className="text-red-600 text-sm">{error}</div>
</div>
<button
onClick={onRetry}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
>
Попробовать снова
</button>
</div>
)
return <LoadingError onRetry={onRetry} />
}
// Процент выполнения берем только из данных API
@@ -49,26 +37,8 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
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) => {
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 по убыванию
const sortedData = [...projectsData].sort((a, b) => {
const sortedData = (projectsData && projectsData.length > 0) ? [...projectsData].sort((a, b) => {
const priorityA = normalizePriority(a.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 minGoalB = parseFloat(b.min_goal_score) || 0
return minGoalB - minGoalA
})
}) : []
return (
<div>
@@ -99,10 +69,12 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
<div className="flex-1">
<div className="text-sm sm:text-base text-gray-600 mb-1">Выполнение целей</div>
<div className="text-2xl sm:text-3xl lg:text-4xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{hasProgressData ? `${overallProgress.toFixed(1)}%` : 'N/A'}
{hasProgressData && typeof overallProgress === 'number' && Number.isFinite(overallProgress)
? `${overallProgress.toFixed(1)}%`
: 'N/A'}
</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">
<svg className="transform -rotate-90" viewBox="0 0 64 64">
<circle
@@ -121,7 +93,7 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
stroke="url(#gradient)"
strokeWidth="6"
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"
/>
{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">
{sortedData.map((project, index) => {
if (!project || !project.project_name) {
return null
}
const projectColor = getProjectColor(project.project_name, allProjects)
return (
<div key={index}>
<ProjectProgressBar
projectName={project.project_name}
totalScore={parseFloat(project.total_score)}
minGoalScore={parseFloat(project.min_goal_score)}
maxGoalScore={parseFloat(project.max_goal_score)}
totalScore={parseFloat(project.total_score) || 0}
minGoalScore={parseFloat(project.min_goal_score) || 0}
maxGoalScore={parseFloat(project.max_goal_score) || 0}
onProjectClick={onProjectClick}
projectColor={projectColor}
priority={project.priority}

View File

@@ -1,269 +1,40 @@
.config-selection {
.dictionary-list {
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;
padding-bottom: 5rem;
}
.add-config-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
background-color: rgba(52, 152, 219, 0.05);
border-color: #2980b9;
}
.add-config-icon {
font-size: 3rem;
font-weight: bold;
color: #3498db;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
line-height: 1;
}
.add-config-text {
font-size: 1rem;
font-weight: 500;
color: #3498db;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.loading, .error-message {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.configs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.config-card {
background: #3498db;
border-radius: 12px;
padding: 1.5rem 1rem;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 180px;
position: relative;
}
.card-menu-button {
position: absolute;
top: 0.5rem;
right: 0;
background: transparent;
.dictionary-close-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 6px;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.75rem;
color: white;
font-weight: bold;
transition: all 0.2s;
z-index: 10;
padding: 0;
line-height: 1;
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);
}
.card-menu-button:hover {
opacity: 0.8;
transform: scale(1.1);
}
.config-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
}
.config-words-count {
font-size: 2.5rem;
font-weight: bold;
color: white;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.config-max-cards {
font-size: 1.5rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
margin-top: -1rem;
margin-bottom: auto;
}
.config-name {
font-size: 1rem;
font-weight: 500;
color: white;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.config-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.config-modal {
background: white;
border-radius: 12px;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.config-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.config-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
}
.config-modal-close:hover {
background-color: #f0f0f0;
}
.config-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.config-modal-edit,
.config-modal-delete {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.config-modal-edit {
background-color: #3498db;
color: white;
}
.config-modal-edit:hover {
background-color: #2980b9;
transform: translateY(-1px);
}
.config-modal-delete {
background-color: #e74c3c;
color: white;
}
.config-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
}
.section-divider {
margin: 0.5rem 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 2px solid #e0e0e0;
}
.section-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
.dictionary-close-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.dictionaries-section {
margin-top: 2rem;
.dictionaries-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
padding-top: 4rem;
margin-bottom: 1rem;
}
.dictionary-card {
@@ -281,13 +52,31 @@
position: relative;
}
.dictionary-card .card-menu-button {
background: transparent;
color: #2c3e50;
.dictionary-list .dictionary-card .dictionary-menu-button {
position: absolute;
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;
transform: scale(1.1);
}
.dictionary-card:hover {
@@ -337,11 +126,99 @@
border-color: #1a252f;
}
.add-dictionary-button .add-config-icon {
.add-dictionary-icon {
font-size: 3rem;
font-weight: bold;
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;
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);
}

View 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

View File

@@ -12,7 +12,9 @@ import {
} from 'chart.js'
import { Line } from 'react-chartjs-2'
import WeekProgressChart from './WeekProgressChart'
import LoadingError from './LoadingError'
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
import './Integrations.css'
// Экспортируем для обратной совместимости (если используется в других местах)
export { getProjectColorByIndex } from '../utils/projectUtils'
@@ -118,30 +120,17 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
// Показываем loading только если данных нет и идет загрузка
if (loading && !chartData) {
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="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>
)
}
if (error && !chartData) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
<div className="text-red-600 text-sm">{error}</div>
</div>
<button
onClick={onRetry}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
>
Попробовать снова
</button>
</div>
)
return <LoadingError onRetry={onRetry} />
}
const chartOptions = {
@@ -214,22 +203,17 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
}
return (
<div>
<div className="max-w-2xl mx-auto">
{onNavigate && (
<div className="flex justify-end mb-4">
<button
onClick={() => onNavigate('current')}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
className="close-x-button"
title="Закрыть"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
)}
<div style={{ height: '550px' }}>
<div style={{ height: '550px', paddingTop: '60px' }}>
<Line data={chartData} options={chartOptions} />
</div>
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />

View File

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

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

View 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

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

View 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

View File

@@ -19,17 +19,122 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
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)
const PROJECTS_API_URL = '/projects'
const PRIORITY_UPDATE_API_URL = '/project/priority'
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 [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState(null)
const [validationError, setValidationError] = useState(null)
const handleProjectClick = (projectName) => {
setNewProjectName(projectName)
@@ -37,16 +142,16 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
const handleSubmit = async () => {
if (!newProjectName.trim()) {
setError('Введите название проекта')
setValidationError('Введите название проекта')
return
}
setIsSubmitting(true)
setError(null)
setValidationError(null)
try {
const projectId = project.id ?? project.name
const response = await fetch(PROJECT_MOVE_API_URL, {
const response = await authFetch(PROJECT_MOVE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -63,7 +168,9 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
onSuccess()
} catch (err) {
console.error('Ошибка переноса проекта:', err)
setError(err.message || 'Ошибка при переносе проекта')
if (onError) {
onError(err.message || 'Ошибка при переносе проекта')
}
} finally {
setIsSubmitting(false)
}
@@ -110,8 +217,8 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
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"
/>
{error && (
<div className="mt-2 text-sm text-red-600">{error}</div>
{validationError && (
<div className="mt-2 text-sm text-red-600">{validationError}</div>
)}
</div>
@@ -173,7 +280,7 @@ function SortableProjectItem({ project, index, allProjects, onMenuClick }) {
<div
ref={setNodeRef}
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 ${
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 (
<div className="mb-6">
<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}
/>
))}
{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>
)
}
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
const { authFetch } = useAuth()
const [projectsLoading, setProjectsLoading] = useState(false)
const [projectsError, setProjectsError] = useState(null)
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
const [toastMessage, setToastMessage] = useState(null)
// Уведомляем родительский компонент об изменении состояния загрузки
useEffect(() => {
@@ -287,6 +404,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
const [activeId, setActiveId] = useState(null)
const [selectedProject, setSelectedProject] = useState(null) // Для модального окна
const [showMoveScreen, setShowMoveScreen] = useState(false) // Для экрана переноса
const [showAddScreen, setShowAddScreen] = useState(false) // Для экрана добавления
const scrollContainerRef = useRef(null)
@@ -298,7 +416,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 10, // Активация только после перемещения на 10px
distance: 15, // Увеличиваем расстояние для активации, чтобы дать больше времени для скролла
},
}),
useSensor(KeyboardSensor, {
@@ -381,7 +499,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
}
setProjectsError(null)
const response = await fetch(PROJECTS_API_URL)
const response = await authFetch(PROJECTS_API_URL)
if (!response.ok) {
throw new Error('Не удалось загрузить проекты')
}
@@ -483,7 +601,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
const sendPriorityChanges = useCallback(async (changes) => {
if (!changes.length) return
try {
await fetch(PRIORITY_UPDATE_API_URL, {
await authFetch(PRIORITY_UPDATE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
@@ -723,7 +841,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
try {
const projectId = selectedProject.id ?? selectedProject.name
const response = await fetch(`/project/delete`, {
const response = await authFetch(`/project/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: projectId }),
@@ -738,7 +856,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
fetchProjects()
} catch (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)
return (
<div className="max-w-4xl mx-auto">
<div className="max-w-2xl mx-auto flex flex-col h-full">
{onNavigate && (
<div className="flex justify-end mb-4">
<button
onClick={() => onNavigate('current')}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
className="close-x-button"
title="Закрыть"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
)}
{projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-sm">
<div className="font-semibold">Не удалось загрузить проекты</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
<span className="text-red-600">{projectsError}</span>
<button
onClick={() => fetchProjects()}
className="rounded-md bg-red-600 px-3 py-1 text-white shadow hover:bg-red-700 transition"
>
Повторить
</button>
</div>
</div>
<LoadingError onRetry={fetchProjects} />
)}
{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>
) : (
<DndContext
@@ -797,7 +902,10 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
onDragEnd={handleDragEnd}
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}>
<PrioritySlot
title="Максимальный приоритет (1 проект)"
@@ -827,6 +935,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
allProjects={allProjects}
onMenuClick={handleMenuClick}
containerId="low"
onAddClick={() => setShowAddScreen(true)}
/>
</SortableContext>
@@ -901,6 +1010,31 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
setSelectedProject(null)
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>

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

View 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

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

File diff suppressed because it is too large Load Diff

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

View 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

View File

@@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './Integrations.css'
function TelegramIntegration({ onBack }) {
const [botToken, setBotToken] = useState('')
const [chatId, setChatId] = useState('')
function TelegramIntegration({ onNavigate }) {
const { authFetch } = useAuth()
const [integration, setIntegration] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
fetchIntegration()
@@ -16,13 +16,12 @@ function TelegramIntegration({ onBack }) {
const fetchIntegration = async () => {
try {
setLoading(true)
const response = await fetch('/api/integrations/telegram')
const response = await authFetch('/api/integrations/telegram')
if (!response.ok) {
throw new Error('Ошибка при загрузке интеграции')
}
const data = await response.json()
setBotToken(data.bot_token || '')
setChatId(data.chat_id || '')
setIntegration(data)
} catch (error) {
console.error('Error fetching integration:', error)
setError('Не удалось загрузить данные интеграции')
@@ -31,140 +30,111 @@ function TelegramIntegration({ onBack }) {
}
}
const handleSave = async () => {
if (!botToken.trim()) {
setError('Bot Token обязателен для заполнения')
return
const handleOpenBot = () => {
if (integration?.deep_link) {
window.open(integration.deep_link, '_blank')
}
}
try {
setSaving(true)
setError('')
setSuccess('')
const response = await fetch('/api/integrations/telegram', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ bot_token: botToken }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Ошибка при сохранении')
const handleRefresh = () => {
fetchIntegration()
}
const data = await response.json()
setBotToken(data.bot_token || '')
setChatId(data.chat_id || '')
setSuccess('Bot Token успешно сохранен!')
} catch (error) {
console.error('Error saving integration:', error)
setError(error.message || 'Не удалось сохранить Bot Token')
} finally {
setSaving(false)
if (loading) {
return (
<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>
)
}
if (error && !integration) {
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
</button>
<LoadingError onRetry={fetchIntegration} />
</div>
)
}
return (
<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>
<h1 className="text-2xl font-bold mb-6">Telegram интеграция</h1>
{loading ? (
<div className="text-gray-500">Загрузка...</div>
) : (
<>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Настройки</h2>
<h2 className="text-lg font-semibold mb-4">Статус подключения</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Telegram Bot Token
</label>
<input
type="text"
value={botToken}
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"
/>
{integration?.is_connected ? (
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center text-green-700">
<span className="text-xl mr-2"></span>
<span className="font-medium">Telegram подключен</span>
</div>
</div>
{chatId && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
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}
{integration.telegram_user_id && (
<div className="text-sm text-gray-600">
Telegram ID: <span className="font-mono">{integration.telegram_user_id}</span>
</div>
)}
<button
onClick={handleSave}
disabled={saving || !botToken.trim()}
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"
onClick={handleOpenBot}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
{saving ? 'Сохранение...' : 'Сохранить Bot Token'}
Открыть бота
</button>
</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">
Откуда взять Bot Token
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Откройте Telegram и найдите бота @BotFather</li>
<li>Отправьте команду /newbot</li>
<li>Следуйте инструкциям для создания нового бота</li>
<li>
После создания бота BotFather предоставит вам Bot Token
</li>
<li>Скопируйте токен и вставьте его в поле выше</li>
</ol>
) : (
<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-yellow-50 border border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-yellow-900">
Что нужно сделать после сохранения Bot Token
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>После сохранения Bot Token отправьте первое сообщение вашему боту в Telegram</li>
<li>
Chat ID будет автоматически сохранен после обработки первого
сообщения
</li>
<li>
После этого бот сможет отправлять вам ответные сообщения
</li>
</ol>
<button
onClick={handleOpenBot}
disabled={!integration?.deep_link}
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"
>
Подключить Telegram
</button>
<button
onClick={handleRefresh}
className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Проверить подключение
</button>
</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>
)
}
export default TelegramIntegration

View File

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

View File

@@ -16,30 +16,6 @@
flex-direction: column;
}
.test-close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.test-close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.test-duration-selection {
text-align: center;
@@ -110,9 +86,13 @@
.test-progress {
margin-top: 1rem;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.progress-text {
.test-progress .progress-text {
font-size: 1.5rem;
color: #2c3e50;
font-weight: 500;
@@ -259,6 +239,7 @@
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
align-content: start;
overflow-y: auto;
overflow-x: hidden;
padding: 4rem 1rem 1rem 1rem;
@@ -347,7 +328,7 @@
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
align-items: start;
align-content: start;
overflow-y: auto;
overflow-x: hidden;
padding: 4rem 1rem 1rem 1rem;

View File

@@ -1,18 +1,23 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './TestWords.css'
import './Integrations.css'
const API_URL = '/api'
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 configId = initialConfigId || null
const maxCards = initialMaxCards || null
const taskId = initialTaskId || null
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
const [testWords, setTestWords] = useState([]) // Пул слов для показа
const [currentIndex, setCurrentIndex] = useState(0)
const [currentWord, setCurrentWord] = useState(null) // Текущее слово, которое показывается (уже удалено из пула)
const [flippedCards, setFlippedCards] = useState(new Set())
const [wordStats, setWordStats] = useState({}) // Локальная статистика
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
@@ -25,16 +30,191 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
const isFinishingRef = useRef(false)
const wordStatsRef = useRef({})
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(() => {
setWords([])
setTestWords([])
setCurrentIndex(0)
setCurrentWord(null)
setFlippedCards(new Set())
setWordStats({})
wordStatsRef.current = {}
setCardsShown(0)
cardsShownRef.current = 0 // Сбрасываем синхронный счётчик
setTotalAnswers(0)
setError('')
setShowPreview(false) // Сбрасываем экран предпросмотра
@@ -49,7 +229,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
throw new Error('config_id обязателен для запуска теста')
}
const url = `${API_URL}/test/words?config_id=${configId}`
const response = await fetch(url)
const response = await authFetch(url)
if (!response.ok) {
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))
// Создаем пул, где каждое слово повторяется n раз
const wordPool = []
let wordPool = []
for (let i = 0; i < n; i++) {
wordPool.push(...data)
}
// Перемешиваем пул случайным образом
for (let i = wordPool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[wordPool[i], wordPool[j]] = [wordPool[j], wordPool[i]]
}
// Равномерно распределяем слова в пуле
wordPool = redistributeWordsEvenly(wordPool, data)
setTestWords(wordPool)
setWordStats(stats)
@@ -109,16 +286,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
loadWords()
}, [wordCount, configId])
const getCurrentWord = () => {
if (currentIndex < testWords.length && testWords.length > 0) {
return testWords[currentIndex]
}
return null
}
// Правый счётчик: кол-во полученных ответов + кол-во слов в пуле (не больше maxCards)
// Правый счётчик: текущий размер пула + показанные карточки, но не больше maxCards
const getRightCounter = () => {
const total = totalAnswers + testWords.length
const total = testWords.length + cardsShown
if (maxCards !== null && maxCards > 0) {
return Math.min(total, maxCards)
}
@@ -126,7 +296,15 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
}
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
})
const response = await fetch(`${API_URL}/test/progress`, {
const response = await authFetch(`${API_URL}/test/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
@@ -189,40 +367,98 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
const responseData = await response.json().catch(() => ({}))
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) {
console.error('Failed to save progress:', err)
// Можно показать уведомление пользователю, но не блокируем показ результатов
}
}
// Проверка условий завершения и показ следующей карточки
const showNextCardOrFinish = (newTestWords, currentCardsShown) => {
// Проверяем, не завершился ли тест (на случай если finishTest уже был вызван)
if (isFinishingRef.current || showResults) {
// Берём карточку из пула (getAndDelete) и показываем её
const showNextCard = () => {
// Проверяем, не завершился ли тест
if (isFinishingRef.current) {
return
}
// Используем функциональное обновление для получения актуального состояния пула
setTestWords(prevPool => {
// Повторная проверка внутри callback (на случай если состояние изменилось)
if (isFinishingRef.current) {
return prevPool
}
// Используем ref для синхронного доступа к счётчику
const nextCardsShown = cardsShownRef.current + 1
// Условие 1: Достигли максимума карточек
if (maxCards !== null && maxCards > 0 && currentCardsShown >= maxCards) {
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
finishTest()
return
return prevPool
}
// Условие 2: Пул слов пуст
if (newTestWords.length === 0) {
if (prevPool.length === 0) {
finishTest()
return
return prevPool
}
// Показываем следующую карточку и увеличиваем левый счётчик
// Но сначала проверяем, не достигнем ли мы максимума после увеличения
const nextCardsShown = currentCardsShown + 1
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
finishTest()
return
}
// 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) => {
@@ -255,25 +491,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
const newTotalAnswers = totalAnswers + 1
setTotalAnswers(newTotalAnswers)
// Убираем только один экземпляр слова из пула (по текущему индексу)
const newTestWords = [...testWords]
newTestWords.splice(currentIndex, 1)
// Обновляем индекс: если удалили последний элемент, переходим к предыдущему
let newIndex = currentIndex
if (newTestWords.length === 0) {
newIndex = 0
} else if (currentIndex >= newTestWords.length) {
newIndex = newTestWords.length - 1
}
// Обновляем состояние
setTestWords(newTestWords)
setCurrentIndex(newIndex)
setFlippedCards(new Set())
// Проверяем условия завершения или показываем следующую карточку
showNextCardOrFinish(newTestWords, cardsShown)
// onSuccess: просто повторяем (showNextCard)
// Карточка уже удалена из пула при показе, просто показываем следующую
showNextCard()
// Если тест завершился, не сбрасываем processingRef
if (isFinishingRef.current) {
@@ -313,18 +533,20 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
const newTotalAnswers = totalAnswers + 1
setTotalAnswers(newTotalAnswers)
// Слово остаётся в пуле, переходим к следующему
let newIndex = currentIndex + 1
if (newIndex >= testWords.length) {
newIndex = 0
}
// onFailure: возвращаем карточку в пул, сортируем, повторяем
setTestWords(prevPool => {
// cards.add(currentCard): возвращаем слово обратно в пул
let newTestWords = [...prevPool, word]
setCurrentIndex(newIndex)
setFlippedCards(new Set())
// cards.sort(): равномерно перераспределяем слова в пуле
// Передаём wordId, чтобы текущая карточка не оказалась первой (следующей для показа)
newTestWords = redistributeWordsEvenly(newTestWords, words, wordId)
// Проверяем условия завершения или показываем следующую карточку
// При failure пул не изменяется
showNextCardOrFinish(testWords, cardsShown)
return newTestWords
})
// repeat(): показываем следующую карточку
showNextCard()
// Если тест завершился, не сбрасываем processingRef
if (isFinishingRef.current) {
@@ -335,17 +557,17 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
}
const handleClose = () => {
onNavigate?.('test-config')
onNavigate?.('tasks')
}
const handleStartTest = () => {
setShowPreview(false)
// Показываем первую карточку и увеличиваем левый счётчик
setCardsShown(1)
// Показываем первую карточку (берём из пула)
showNextCard()
}
const handleFinish = () => {
onNavigate?.('test-config')
onNavigate?.('tasks')
}
const getRandomSide = (word) => {
@@ -354,7 +576,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
return (
<div className="test-container test-container-fullscreen">
<button className="test-close-x-button" onClick={handleClose}>
<button className="close-x-button" onClick={handleClose}>
</button>
{showPreview ? (
@@ -416,13 +638,73 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
) : (
<div className="test-screen">
{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 && (
<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() && (() => {
const word = getCurrentWord()
{!loading && !error && !isFinishingRef.current && currentWord && (() => {
const word = currentWord
const isFlipped = flippedCards.has(word.id)
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 ${isFlipped ? 'flipped' : ''}`}
onClick={() => !isFlipped && handleCardFlip(word.id)}
onClick={() => handleCardFlip(word.id)}
>
<div className="test-card-front">
<div className="test-card-content">

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

View 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

View File

@@ -1,90 +1,210 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './Integrations.css'
function TodoistIntegration({ onBack }) {
const [webhookURL, setWebhookURL] = useState('')
function TodoistIntegration({ onNavigate }) {
const { authFetch } = useAuth()
const [connected, setConnected] = useState(false)
const [todoistEmail, setTodoistEmail] = useState('')
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(() => {
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 {
setLoading(true)
const response = await fetch('/api/integrations/todoist/webhook-url')
setError('')
const response = await authFetch('/api/integrations/todoist/status')
if (!response.ok) {
throw new Error('Ошибка при загрузке URL webhook')
throw new Error('Ошибка при проверке статуса')
}
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) {
console.error('Error fetching webhook URL:', error)
console.error('Error checking status:', error)
setError(error.message || 'Не удалось проверить статус')
setIsLoadingError(true)
} finally {
setLoading(false)
}
}
const copyToClipboard = async () => {
const handleConnect = async () => {
try {
await navigator.clipboard.writeText(webhookURL)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('Error copying to clipboard:', error)
setLoading(true)
setError('')
// Получаем 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) {
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 (
<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>
<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">
<h2 className="text-lg font-semibold mb-4">Webhook URL</h2>
{loading ? (
<div className="text-gray-500">Загрузка...</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>
) : connected ? (
<div>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Статус подключения</h2>
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="text"
value={webhookURL}
readOnly
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-sm"
/>
<button
onClick={copyToClipboard}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors whitespace-nowrap"
>
{copied ? 'Скопировано!' : 'Копировать'}
</button>
<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
onClick={handleConnect}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold"
>
Подключить Todoist
</button>
</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">
Как использовать в приложении TODOist
Что нужно сделать
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Откройте приложение TODOist на вашем устройстве</li>
<li>Перейдите в настройки проекта или задачи</li>
<li>Найдите раздел "Интеграции" или "Webhooks"</li>
<li>Вставьте скопированный URL webhook в соответствующее поле</li>
<li>Сохраните настройки</li>
<li>
Теперь при закрытии задач в TODOist они будут автоматически
обрабатываться системой
</li>
<li>Нажмите кнопку "Подключить Todoist"</li>
<li>Авторизуйтесь в Todoist</li>
<li>Готово! Закрытые задачи будут автоматически обрабатываться</li>
</ol>
</div>
</div>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default TodoistIntegration

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

View 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

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

View 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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,12 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './WordList.css'
const API_URL = '/api'
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
const { authFetch } = useAuth()
const [words, setWords] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -44,7 +47,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
const fetchDictionary = async (dictId) => {
try {
const response = await fetch(`${API_URL}/dictionaries`)
const response = await authFetch(`${API_URL}/dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
@@ -74,7 +77,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
try {
setLoading(true)
const url = `${API_URL}/words?dictionary_id=${dictId}`
const response = await fetch(url)
const response = await authFetch(url)
if (!response.ok) {
throw new Error('Ошибка при загрузке слов')
}
@@ -102,7 +105,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
try {
if (!hasValidDictionary(currentDictionaryId)) {
// Create new dictionary
const response = await fetch(`${API_URL}/dictionaries`, {
const response = await authFetch(`${API_URL}/dictionaries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -131,7 +134,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
onNavigate?.('words', { dictionaryId: newDictionaryId })
} else if (hasValidDictionary(currentDictionaryId)) {
// Update existing dictionary (rename)
const response = await fetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -161,7 +164,12 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
if (loading) {
return (
<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>
)
}
@@ -169,7 +177,11 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
if (error) {
return (
<div className="word-list">
<div className="error-message">{error}</div>
<LoadingError onRetry={() => {
if (hasValidDictionary(currentDictionaryId)) {
fetchWordsForDictionary(currentDictionaryId)
}
}} />
</div>
)
}
@@ -177,7 +189,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
return (
<div className="word-list">
<button
onClick={() => onNavigate?.('test-config')}
onClick={() => onNavigate?.('dictionaries')}
className="close-x-button"
title="Закрыть"
>

View 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

View 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

View 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

View 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

View File

@@ -6,13 +6,15 @@ html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
margin: 0;
padding: 0;
min-height: 100%;
min-height: 100dvh;
height: 100%;
height: 100dvh;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
@@ -22,8 +24,9 @@ body {
}
#root {
min-height: 100vh;
min-height: 100dvh; /* Dynamic viewport height для мобильных устройств */
height: 100vh;
height: 100dvh; /* Dynamic viewport height для мобильных устройств */
overflow: hidden;
background: #f3f4f6;
background-attachment: fixed;
display: flex;

View File

@@ -1,5 +1,6 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import { resolve } from 'path'
export default defineConfig(({ mode }) => {
@@ -12,7 +13,100 @@ export default defineConfig(({ mode }) => {
const env = { ...rootEnv, ...localEnv, ...process.env }
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: {
host: '0.0.0.0',
port: parseInt(env.VITE_PORT || '3000', 10),

View File

@@ -47,14 +47,32 @@ DB_USER=${DB_USER:-playeng}
DB_PASSWORD=${DB_PASSWORD:-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
echo "❌ Ошибка: Укажите имя дампа"
echo "Использование: ./restore-db.sh [--env-file FILE] [имя_дампа.sql.gz]"
echo ""
echo "Доступные дампы:"
ls -lh database-dumps/*.sql.gz 2>/dev/null | awk '{print " " $9}' | sed "s|database-dumps/||g" || echo " (нет дампов)"
DUMP_DIR="database-dumps"
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 ""
fi
# Определяем полный путь к файлу
@@ -86,7 +104,11 @@ if [ ! -f "$FULL_DUMP_PATH" ]; then
fi
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"
read -p " Продолжить? (yes/no): " confirm
@@ -97,21 +119,37 @@ fi
echo "🔄 Восстановление базы данных из дампа..."
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"
# Распаковываем, если сжат
# Распаковываем и модифицируем дамп
TEMP_DUMP="/tmp/restore_$$.sql"
if [[ "$FULL_DUMP_PATH" == *.gz ]]; then
echo " Распаковка дампа..."
gunzip -c "$FULL_DUMP_PATH" > "$TEMP_DUMP"
echo " Распаковка и модификация дампа..."
gunzip -c "$FULL_DUMP_PATH" | \
sed 's/n8n_user/'"$DB_USER"'/g' | \
sed '/^\\restrict/d' | \
sed '/^\\unrestrict/d' > "$TEMP_DUMP"
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
echo " Владелец таблиц в дампе заменён на: $DB_USER"
# Восстанавливаем через docker-compose, если контейнер запущен
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
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 "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
# Или напрямую через 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 "CREATE DATABASE $DB_NAME;"
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"
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
View 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"

View File

@@ -1,6 +1,7 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
user=root
@@ -17,8 +18,11 @@ command=/app/backend/main
directory=/app/backend
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/backend.err.log
stdout_logfile=/var/log/supervisor/backend.out.log
# Логи идут в stdout/stderr контейнера для docker logs
stderr_logfile=/dev/stderr
stdout_logfile=/dev/stdout
stderr_logfile_maxbytes=0
stdout_logfile_maxbytes=0
priority=20
# Переменные окружения будут переданы из docker run --env-file
# PORT по умолчанию 8080 внутри контейнера