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 (