From 60a6f4deb40e0f21aee8ce2c98ff8d3205ea5a0a Mon Sep 17 00:00:00 2001 From: Play Life Bot Date: Thu, 8 Jan 2026 00:02:06 +0300 Subject: [PATCH] feat: improved navigation and unified close buttons - version 3.5.0 --- VERSION | 2 +- play-life-web/package.json | 2 +- play-life-web/src/App.jsx | 295 +++++++++++++++++- .../src/components/FullStatistics.jsx | 20 +- play-life-web/src/components/Profile.jsx | 19 +- .../src/components/ProjectPriorityManager.jsx | 20 +- play-life-web/src/components/TaskForm.css | 26 +- .../src/components/TelegramIntegration.jsx | 4 +- play-life-web/src/components/TestWords.css | 24 -- play-life-web/src/components/TestWords.jsx | 3 +- .../src/components/TodoistIntegration.jsx | 4 +- 11 files changed, 326 insertions(+), 93 deletions(-) diff --git a/VERSION b/VERSION index 4d9d11c..1545d96 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.4.2 +3.5.0 diff --git a/play-life-web/package.json b/play-life-web/package.json index d439c7a..923025a 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.3.1", + "version": "3.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 23641c4..4a72e03 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -10,6 +10,8 @@ import TestWords from './components/TestWords' import Profile from './components/Profile' import TaskList from './components/TaskList' import TaskForm from './components/TaskForm.jsx' +import TodoistIntegration from './components/TodoistIntegration' +import TelegramIntegration from './components/TelegramIntegration' import { AuthProvider, useAuth } from './components/auth/AuthContext' import AuthScreen from './components/auth/AuthScreen' @@ -17,6 +19,10 @@ import AuthScreen from './components/auth/AuthScreen' const CURRENT_WEEK_API_URL = '/playlife-feed' const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' +// Определяем основные табы (без крестика) и глубокие табы (с крестиком) +const mainTabs = ['current', 'test-config', 'tasks', 'profile'] +const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities'] + function AppContent() { const { authFetch, isAuthenticated, loading: authLoading } = useAuth() @@ -47,6 +53,8 @@ function AppContent() { tasks: false, 'task-form': false, profile: false, + 'todoist-integration': false, + 'telegram-integration': false, }) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) @@ -62,6 +70,8 @@ function AppContent() { tasks: false, 'task-form': false, profile: false, + 'todoist-integration': false, + 'telegram-integration': false, }) // Параметры для навигации между вкладками @@ -98,21 +108,59 @@ function AppContent() { // Восстанавливаем последний выбранный таб после перезагрузки const [isInitialized, setIsInitialized] = useState(false) + // Инициализация из URL (только для глубоких табов) или localStorage useEffect(() => { if (isInitialized) return try { - const savedTab = window.localStorage?.getItem('activeTab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile'] - if (savedTab && validTabs.includes(savedTab)) { - setActiveTab(savedTab) - setLoadedTabs(prev => ({ ...prev, [savedTab]: true })) - setIsInitialized(true) + // Проверяем URL только для глубоких табов + const urlParams = new URLSearchParams(window.location.search) + const tabFromUrl = urlParams.get('tab') + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', '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 { - setIsInitialized(true) + // Если в URL нет глубокого таба, проверяем localStorage для основного таба + const savedTab = window.localStorage?.getItem('activeTab') + if (savedTab && validTabs.includes(savedTab)) { + setActiveTab(savedTab) + setLoadedTabs(prev => ({ ...prev, [savedTab]: true })) + } + // Очищаем URL от параметров таба, если это основной таб + if (tabFromUrl && mainTabs.includes(tabFromUrl)) { + const url = new URL(window.location) + url.searchParams.delete('tab') + url.searchParams.forEach((value, key) => { + url.searchParams.delete(key) + }) + window.history.replaceState({}, '', url) + } } + setIsInitialized(true) } catch (err) { - console.warn('Не удалось прочитать активный таб из localStorage', err) + console.warn('Не удалось прочитать активный таб', err) setIsInitialized(true) } }, [isInitialized]) @@ -121,6 +169,91 @@ function AppContent() { 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) { @@ -240,6 +373,8 @@ function AppContent() { tasks: false, 'task-form': false, profile: false, + 'todoist-integration': false, + 'telegram-integration': false, }) // Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback) @@ -350,6 +485,82 @@ function AppContent() { setIsRefreshing(false) }, [fetchCurrentWeekData, fetchFullStatisticsData]) + // Обработчик кнопки "назад" в браузере (только для глубоких табов) + useEffect(() => { + const handlePopState = (event) => { + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', '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 = () => { @@ -371,6 +582,8 @@ function AppContent() { const handleProjectClick = (projectName) => { setSelectedProject(projectName) markTabAsLoaded('full') + setTabParams({ selectedProject: projectName }) + updateUrl('full', { selectedProject: projectName }, activeTab) setActiveTab('full') } @@ -378,20 +591,51 @@ function AppContent() { if (tab === 'full' && activeTab === 'full') { // При повторном клике на "Полная статистика" сбрасываем выбранный проект setSelectedProject(null) + setTabParams({}) + updateUrl('full', {}, activeTab) } else if (tab !== activeTab || tab === 'task-form') { // Для task-form всегда обновляем параметры, даже если это тот же таб markTabAsLoaded(tab) + + // Определяем, является ли текущий таб глубоким + const isCurrentTabDeep = deepTabs.includes(activeTab) + const isNewTabDeep = deepTabs.includes(tab) + const isCurrentTabMain = mainTabs.includes(activeTab) + const isNewTabMain = mainTabs.includes(tab) + // Сбрасываем tabParams при переходе с add-config на другой таб if (activeTab === 'add-config' && tab !== 'add-config') { setTabParams({}) + if (isNewTabMain) { + clearUrl() + } else if (isNewTabDeep) { + updateUrl(tab, {}, activeTab) + } } else { // Для task-form явно удаляем taskId, если он undefined if (tab === 'task-form' && params.taskId === undefined) { 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, true) + } } } + setActiveTab(tab) if (tab === 'current') { setSelectedProject(null) @@ -457,12 +701,25 @@ function AppContent() { }, [activeTab]) // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) - const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' + const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' + + // Определяем отступы для контейнера + const getContainerPadding = () => { + if (!isFullscreenTab) { + return 'p-4 md:p-6' + } + // Для экрана статистики добавляем горизонтальные отступы + if (activeTab === 'full') { + return 'px-4 md:px-6 py-0' + } + // Для остальных fullscreen экранов без отступов + return 'p-0' + } return (
-
+
{loadedTabs.current && (
setSelectedProject(null)} + onClearSelection={() => { + setSelectedProject(null) + setTabParams({}) + replaceUrl('full', {}) + }} data={fullStatisticsData} loading={fullStatisticsLoading} error={fullStatisticsError} @@ -584,6 +845,18 @@ function AppContent() {
)} + + {loadedTabs['todoist-integration'] && ( +
+ +
+ )} + + {loadedTabs['telegram-integration'] && ( +
+ +
+ )}
diff --git a/play-life-web/src/components/FullStatistics.jsx b/play-life-web/src/components/FullStatistics.jsx index 9063966..b10ca8a 100644 --- a/play-life-web/src/components/FullStatistics.jsx +++ b/play-life-web/src/components/FullStatistics.jsx @@ -13,6 +13,7 @@ import { import { Line } from 'react-chartjs-2' import WeekProgressChart from './WeekProgressChart' import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils' +import './Integrations.css' // Экспортируем для обратной совместимости (если используется в других местах) export { getProjectColorByIndex } from '../utils/projectUtils' @@ -216,18 +217,13 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro return (
{onNavigate && ( -
- -
+ )}
diff --git a/play-life-web/src/components/Profile.jsx b/play-life-web/src/components/Profile.jsx index 39f5698..a41ae5c 100644 --- a/play-life-web/src/components/Profile.jsx +++ b/play-life-web/src/components/Profile.jsx @@ -1,15 +1,12 @@ -import React, { useState } from 'react' +import React from 'react' import { useAuth } from './auth/AuthContext' -import TodoistIntegration from './TodoistIntegration' -import TelegramIntegration from './TelegramIntegration' function Profile({ onNavigate }) { const { user, logout } = useAuth() - const [selectedIntegration, setSelectedIntegration] = useState(null) const integrations = [ - { id: 'todoist', name: 'TODOist' }, - { id: 'telegram', name: 'Telegram' }, + { id: 'todoist-integration', name: 'TODOist' }, + { id: 'telegram-integration', name: 'Telegram' }, ] const handleLogout = async () => { @@ -18,14 +15,6 @@ function Profile({ onNavigate }) { } } - if (selectedIntegration) { - if (selectedIntegration === 'todoist') { - return setSelectedIntegration(null)} /> - } else if (selectedIntegration === 'telegram') { - return setSelectedIntegration(null)} /> - } - } - return (
{/* Profile Header */} @@ -52,7 +41,7 @@ function Profile({ onNavigate }) { {integrations.map((integration) => ( -
+ )} {projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
diff --git a/play-life-web/src/components/TaskForm.css b/play-life-web/src/components/TaskForm.css index 2b3a3dc..15cb122 100644 --- a/play-life-web/src/components/TaskForm.css +++ b/play-life-web/src/components/TaskForm.css @@ -6,26 +6,28 @@ } .close-x-button { - position: absolute; + position: fixed; top: 1rem; right: 1rem; - background: #f3f4f6; - border: 1px solid #e5e7eb; - border-radius: 50%; - width: 2rem; - height: 2rem; + 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; - cursor: pointer; - font-size: 1.25rem; - color: #6b7280; - transition: all 0.2s; + 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: #e5e7eb; - color: #1f2937; + background-color: #ffffff; + color: #2c3e50; } .task-form h2 { diff --git a/play-life-web/src/components/TelegramIntegration.jsx b/play-life-web/src/components/TelegramIntegration.jsx index f4f7537..63591d6 100644 --- a/play-life-web/src/components/TelegramIntegration.jsx +++ b/play-life-web/src/components/TelegramIntegration.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react' import { useAuth } from './auth/AuthContext' import './Integrations.css' -function TelegramIntegration({ onBack }) { +function TelegramIntegration({ onNavigate }) { const { authFetch } = useAuth() const [integration, setIntegration] = useState(null) const [loading, setLoading] = useState(true) @@ -49,7 +49,7 @@ function TelegramIntegration({ onBack }) { return (
- diff --git a/play-life-web/src/components/TestWords.css b/play-life-web/src/components/TestWords.css index 4836452..27eeb03 100644 --- a/play-life-web/src/components/TestWords.css +++ b/play-life-web/src/components/TestWords.css @@ -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; diff --git a/play-life-web/src/components/TestWords.jsx b/play-life-web/src/components/TestWords.jsx index ca51902..264fb54 100644 --- a/play-life-web/src/components/TestWords.jsx +++ b/play-life-web/src/components/TestWords.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react' import { useAuth } from './auth/AuthContext' import './TestWords.css' +import './Integrations.css' const API_URL = '/api' @@ -436,7 +437,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC return (
- {showPreview ? ( diff --git a/play-life-web/src/components/TodoistIntegration.jsx b/play-life-web/src/components/TodoistIntegration.jsx index 77985b1..d40f906 100644 --- a/play-life-web/src/components/TodoistIntegration.jsx +++ b/play-life-web/src/components/TodoistIntegration.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react' import { useAuth } from './auth/AuthContext' import './Integrations.css' -function TodoistIntegration({ onBack }) { +function TodoistIntegration({ onNavigate }) { const { authFetch } = useAuth() const [connected, setConnected] = useState(false) const [todoistEmail, setTodoistEmail] = useState('') @@ -102,7 +102,7 @@ function TodoistIntegration({ onBack }) { return (
-