1575 lines
68 KiB
React
1575 lines
68 KiB
React
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
|||
|
|
import CurrentWeek from './components/CurrentWeek'
|
|||
|
|
import FullStatistics from './components/FullStatistics'
|
|||
|
|
import ProjectPriorityManager from './components/ProjectPriorityManager'
|
|||
|
|
import WordList from './components/WordList'
|
|||
|
|
import AddWords from './components/AddWords'
|
|||
|
|
import DictionaryList from './components/DictionaryList'
|
|||
|
|
import TestWords from './components/TestWords'
|
|||
|
|
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 FitbitIntegration from './components/FitbitIntegration'
|
|||
|
|
import Tracking from './components/Tracking'
|
|||
|
|
import TrackingAccess from './components/TrackingAccess'
|
|||
|
|
import TrackingInviteAccept from './components/TrackingInviteAccept'
|
|||
|
|
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'
|
|||
|
|
|
|||
|
|
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
|||
|
|
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', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite']
|
|||
|
|
|
|||
|
|
function AppContent() {
|
|||
|
|
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
|||
|
|
const prevIsAuthenticatedRef = useRef(null)
|
|||
|
|
|
|||
|
|
// Все хуки должны быть объявлены до условных возвратов
|
|||
|
|
const [activeTab, setActiveTab] = useState('current')
|
|||
|
|
const [selectedProject, setSelectedProject] = useState(null)
|
|||
|
|
const [loadedTabs, setLoadedTabs] = useState({
|
|||
|
|
current: false,
|
|||
|
|
priorities: false,
|
|||
|
|
full: false,
|
|||
|
|
words: false,
|
|||
|
|
'add-words': false,
|
|||
|
|
dictionaries: false,
|
|||
|
|
test: 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,
|
|||
|
|
'fitbit-integration': false,
|
|||
|
|
tracking: false,
|
|||
|
|
'tracking-access': false,
|
|||
|
|
'tracking-invite': false,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
|||
|
|
const [tabsInitialized, setTabsInitialized] = useState({
|
|||
|
|
current: false,
|
|||
|
|
priorities: false,
|
|||
|
|
full: false,
|
|||
|
|
words: false,
|
|||
|
|
'add-words': false,
|
|||
|
|
dictionaries: false,
|
|||
|
|
test: 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,
|
|||
|
|
'fitbit-integration': false,
|
|||
|
|
tracking: false,
|
|||
|
|
'tracking-access': false,
|
|||
|
|
'tracking-invite': false,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Параметры для навигации между вкладками
|
|||
|
|
const [tabParams, setTabParams] = useState({})
|
|||
|
|
|
|||
|
|
// Предыдущий таб для возврата из модальных окон
|
|||
|
|
const [previousTab, setPreviousTab] = useState(null)
|
|||
|
|
|
|||
|
|
// Модальное окно выбора типа задачи
|
|||
|
|
const [showAddModal, setShowAddModal] = useState(false)
|
|||
|
|
|
|||
|
|
// Ref для функции открытия модала добавления записи в CurrentWeek
|
|||
|
|
const currentWeekAddModalRef = useRef(null)
|
|||
|
|
|
|||
|
|
// Кеширование данных
|
|||
|
|
const [currentWeekData, setCurrentWeekData] = useState(null)
|
|||
|
|
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
|||
|
|
const [tasksData, setTasksData] = useState(null)
|
|||
|
|
const [todayEntriesData, setTodayEntriesData] = useState(null)
|
|||
|
|
|
|||
|
|
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
|
|||
|
|
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
|
|||
|
|
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
|
|||
|
|
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
|
|||
|
|
const [tasksLoading, setTasksLoading] = useState(false)
|
|||
|
|
const [todayEntriesLoading, setTodayEntriesLoading] = useState(false)
|
|||
|
|
|
|||
|
|
// Состояния фоновой загрузки (не показываются визуально)
|
|||
|
|
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
|
|||
|
|
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
|
|||
|
|
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
|
|||
|
|
const [tasksBackgroundLoading, setTasksBackgroundLoading] = useState(false)
|
|||
|
|
const [todayEntriesBackgroundLoading, setTodayEntriesBackgroundLoading] = useState(false)
|
|||
|
|
|
|||
|
|
// Ошибки
|
|||
|
|
const [currentWeekError, setCurrentWeekError] = useState(null)
|
|||
|
|
const [fullStatisticsError, setFullStatisticsError] = useState(null)
|
|||
|
|
const [prioritiesError, setPrioritiesError] = useState(null)
|
|||
|
|
const [tasksError, setTasksError] = useState(null)
|
|||
|
|
const [todayEntriesError, setTodayEntriesError] = useState(null)
|
|||
|
|
|
|||
|
|
// Состояние для кнопки Refresh (если она есть)
|
|||
|
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
|||
|
|
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
|||
|
|
const [dictionariesRefreshTrigger, setDictionariesRefreshTrigger] = useState(0)
|
|||
|
|
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
|||
|
|
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Восстанавливаем последний выбранный таб после перезагрузки
|
|||
|
|
const [isInitialized, setIsInitialized] = useState(false)
|
|||
|
|
|
|||
|
|
// Переключение на экран прогрессии после успешной авторизации
|
|||
|
|
useEffect(() => {
|
|||
|
|
// Обновляем ref только после того, как authLoading стал false
|
|||
|
|
if (!authLoading) {
|
|||
|
|
const wasNotAuthenticated = prevIsAuthenticatedRef.current === false
|
|||
|
|
prevIsAuthenticatedRef.current = isAuthenticated
|
|||
|
|
|
|||
|
|
// Проверяем, что это новая авторизация (переход с false на true)
|
|||
|
|
// и что инициализация уже завершена (чтобы не конфликтовать с восстановлением из URL/localStorage)
|
|||
|
|
if (wasNotAuthenticated && isAuthenticated && isInitialized) {
|
|||
|
|
// Переключаемся на экран прогресса только если нет таба в URL
|
|||
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|||
|
|
const tabFromUrl = urlParams.get('tab')
|
|||
|
|
|
|||
|
|
// Если в URL нет таба, переключаемся на current (экран прогресса)
|
|||
|
|
if (!tabFromUrl) {
|
|||
|
|
setActiveTab('current')
|
|||
|
|
setLoadedTabs(prev => ({ ...prev, current: true }))
|
|||
|
|
// Очищаем URL, так как current - это основной таб
|
|||
|
|
const url = new URL(window.location)
|
|||
|
|
url.searchParams.delete('tab')
|
|||
|
|
url.searchParams.forEach((value, key) => {
|
|||
|
|
url.searchParams.delete(key)
|
|||
|
|
})
|
|||
|
|
window.history.replaceState({ tab: 'current' }, '', url)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, [isAuthenticated, isInitialized, authLoading])
|
|||
|
|
|
|||
|
|
// Инициализация из 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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Проверяем путь /tracking/invite/:token
|
|||
|
|
if (path.startsWith('/tracking/invite/')) {
|
|||
|
|
const token = path.replace('/tracking/invite/', '')
|
|||
|
|
if (token) {
|
|||
|
|
setActiveTab('tracking-invite')
|
|||
|
|
setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true }))
|
|||
|
|
setTabParams({ inviteToken: token })
|
|||
|
|
setIsInitialized(true)
|
|||
|
|
window.history.replaceState({}, '', '/?tab=tracking-invite&inviteToken=' + token)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Проверяем параметры OAuth callback от Fitbit
|
|||
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|||
|
|
const integration = urlParams.get('integration')
|
|||
|
|
if (integration === 'fitbit') {
|
|||
|
|
setActiveTab('fitbit-integration')
|
|||
|
|
setLoadedTabs(prev => ({ ...prev, 'fitbit-integration': true }))
|
|||
|
|
setIsInitialized(true)
|
|||
|
|
// Перезаписываем URL с tab параметром и сохраняем integration/status для компонента
|
|||
|
|
const status = urlParams.get('status')
|
|||
|
|
const message = urlParams.get('message')
|
|||
|
|
let newUrl = '/?tab=fitbit-integration&integration=fitbit'
|
|||
|
|
if (status) newUrl += `&status=${status}`
|
|||
|
|
if (message) newUrl += `&message=${message}`
|
|||
|
|
window.history.replaceState({}, '', newUrl)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Проверяем URL только для глубоких табов
|
|||
|
|
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', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite']
|
|||
|
|
|
|||
|
|
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
|||
|
|
// Если в URL есть глубокий таб, восстанавливаем его
|
|||
|
|
setActiveTab(tabFromUrl)
|
|||
|
|
setLoadedTabs(prev => ({ ...prev, [tabFromUrl]: true }))
|
|||
|
|
|
|||
|
|
// Восстанавливаем параметры из URL
|
|||
|
|
const params = {}
|
|||
|
|
urlParams.forEach((value, key) => {
|
|||
|
|
if (key !== 'tab') {
|
|||
|
|
try {
|
|||
|
|
params[key] = JSON.parse(value)
|
|||
|
|
} catch {
|
|||
|
|
params[key] = value
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
if (Object.keys(params).length > 0) {
|
|||
|
|
setTabParams(params)
|
|||
|
|
// Если это экран full с selectedProject, восстанавливаем его
|
|||
|
|
if (tabFromUrl === 'full' && params.selectedProject) {
|
|||
|
|
setSelectedProject(params.selectedProject)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Если в URL нет глубокого таба, проверяем localStorage для основного таба
|
|||
|
|
const savedTab = window.localStorage?.getItem('activeTab')
|
|||
|
|
if (savedTab && validTabs.includes(savedTab)) {
|
|||
|
|
setActiveTab(savedTab)
|
|||
|
|
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
|
|||
|
|
}
|
|||
|
|
// Очищаем URL от параметров таба, если это основной таб
|
|||
|
|
if (tabFromUrl && mainTabs.includes(tabFromUrl)) {
|
|||
|
|
const url = new URL(window.location)
|
|||
|
|
url.searchParams.delete('tab')
|
|||
|
|
url.searchParams.forEach((value, key) => {
|
|||
|
|
url.searchParams.delete(key)
|
|||
|
|
})
|
|||
|
|
window.history.replaceState({}, '', url)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setIsInitialized(true)
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('Не удалось прочитать активный таб', err)
|
|||
|
|
setIsInitialized(true)
|
|||
|
|
}
|
|||
|
|
}, [isInitialized])
|
|||
|
|
|
|||
|
|
const markTabAsLoaded = useCallback((tab) => {
|
|||
|
|
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) {
|
|||
|
|
setCurrentWeekBackgroundLoading(true)
|
|||
|
|
} else {
|
|||
|
|
setCurrentWeekLoading(true)
|
|||
|
|
}
|
|||
|
|
setCurrentWeekError(null)
|
|||
|
|
const response = await authFetch(CURRENT_WEEK_API_URL)
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error('Ошибка загрузки данных')
|
|||
|
|
}
|
|||
|
|
const jsonData = await response.json()
|
|||
|
|
// Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}]
|
|||
|
|
let projects = []
|
|||
|
|
let total = null
|
|||
|
|
let groupProgress1 = null
|
|||
|
|
let groupProgress2 = null
|
|||
|
|
let groupProgress0 = null
|
|||
|
|
|
|||
|
|
if (Array.isArray(jsonData) && jsonData.length > 0) {
|
|||
|
|
// Если ответ - массив, проверяем первый элемент
|
|||
|
|
const firstItem = jsonData[0]
|
|||
|
|
if (firstItem && typeof firstItem === 'object') {
|
|||
|
|
// Если первый элемент - объект с полями total и projects
|
|||
|
|
if (firstItem.projects && Array.isArray(firstItem.projects)) {
|
|||
|
|
projects = firstItem.projects
|
|||
|
|
total = firstItem.total !== undefined ? firstItem.total : null
|
|||
|
|
groupProgress1 = firstItem.group_progress_1 !== undefined ? firstItem.group_progress_1 : null
|
|||
|
|
groupProgress2 = firstItem.group_progress_2 !== undefined ? firstItem.group_progress_2 : null
|
|||
|
|
groupProgress0 = firstItem.group_progress_0 !== undefined ? firstItem.group_progress_0 : null
|
|||
|
|
} else {
|
|||
|
|
// Если это просто массив проектов
|
|||
|
|
projects = jsonData
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Если это массив проектов напрямую
|
|||
|
|
projects = jsonData
|
|||
|
|
}
|
|||
|
|
} else if (jsonData && typeof jsonData === 'object' && !Array.isArray(jsonData)) {
|
|||
|
|
// Если ответ - объект напрямую
|
|||
|
|
projects = jsonData.projects || jsonData.data || []
|
|||
|
|
total = jsonData.total !== undefined ? jsonData.total : null
|
|||
|
|
groupProgress1 = jsonData.group_progress_1 !== undefined ? jsonData.group_progress_1 : null
|
|||
|
|
groupProgress2 = jsonData.group_progress_2 !== undefined ? jsonData.group_progress_2 : null
|
|||
|
|
groupProgress0 = jsonData.group_progress_0 !== undefined ? jsonData.group_progress_0 : null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setCurrentWeekData({
|
|||
|
|
projects: Array.isArray(projects) ? projects : [],
|
|||
|
|
total: total,
|
|||
|
|
group_progress_1: groupProgress1,
|
|||
|
|
group_progress_2: groupProgress2,
|
|||
|
|
group_progress_0: groupProgress0
|
|||
|
|
})
|
|||
|
|
} catch (err) {
|
|||
|
|
setCurrentWeekError(err.message)
|
|||
|
|
console.error('Ошибка загрузки данных текущей недели:', err)
|
|||
|
|
} finally {
|
|||
|
|
if (isBackground) {
|
|||
|
|
setCurrentWeekBackgroundLoading(false)
|
|||
|
|
} else {
|
|||
|
|
setCurrentWeekLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, [authFetch])
|
|||
|
|
|
|||
|
|
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
|
|||
|
|
try {
|
|||
|
|
if (isBackground) {
|
|||
|
|
setFullStatisticsBackgroundLoading(true)
|
|||
|
|
} else {
|
|||
|
|
setFullStatisticsLoading(true)
|
|||
|
|
}
|
|||
|
|
setFullStatisticsError(null)
|
|||
|
|
const response = await authFetch(FULL_STATISTICS_API_URL)
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error('Ошибка загрузки данных')
|
|||
|
|
}
|
|||
|
|
const jsonData = await response.json()
|
|||
|
|
setFullStatisticsData(jsonData)
|
|||
|
|
} catch (err) {
|
|||
|
|
setFullStatisticsError(err.message)
|
|||
|
|
console.error('Ошибка загрузки данных полной статистики:', err)
|
|||
|
|
} finally {
|
|||
|
|
if (isBackground) {
|
|||
|
|
setFullStatisticsBackgroundLoading(false)
|
|||
|
|
} else {
|
|||
|
|
setFullStatisticsLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, [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])
|
|||
|
|
|
|||
|
|
const fetchTodayEntries = useCallback(async (isBackground = false, projectName = null, date = null) => {
|
|||
|
|
try {
|
|||
|
|
if (isBackground) {
|
|||
|
|
setTodayEntriesBackgroundLoading(true)
|
|||
|
|
} else {
|
|||
|
|
setTodayEntriesLoading(true)
|
|||
|
|
}
|
|||
|
|
setTodayEntriesError(null)
|
|||
|
|
|
|||
|
|
// Формируем URL с опциональными параметрами project и date
|
|||
|
|
let url = '/api/today-entries'
|
|||
|
|
const params = []
|
|||
|
|
if (projectName) {
|
|||
|
|
params.push(`project=${encodeURIComponent(projectName)}`)
|
|||
|
|
}
|
|||
|
|
if (date) {
|
|||
|
|
params.push(`date=${encodeURIComponent(date)}`)
|
|||
|
|
}
|
|||
|
|
if (params.length > 0) {
|
|||
|
|
url += `?${params.join('&')}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await authFetch(url)
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error('Ошибка загрузки данных')
|
|||
|
|
}
|
|||
|
|
const jsonData = await response.json()
|
|||
|
|
setTodayEntriesData(Array.isArray(jsonData) ? jsonData : [])
|
|||
|
|
} catch (err) {
|
|||
|
|
setTodayEntriesError(err.message || 'Ошибка загрузки данных')
|
|||
|
|
console.error('Ошибка загрузки today entries:', err)
|
|||
|
|
} finally {
|
|||
|
|
if (isBackground) {
|
|||
|
|
setTodayEntriesBackgroundLoading(false)
|
|||
|
|
} else {
|
|||
|
|
setTodayEntriesLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, [authFetch])
|
|||
|
|
|
|||
|
|
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
|||
|
|
const tabsInitializedRef = useRef({
|
|||
|
|
current: false,
|
|||
|
|
priorities: false,
|
|||
|
|
full: false,
|
|||
|
|
words: false,
|
|||
|
|
'add-words': false,
|
|||
|
|
dictionaries: false,
|
|||
|
|
test: false,
|
|||
|
|
tasks: false,
|
|||
|
|
'task-form': false,
|
|||
|
|
profile: false,
|
|||
|
|
'todoist-integration': false,
|
|||
|
|
'telegram-integration': false,
|
|||
|
|
'fitbit-integration': false,
|
|||
|
|
tracking: false,
|
|||
|
|
'tracking-access': false,
|
|||
|
|
'tracking-invite': false,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
|
|||
|
|
const cacheRef = useRef({
|
|||
|
|
current: null,
|
|||
|
|
full: null,
|
|||
|
|
tasks: null,
|
|||
|
|
todayEntries: null,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Refs для отслеживания активного таба
|
|||
|
|
const prevActiveTabRef = useRef(null)
|
|||
|
|
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
|
|||
|
|
|
|||
|
|
// Обновляем ref при изменении данных
|
|||
|
|
useEffect(() => {
|
|||
|
|
cacheRef.current.current = currentWeekData
|
|||
|
|
}, [currentWeekData])
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
cacheRef.current.full = fullStatisticsData
|
|||
|
|
}, [fullStatisticsData])
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
cacheRef.current.tasks = tasksData
|
|||
|
|
}, [tasksData])
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
cacheRef.current.todayEntries = todayEntriesData
|
|||
|
|
}, [todayEntriesData])
|
|||
|
|
|
|||
|
|
// Функция для загрузки данных таба
|
|||
|
|
const loadTabData = useCallback((tab, isBackground = false, projectName = null) => {
|
|||
|
|
if (tab === 'current') {
|
|||
|
|
const hasCache = cacheRef.current.current !== null
|
|||
|
|
const isInitialized = tabsInitializedRef.current.current
|
|||
|
|
|
|||
|
|
if (!isInitialized) {
|
|||
|
|
// Первая загрузка таба - загружаем с индикатором
|
|||
|
|
fetchCurrentWeekData(false)
|
|||
|
|
tabsInitializedRef.current.current = true
|
|||
|
|
setTabsInitialized(prev => ({ ...prev, current: true }))
|
|||
|
|
} else if (hasCache && isBackground) {
|
|||
|
|
// Возврат на таб с кешем - фоновая загрузка
|
|||
|
|
fetchCurrentWeekData(true)
|
|||
|
|
}
|
|||
|
|
// Если нет кеша и это не первая загрузка - ничего не делаем (данные уже загружаются)
|
|||
|
|
} else if (tab === 'full') {
|
|||
|
|
const hasCache = cacheRef.current.full !== null
|
|||
|
|
const hasCurrentWeekCache = cacheRef.current.current !== null
|
|||
|
|
const isInitialized = tabsInitializedRef.current.full
|
|||
|
|
|
|||
|
|
if (!isInitialized) {
|
|||
|
|
// Первая загрузка таба
|
|||
|
|
if (hasCache) {
|
|||
|
|
// Если есть кеш - используем фоновую загрузку, показываем старые данные
|
|||
|
|
fetchFullStatisticsData(true)
|
|||
|
|
} else {
|
|||
|
|
// Если кеша нет - загружаем с индикатором
|
|||
|
|
fetchFullStatisticsData(false)
|
|||
|
|
}
|
|||
|
|
// Также запускаем фоновую загрузку currentWeekData, если его нет
|
|||
|
|
if (!hasCurrentWeekCache) {
|
|||
|
|
fetchCurrentWeekData(true)
|
|||
|
|
}
|
|||
|
|
// todayEntries будет загружен в FullStatistics компоненте при выборе дня
|
|||
|
|
tabsInitializedRef.current.full = true
|
|||
|
|
setTabsInitialized(prev => ({ ...prev, full: true }))
|
|||
|
|
} else if (hasCache && isBackground) {
|
|||
|
|
// Возврат на таб с кешем - фоновая загрузка
|
|||
|
|
fetchFullStatisticsData(true)
|
|||
|
|
// Также запускаем фоновую загрузку currentWeekData, если его нет
|
|||
|
|
if (!hasCurrentWeekCache) {
|
|||
|
|
fetchCurrentWeekData(true)
|
|||
|
|
}
|
|||
|
|
// todayEntries будет загружен в FullStatistics компоненте при выборе дня
|
|||
|
|
}
|
|||
|
|
} else if (tab === 'priorities') {
|
|||
|
|
const isInitialized = tabsInitializedRef.current.priorities
|
|||
|
|
|
|||
|
|
if (!isInitialized) {
|
|||
|
|
// Первая загрузка таба
|
|||
|
|
setPrioritiesRefreshTrigger(prev => prev + 1)
|
|||
|
|
tabsInitializedRef.current.priorities = true
|
|||
|
|
setTabsInitialized(prev => ({ ...prev, priorities: true }))
|
|||
|
|
} else if (isBackground) {
|
|||
|
|
// Возврат на таб - фоновая загрузка
|
|||
|
|
setPrioritiesRefreshTrigger(prev => prev + 1)
|
|||
|
|
}
|
|||
|
|
} else if (tab === 'dictionaries') {
|
|||
|
|
const isInitialized = tabsInitializedRef.current['dictionaries']
|
|||
|
|
|
|||
|
|
if (!isInitialized) {
|
|||
|
|
// Первая загрузка таба
|
|||
|
|
setDictionariesRefreshTrigger(prev => prev + 1)
|
|||
|
|
tabsInitializedRef.current['dictionaries'] = true
|
|||
|
|
setTabsInitialized(prev => ({ ...prev, 'dictionaries': true }))
|
|||
|
|
} else if (isBackground) {
|
|||
|
|
// Возврат на таб - фоновая загрузка
|
|||
|
|
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, fetchTasksData, fetchTodayEntries])
|
|||
|
|
|
|||
|
|
// Функция для обновления всех данных (для кнопки Refresh, если она есть)
|
|||
|
|
const refreshAllData = useCallback(async () => {
|
|||
|
|
setIsRefreshing(true)
|
|||
|
|
setPrioritiesError(null)
|
|||
|
|
setCurrentWeekError(null)
|
|||
|
|
setFullStatisticsError(null)
|
|||
|
|
|
|||
|
|
// Триггерим обновление приоритетов
|
|||
|
|
setPrioritiesRefreshTrigger(prev => prev + 1)
|
|||
|
|
|
|||
|
|
// Загружаем все данные параллельно (не фоново)
|
|||
|
|
await Promise.all([
|
|||
|
|
fetchCurrentWeekData(false),
|
|||
|
|
fetchFullStatisticsData(false),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
setIsRefreshing(false)
|
|||
|
|
}, [fetchCurrentWeekData, fetchFullStatisticsData])
|
|||
|
|
|
|||
|
|
// Обработчик кнопки "назад" в браузере (только для глубоких табов)
|
|||
|
|
useEffect(() => {
|
|||
|
|
const handlePopState = (event) => {
|
|||
|
|
// Проверяем, есть ли открытые модальные окна в DOM
|
|||
|
|
const taskDetailModal = document.querySelector('.task-detail-modal-overlay')
|
|||
|
|
const wishlistDetailModal = document.querySelector('.wishlist-detail-modal-overlay')
|
|||
|
|
|
|||
|
|
// Если есть открытые модальные окна, не обрабатываем здесь - компоненты сами закроют их
|
|||
|
|
if (taskDetailModal || wishlistDetailModal) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Если это модальное окно, не обрабатываем здесь - компоненты сами закроют его
|
|||
|
|
if (event.state && event.state.modalOpen) {
|
|||
|
|
// Если модальных окон нет в DOM, это устаревшая запись — пропускаем её
|
|||
|
|
if (!taskDetailModal && !wishlistDetailModal) {
|
|||
|
|
window.history.back()
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite']
|
|||
|
|
|
|||
|
|
// Проверяем state текущей записи истории (куда мы вернулись)
|
|||
|
|
if (event.state && event.state.tab) {
|
|||
|
|
const { tab, params = {} } = event.state
|
|||
|
|
|
|||
|
|
if (validTabs.includes(tab)) {
|
|||
|
|
setActiveTab(tab)
|
|||
|
|
setTabParams(params)
|
|||
|
|
markTabAsLoaded(tab)
|
|||
|
|
// Если это экран full, устанавливаем selectedProject только если он есть в params
|
|||
|
|
if (tab === 'full') {
|
|||
|
|
setSelectedProject(params.selectedProject || 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 только если он есть в params
|
|||
|
|
if (tabFromUrl === 'full') {
|
|||
|
|
setSelectedProject(params.selectedProject || null)
|
|||
|
|
}
|
|||
|
|
} 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 = () => {
|
|||
|
|
if (document.visibilityState === 'visible') {
|
|||
|
|
// Загружаем данные активного таба фоново
|
|||
|
|
const projectName = activeTab === 'full' ? selectedProject : null
|
|||
|
|
loadTabData(activeTab, true, projectName)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.addEventListener('focus', handleFocus)
|
|||
|
|
document.addEventListener('visibilitychange', handleFocus)
|
|||
|
|
|
|||
|
|
return () => {
|
|||
|
|
window.removeEventListener('focus', handleFocus)
|
|||
|
|
document.removeEventListener('visibilitychange', handleFocus)
|
|||
|
|
}
|
|||
|
|
}, [activeTab, loadTabData])
|
|||
|
|
|
|||
|
|
const handleProjectClick = (projectName) => {
|
|||
|
|
setSelectedProject(projectName)
|
|||
|
|
markTabAsLoaded('full')
|
|||
|
|
setTabParams({ selectedProject: projectName })
|
|||
|
|
updateUrl('full', { selectedProject: projectName }, activeTab)
|
|||
|
|
setActiveTab('full')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleTabChange = (tab, params = {}) => {
|
|||
|
|
if (tab === 'full' && activeTab === 'full') {
|
|||
|
|
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
|
|||
|
|
setSelectedProject(null)
|
|||
|
|
setTabParams({})
|
|||
|
|
updateUrl('full', {}, activeTab)
|
|||
|
|
} else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form' || (tab === 'words' && Object.keys(params).length > 0)) {
|
|||
|
|
// Для 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 (возврат после создания), или isTest (создание теста)
|
|||
|
|
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined && params.isTest === undefined
|
|||
|
|
// Проверяем, что boardId не null и не undefined (null означает "нет доски", но это валидное значение)
|
|||
|
|
const hasBoardId = params.boardId !== null && params.boardId !== undefined
|
|||
|
|
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && !hasBoardId
|
|||
|
|
if (isTaskFormWithNoParams || isWishlistFormWithNoParams) {
|
|||
|
|
setTabParams({})
|
|||
|
|
if (isNewTabMain) {
|
|||
|
|
clearUrl()
|
|||
|
|
} else if (isNewTabDeep) {
|
|||
|
|
updateUrl(tab, {}, activeTab)
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
setTabParams(params)
|
|||
|
|
// Обновляем URL только для глубоких табов
|
|||
|
|
if (isNewTabDeep) {
|
|||
|
|
// Проверяем, была ли последняя запись в истории от модального окна
|
|||
|
|
const currentState = window.history.state || {}
|
|||
|
|
const isFromModal = currentState.modalOpen === true
|
|||
|
|
const isNavigatingToForm = tab === 'task-form' || tab === 'wishlist-form'
|
|||
|
|
|
|||
|
|
if (isFromModal && isNavigatingToForm) {
|
|||
|
|
// Заменяем запись модального окна на запись формы редактирования
|
|||
|
|
// Используем replaceState вместо pushState, сохраняя activeTab как previousTab
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
window.history.replaceState({ tab, params, previousTab: activeTab }, '', url)
|
|||
|
|
} else {
|
|||
|
|
// Сохраняем текущий таб как предыдущий при переходе на глубокий таб
|
|||
|
|
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)
|
|||
|
|
} else if (tab === 'full') {
|
|||
|
|
// Если переходим на full без selectedProject в params, очищаем выбранный проект
|
|||
|
|
if (!params.selectedProject) {
|
|||
|
|
setSelectedProject(null)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Обновляем список слов при возврате из экрана добавления слов
|
|||
|
|
if (activeTab === 'add-words' && tab === 'words') {
|
|||
|
|
setWordsRefreshTrigger(prev => prev + 1)
|
|||
|
|
}
|
|||
|
|
// Обновляем список задач при возврате из экрана редактирования или теста
|
|||
|
|
// Используем фоновую загрузку, чтобы не показывать индикатор загрузки
|
|||
|
|
if ((activeTab === 'task-form' || activeTab === 'test') && tab === 'tasks') {
|
|||
|
|
fetchTasksData(true)
|
|||
|
|
}
|
|||
|
|
// Сохраняем предыдущий таб при открытии wishlist-form или wishlist-detail
|
|||
|
|
if ((tab === 'wishlist-form' || tab === 'wishlist-detail') && activeTab !== tab) {
|
|||
|
|
setPreviousTab(activeTab)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обновляем список желаний при возврате из экрана редактирования
|
|||
|
|
if (activeTab === 'wishlist-form' && tab !== 'wishlist-form') {
|
|||
|
|
// Сохраняем boardId из параметров или текущих tabParams
|
|||
|
|
const savedBoardId = params.boardId || tabParams.boardId
|
|||
|
|
// Параметры уже установлены в строке 649, но мы можем их обновить, чтобы сохранить boardId
|
|||
|
|
if (savedBoardId && tab === 'wishlist') {
|
|||
|
|
setTabParams(prev => ({ ...prev, boardId: savedBoardId }))
|
|||
|
|
}
|
|||
|
|
if (tab === 'wishlist') {
|
|||
|
|
setWishlistRefreshTrigger(prev => prev + 1)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обновляем список желаний при возврате из экрана детализации
|
|||
|
|
if (activeTab === 'wishlist-detail' && tab !== 'wishlist-detail') {
|
|||
|
|
if (tab === 'wishlist') {
|
|||
|
|
setWishlistRefreshTrigger(prev => prev + 1)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Загрузка данных произойдет в useEffect при изменении activeTab
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обработчики для кнопки добавления задачи
|
|||
|
|
const handleAddClick = () => {
|
|||
|
|
setShowAddModal(true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleAddTask = () => {
|
|||
|
|
setShowAddModal(false)
|
|||
|
|
handleNavigate('task-form', { taskId: undefined, isTest: false })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleAddTest = () => {
|
|||
|
|
setShowAddModal(false)
|
|||
|
|
handleNavigate('task-form', { taskId: undefined, isTest: true })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обработчик навигации для компонентов
|
|||
|
|
const handleNavigate = (tab, params = {}) => {
|
|||
|
|
handleTabChange(tab, params)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Загружаем данные при открытии таба (когда таб становится активным)
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!activeTab || !loadedTabs[activeTab]) return
|
|||
|
|
|
|||
|
|
const isFirstLoad = !tabsInitializedRef.current[activeTab]
|
|||
|
|
const isReturningToTab = prevActiveTabRef.current !== null && prevActiveTabRef.current !== activeTab
|
|||
|
|
|
|||
|
|
// Проверяем, не загружали ли мы уже этот таб в этом рендере
|
|||
|
|
const tabKey = `${activeTab}-${isFirstLoad ? 'first' : 'return'}`
|
|||
|
|
if (lastLoadedTabRef.current === tabKey) {
|
|||
|
|
return // Уже загружали
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Обновляем список слов при возврате из экрана добавления слов
|
|||
|
|
if (prevActiveTabRef.current === 'add-words' && activeTab === 'words') {
|
|||
|
|
setWordsRefreshTrigger(prev => prev + 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isFirstLoad) {
|
|||
|
|
// Первая загрузка таба
|
|||
|
|
lastLoadedTabRef.current = tabKey
|
|||
|
|
const projectName = activeTab === 'full' ? selectedProject : null
|
|||
|
|
loadTabData(activeTab, false, projectName)
|
|||
|
|
} else if (isReturningToTab) {
|
|||
|
|
// Возврат на таб - фоновая загрузка
|
|||
|
|
lastLoadedTabRef.current = tabKey
|
|||
|
|
const projectName = activeTab === 'full' ? selectedProject : null
|
|||
|
|
loadTabData(activeTab, true, projectName)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
prevActiveTabRef.current = activeTab
|
|||
|
|
}, [activeTab, loadedTabs, loadTabData, selectedProject])
|
|||
|
|
|
|||
|
|
// Обновляем todayEntries при изменении selectedProject для таба 'full'
|
|||
|
|
// НЕ загружаем данные при открытии таба - это делает компонент FullStatistics
|
|||
|
|
// Загружаем только при изменении selectedProject, если таб уже открыт
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (activeTab === 'full' && prevActiveTabRef.current === 'full') {
|
|||
|
|
// Таб уже был открыт, просто изменился selectedProject
|
|||
|
|
// Данные будут загружены компонентом FullStatistics с правильной датой
|
|||
|
|
}
|
|||
|
|
}, [selectedProject, activeTab])
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Определяем общее состояние загрузки и ошибок для кнопки Refresh
|
|||
|
|
const isAnyLoading = currentWeekLoading || fullStatisticsLoading || prioritiesLoading || isRefreshing
|
|||
|
|
const hasAnyError = currentWeekError || fullStatisticsError || prioritiesError
|
|||
|
|
|
|||
|
|
// Сохраняем выбранный таб, чтобы восстановить его после перезагрузки
|
|||
|
|
useEffect(() => {
|
|||
|
|
try {
|
|||
|
|
window.localStorage?.setItem('activeTab', activeTab)
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('Не удалось сохранить активный таб в localStorage', err)
|
|||
|
|
}
|
|||
|
|
}, [activeTab])
|
|||
|
|
|
|||
|
|
// 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) {
|
|||
|
|
prevIsAuthenticatedRef.current = false
|
|||
|
|
return <AuthScreen />
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
|||
|
|
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite'
|
|||
|
|
|
|||
|
|
// Функция для получения классов скролл-контейнера для каждого таба
|
|||
|
|
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
|
|||
|
|
const getTabContainerClasses = (tabName) => {
|
|||
|
|
const isActive = activeTab === tabName
|
|||
|
|
const baseClasses = 'absolute inset-0 overflow-y-auto'
|
|||
|
|
// Активный таб: z-10 (сверху), неактивные: z-0 + invisible + opacity-0 (мгновенное скрытие)
|
|||
|
|
const visibilityClasses = isActive ? 'z-10' : 'z-0 invisible opacity-0 pointer-events-none'
|
|||
|
|
|
|||
|
|
// Определяем padding для каждого таба
|
|||
|
|
let paddingClasses = ''
|
|||
|
|
if (tabName === 'current' || tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') {
|
|||
|
|
paddingClasses = 'pb-20'
|
|||
|
|
} else if (tabName === 'words' || tabName === 'dictionaries') {
|
|||
|
|
paddingClasses = 'pb-16'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return `${baseClasses} ${paddingClasses} ${visibilityClasses}`.trim()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Функция для определения отступов внутреннего контейнера
|
|||
|
|
const getInnerContainerClasses = (tabName) => {
|
|||
|
|
if (tabName === 'tasks' || tabName === 'wishlist' || tabName === 'profile') {
|
|||
|
|
return 'max-w-7xl mx-auto p-4 md:p-8'
|
|||
|
|
}
|
|||
|
|
if (tabName === 'current') {
|
|||
|
|
return 'max-w-7xl mx-auto p-4 md:p-6'
|
|||
|
|
}
|
|||
|
|
if (tabName === 'full' || tabName === 'priorities' || tabName === 'dictionaries' || tabName === 'words') {
|
|||
|
|
return 'max-w-7xl mx-auto px-4 md:px-8 py-0'
|
|||
|
|
}
|
|||
|
|
// Fullscreen табы без отступов
|
|||
|
|
return 'max-w-7xl mx-auto p-0'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col h-screen h-dvh overflow-hidden">
|
|||
|
|
{/* Контейнер табов - каждый таб имеет свой изолированный скролл */}
|
|||
|
|
<div className="flex-1 relative overflow-hidden">
|
|||
|
|
{loadedTabs.current && (
|
|||
|
|
<div className={getTabContainerClasses('current')}>
|
|||
|
|
<div className={getInnerContainerClasses('current')}>
|
|||
|
|
<CurrentWeek
|
|||
|
|
onProjectClick={handleProjectClick}
|
|||
|
|
data={currentWeekData}
|
|||
|
|
loading={currentWeekLoading}
|
|||
|
|
error={currentWeekError}
|
|||
|
|
onRetry={fetchCurrentWeekData}
|
|||
|
|
allProjectsData={fullStatisticsData}
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
onOpenAddModal={(setOpenFn) => {
|
|||
|
|
currentWeekAddModalRef.current = setOpenFn
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs.priorities && (
|
|||
|
|
<div className={getTabContainerClasses('priorities')}>
|
|||
|
|
<div className={getInnerContainerClasses('priorities')}>
|
|||
|
|
<ProjectPriorityManager
|
|||
|
|
allProjectsData={fullStatisticsData}
|
|||
|
|
currentWeekData={currentWeekData}
|
|||
|
|
shouldLoad={activeTab === 'priorities' && loadedTabs.priorities}
|
|||
|
|
onLoadingChange={setPrioritiesLoading}
|
|||
|
|
onErrorChange={setPrioritiesError}
|
|||
|
|
refreshTrigger={prioritiesRefreshTrigger}
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs.full && (
|
|||
|
|
<div className={getTabContainerClasses('full')}>
|
|||
|
|
<div className={getInnerContainerClasses('full')}>
|
|||
|
|
<FullStatistics
|
|||
|
|
selectedProject={selectedProject}
|
|||
|
|
onClearSelection={() => {
|
|||
|
|
setSelectedProject(null)
|
|||
|
|
setTabParams({})
|
|||
|
|
replaceUrl('full', {})
|
|||
|
|
}}
|
|||
|
|
data={fullStatisticsData}
|
|||
|
|
loading={fullStatisticsLoading}
|
|||
|
|
error={fullStatisticsError}
|
|||
|
|
todayEntries={todayEntriesData}
|
|||
|
|
todayEntriesLoading={todayEntriesLoading || todayEntriesBackgroundLoading}
|
|||
|
|
todayEntriesError={todayEntriesError}
|
|||
|
|
onRetryTodayEntries={() => fetchTodayEntries(false, selectedProject, null)}
|
|||
|
|
fetchTodayEntries={fetchTodayEntries}
|
|||
|
|
onRetry={fetchFullStatisticsData}
|
|||
|
|
currentWeekData={currentWeekData}
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
activeTab={activeTab}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs.words && (
|
|||
|
|
<div className={getTabContainerClasses('words')}>
|
|||
|
|
<div className={getInnerContainerClasses('words')}>
|
|||
|
|
<WordList
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
dictionaryId={tabParams.dictionaryId}
|
|||
|
|
isNewDictionary={tabParams.isNewDictionary}
|
|||
|
|
refreshTrigger={wordsRefreshTrigger}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['add-words'] && (
|
|||
|
|
<div className={getTabContainerClasses('add-words')}>
|
|||
|
|
<div className={getInnerContainerClasses('add-words')}>
|
|||
|
|
<AddWords
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
dictionaryId={tabParams.dictionaryId}
|
|||
|
|
dictionaryName={tabParams.dictionaryName}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs.dictionaries && (
|
|||
|
|
<div className={getTabContainerClasses('dictionaries')}>
|
|||
|
|
<div className={getInnerContainerClasses('dictionaries')}>
|
|||
|
|
<DictionaryList
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
refreshTrigger={dictionariesRefreshTrigger}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs.test && (
|
|||
|
|
<div className={getTabContainerClasses('test')}>
|
|||
|
|
<div className={getInnerContainerClasses('test')}>
|
|||
|
|
<TestWords
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
wordCount={tabParams.wordCount}
|
|||
|
|
configId={tabParams.configId}
|
|||
|
|
maxCards={tabParams.maxCards}
|
|||
|
|
taskId={tabParams.taskId}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs.tasks && (
|
|||
|
|
<div className={getTabContainerClasses('tasks')}>
|
|||
|
|
<div className={getInnerContainerClasses('tasks')}>
|
|||
|
|
<TaskList
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
data={tasksData}
|
|||
|
|
loading={tasksLoading}
|
|||
|
|
backgroundLoading={tasksBackgroundLoading}
|
|||
|
|
error={tasksError}
|
|||
|
|
onRetry={() => fetchTasksData(false)}
|
|||
|
|
onRefresh={(isBackground = false) => fetchTasksData(isBackground)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['task-form'] && (
|
|||
|
|
<div className={getTabContainerClasses('task-form')}>
|
|||
|
|
<div className={getInnerContainerClasses('task-form')}>
|
|||
|
|
<TaskForm
|
|||
|
|
key={tabParams.taskId || 'new'}
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
taskId={tabParams.taskId}
|
|||
|
|
wishlistId={tabParams.wishlistId}
|
|||
|
|
isTest={tabParams.isTest}
|
|||
|
|
returnTo={tabParams.returnTo}
|
|||
|
|
returnWishlistId={tabParams.returnWishlistId}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs.wishlist && (
|
|||
|
|
<div className={getTabContainerClasses('wishlist')}>
|
|||
|
|
<div className={getInnerContainerClasses('wishlist')}>
|
|||
|
|
<Wishlist
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
refreshTrigger={wishlistRefreshTrigger}
|
|||
|
|
isActive={activeTab === 'wishlist'}
|
|||
|
|
initialBoardId={tabParams.boardId}
|
|||
|
|
boardDeleted={tabParams.boardDeleted}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['wishlist-form'] && (
|
|||
|
|
<div className={getTabContainerClasses('wishlist-form')}>
|
|||
|
|
<div className={getInnerContainerClasses('wishlist-form')}>
|
|||
|
|
<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>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['board-form'] && (
|
|||
|
|
<div className={getTabContainerClasses('board-form')}>
|
|||
|
|
<div className={getInnerContainerClasses('board-form')}>
|
|||
|
|
<BoardForm
|
|||
|
|
key={tabParams.boardId || 'new'}
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
boardId={tabParams.boardId}
|
|||
|
|
onSaved={() => setWishlistRefreshTrigger(prev => prev + 1)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['board-join'] && (
|
|||
|
|
<div className={getTabContainerClasses('board-join')}>
|
|||
|
|
<div className={getInnerContainerClasses('board-join')}>
|
|||
|
|
<BoardJoinPreview
|
|||
|
|
key={tabParams.inviteToken}
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
inviteToken={tabParams.inviteToken}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs.profile && (
|
|||
|
|
<div className={getTabContainerClasses('profile')}>
|
|||
|
|
<div className={getInnerContainerClasses('profile')}>
|
|||
|
|
<Profile onNavigate={handleNavigate} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['todoist-integration'] && (
|
|||
|
|
<div className={getTabContainerClasses('todoist-integration')}>
|
|||
|
|
<div className={getInnerContainerClasses('todoist-integration')}>
|
|||
|
|
<TodoistIntegration onNavigate={handleNavigate} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['telegram-integration'] && (
|
|||
|
|
<div className={getTabContainerClasses('telegram-integration')}>
|
|||
|
|
<div className={getInnerContainerClasses('telegram-integration')}>
|
|||
|
|
<TelegramIntegration onNavigate={handleNavigate} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['fitbit-integration'] && (
|
|||
|
|
<div className={getTabContainerClasses('fitbit-integration')}>
|
|||
|
|
<div className={getInnerContainerClasses('fitbit-integration')}>
|
|||
|
|
<FitbitIntegration onNavigate={handleNavigate} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs.tracking && (
|
|||
|
|
<div className={getTabContainerClasses('tracking')}>
|
|||
|
|
<div className={getInnerContainerClasses('tracking')}>
|
|||
|
|
<Tracking
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
activeTab={activeTab}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['tracking-access'] && (
|
|||
|
|
<div className={getTabContainerClasses('tracking-access')}>
|
|||
|
|
<div className={getInnerContainerClasses('tracking-access')}>
|
|||
|
|
<TrackingAccess onNavigate={handleNavigate} activeTab={activeTab} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loadedTabs['tracking-invite'] && (
|
|||
|
|
<div className={getTabContainerClasses('tracking-invite')}>
|
|||
|
|
<div className={getInnerContainerClasses('tracking-invite')}>
|
|||
|
|
<TrackingInviteAccept
|
|||
|
|
inviteToken={tabParams.inviteToken}
|
|||
|
|
onNavigate={handleNavigate}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Кнопка добавления задачи (только для таба задач) */}
|
|||
|
|
{!isFullscreenTab && activeTab === 'tasks' && (
|
|||
|
|
<button
|
|||
|
|
onClick={handleAddClick}
|
|||
|
|
className="fixed bottom-16 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
|
|||
|
|
title="Добавить задачу"
|
|||
|
|
>
|
|||
|
|
<svg
|
|||
|
|
width="24"
|
|||
|
|
height="24"
|
|||
|
|
viewBox="0 0 24 24"
|
|||
|
|
fill="none"
|
|||
|
|
stroke="currentColor"
|
|||
|
|
strokeWidth="2"
|
|||
|
|
strokeLinecap="round"
|
|||
|
|
strokeLinejoin="round"
|
|||
|
|
>
|
|||
|
|
<path d="M12 5v14M5 12h14" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Кнопка добавления желания (только для таба wishlist) */}
|
|||
|
|
{!isFullscreenTab && activeTab === 'wishlist' && (
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
// Получаем boardId из tabParams или из localStorage (где его сохраняет компонент Wishlist)
|
|||
|
|
let boardId = tabParams.boardId
|
|||
|
|
if (!boardId) {
|
|||
|
|
try {
|
|||
|
|
const saved = localStorage.getItem('wishlist_selected_board_id')
|
|||
|
|
if (saved) {
|
|||
|
|
const parsed = parseInt(saved, 10)
|
|||
|
|
if (!isNaN(parsed)) boardId = parsed
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error loading boardId from localStorage:', err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
handleNavigate('wishlist-form', { wishlistId: undefined, boardId: boardId })
|
|||
|
|
}}
|
|||
|
|
className="fixed bottom-16 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
|
|||
|
|
title="Добавить желание"
|
|||
|
|
>
|
|||
|
|
<svg
|
|||
|
|
width="24"
|
|||
|
|
height="24"
|
|||
|
|
viewBox="0 0 24 24"
|
|||
|
|
fill="none"
|
|||
|
|
stroke="currentColor"
|
|||
|
|
strokeWidth="2"
|
|||
|
|
strokeLinecap="round"
|
|||
|
|
strokeLinejoin="round"
|
|||
|
|
>
|
|||
|
|
<path d="M12 5v14M5 12h14" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Кнопка добавления записи (только для таба current - экран прогресса) */}
|
|||
|
|
{!isFullscreenTab && activeTab === 'current' && (
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
if (currentWeekAddModalRef.current) {
|
|||
|
|
currentWeekAddModalRef.current()
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
className="fixed bottom-16 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
|
|||
|
|
title="Добавить запись"
|
|||
|
|
>
|
|||
|
|
<svg
|
|||
|
|
width="24"
|
|||
|
|
height="24"
|
|||
|
|
viewBox="0 0 24 24"
|
|||
|
|
fill="none"
|
|||
|
|
stroke="currentColor"
|
|||
|
|
strokeWidth="2"
|
|||
|
|
strokeLinecap="round"
|
|||
|
|
strokeLinejoin="round"
|
|||
|
|
>
|
|||
|
|
<path d="M12 5v14M5 12h14" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Кнопка добавления словаря (только для таба dictionaries) */}
|
|||
|
|
{activeTab === 'dictionaries' && (
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleNavigate('words', { dictionaryId: null, isNewDictionary: true })}
|
|||
|
|
className="fixed bottom-4 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
|
|||
|
|
title="Добавить словарь"
|
|||
|
|
>
|
|||
|
|
<svg
|
|||
|
|
width="24"
|
|||
|
|
height="24"
|
|||
|
|
viewBox="0 0 24 24"
|
|||
|
|
fill="none"
|
|||
|
|
stroke="currentColor"
|
|||
|
|
strokeWidth="2"
|
|||
|
|
strokeLinecap="round"
|
|||
|
|
strokeLinejoin="round"
|
|||
|
|
>
|
|||
|
|
<path d="M12 5v14M5 12h14" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Кнопка добавления слов (только для таба words) */}
|
|||
|
|
{activeTab === 'words' && (
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
handleNavigate('add-words', { dictionaryId: tabParams.dictionaryId, dictionaryName: tabParams.dictionaryName })
|
|||
|
|
}}
|
|||
|
|
className="fixed bottom-4 right-4 z-20 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white w-[61px] h-[61px] rounded-2xl shadow-lg transition-all duration-200 hover:scale-105 flex items-center justify-center"
|
|||
|
|
title="Добавить слова"
|
|||
|
|
>
|
|||
|
|
<svg
|
|||
|
|
width="24"
|
|||
|
|
height="24"
|
|||
|
|
viewBox="0 0 24 24"
|
|||
|
|
fill="none"
|
|||
|
|
stroke="currentColor"
|
|||
|
|
strokeWidth="2"
|
|||
|
|
strokeLinecap="round"
|
|||
|
|
strokeLinejoin="round"
|
|||
|
|
>
|
|||
|
|
<path d="M12 5v14M5 12h14" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{!isFullscreenTab && (
|
|||
|
|
<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')}
|
|||
|
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
|||
|
|
activeTab === 'current'
|
|||
|
|
? 'text-indigo-700 bg-white/50'
|
|||
|
|
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
|||
|
|
}`}
|
|||
|
|
title="Неделя"
|
|||
|
|
>
|
|||
|
|
<span className="relative z-10 flex items-center justify-center">
|
|||
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|||
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
|||
|
|
<line x1="16" y1="2" x2="16" y2="6"></line>
|
|||
|
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
|||
|
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
|||
|
|
</svg>
|
|||
|
|
</span>
|
|||
|
|
{activeTab === 'current' && (
|
|||
|
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
|||
|
|
)}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleTabChange('tasks')}
|
|||
|
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
|||
|
|
activeTab === 'tasks' || activeTab === 'task-form'
|
|||
|
|
? '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="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 === '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('wishlist')}
|
|||
|
|
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
|||
|
|
activeTab === 'wishlist' || activeTab === 'wishlist-form'
|
|||
|
|
? '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">
|
|||
|
|
<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 === '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>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Модальное окно выбора типа задачи */}
|
|||
|
|
{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>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function App() {
|
|||
|
|
return (
|
|||
|
|
<AuthProvider>
|
|||
|
|
<AppContent />
|
|||
|
|
<PWAUpdatePrompt />
|
|||
|
|
</AuthProvider>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default App
|
|||
|
|
|
|||
|
|
|