Files
play-life/play-life-web/src/App.jsx
2025-12-29 20:01:55 +03:00

532 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 TestConfigSelection from './components/TestConfigSelection'
import AddConfig from './components/AddConfig'
import TestWords from './components/TestWords'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const CURRENT_WEEK_API_URL = '/playlife-feed'
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
function App() {
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,
'test-config': false,
'add-config': false,
test: false,
})
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
const [tabsInitialized, setTabsInitialized] = useState({
current: false,
priorities: false,
full: false,
words: false,
'add-words': false,
'test-config': false,
'add-config': false,
test: false,
})
// Параметры для навигации между вкладками
const [tabParams, setTabParams] = useState({})
// Кеширование данных
const [currentWeekData, setCurrentWeekData] = useState(null)
const [fullStatisticsData, setFullStatisticsData] = useState(null)
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
// Состояния фоновой загрузки (не показываются визуально)
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
// Ошибки
const [currentWeekError, setCurrentWeekError] = useState(null)
const [fullStatisticsError, setFullStatisticsError] = useState(null)
const [prioritiesError, setPrioritiesError] = useState(null)
// Состояние для кнопки Refresh (если она есть)
const [isRefreshing, setIsRefreshing] = useState(false)
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
// Восстанавливаем последний выбранный таб после перезагрузки
const [isInitialized, setIsInitialized] = useState(false)
useEffect(() => {
if (isInitialized) return
try {
const savedTab = window.localStorage?.getItem('activeTab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test']
if (savedTab && validTabs.includes(savedTab)) {
setActiveTab(savedTab)
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
setIsInitialized(true)
} else {
setIsInitialized(true)
}
} catch (err) {
console.warn('Не удалось прочитать активный таб из localStorage', err)
setIsInitialized(true)
}
}, [isInitialized])
const markTabAsLoaded = useCallback((tab) => {
setLoadedTabs(prev => (prev[tab] ? prev : { ...prev, [tab]: true }))
}, [])
const fetchCurrentWeekData = useCallback(async (isBackground = false) => {
try {
if (isBackground) {
setCurrentWeekBackgroundLoading(true)
} else {
setCurrentWeekLoading(true)
}
setCurrentWeekError(null)
console.log('Fetching current week data from:', CURRENT_WEEK_API_URL)
const response = await fetch(CURRENT_WEEK_API_URL)
if (!response.ok) {
throw new Error('Ошибка загрузки данных')
}
const jsonData = await response.json()
// Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}]
let projects = []
let total = 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
} 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
}
setCurrentWeekData({
projects: Array.isArray(projects) ? projects : [],
total: total
})
} catch (err) {
setCurrentWeekError(err.message)
console.error('Ошибка загрузки данных текущей недели:', err)
} finally {
if (isBackground) {
setCurrentWeekBackgroundLoading(false)
} else {
setCurrentWeekLoading(false)
}
}
}, [])
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
try {
if (isBackground) {
setFullStatisticsBackgroundLoading(true)
} else {
setFullStatisticsLoading(true)
}
setFullStatisticsError(null)
const response = await fetch(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)
}
}
}, [])
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
const tabsInitializedRef = useRef({
current: false,
priorities: false,
full: false,
words: false,
'add-words': false,
'test-config': false,
'add-config': false,
test: false,
})
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
const cacheRef = useRef({
current: null,
full: null,
})
// Обновляем ref при изменении данных
useEffect(() => {
cacheRef.current.current = currentWeekData
}, [currentWeekData])
useEffect(() => {
cacheRef.current.full = fullStatisticsData
}, [fullStatisticsData])
// Функция для загрузки данных таба
const loadTabData = useCallback((tab, isBackground = false) => {
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 isInitialized = tabsInitializedRef.current.full
if (!isInitialized) {
// Первая загрузка таба - загружаем с индикатором
fetchFullStatisticsData(false)
tabsInitializedRef.current.full = true
setTabsInitialized(prev => ({ ...prev, full: true }))
} else if (hasCache && isBackground) {
// Возврат на таб с кешем - фоновая загрузка
fetchFullStatisticsData(true)
}
} 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 === 'test-config') {
const isInitialized = tabsInitializedRef.current['test-config']
if (!isInitialized) {
// Первая загрузка таба
setTestConfigRefreshTrigger(prev => prev + 1)
tabsInitializedRef.current['test-config'] = true
setTabsInitialized(prev => ({ ...prev, 'test-config': true }))
} else if (isBackground) {
// Возврат на таб - фоновая загрузка
setTestConfigRefreshTrigger(prev => prev + 1)
}
}
}, [fetchCurrentWeekData, fetchFullStatisticsData])
// Функция для обновления всех данных (для кнопки 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 handleFocus = () => {
if (document.visibilityState === 'visible') {
// Загружаем данные активного таба фоново
loadTabData(activeTab, true)
}
}
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')
setActiveTab('full')
}
const handleTabChange = (tab, params = {}) => {
if (tab === 'full' && activeTab === 'full') {
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
setSelectedProject(null)
} else if (tab !== activeTab) {
markTabAsLoaded(tab)
// Сбрасываем tabParams при переходе с add-config на другой таб
if (activeTab === 'add-config' && tab !== 'add-config') {
setTabParams({})
} else {
setTabParams(params)
}
setActiveTab(tab)
if (tab === 'current') {
setSelectedProject(null)
}
// Обновляем список слов при возврате из экрана добавления слов
if (activeTab === 'add-words' && tab === 'words') {
setWordsRefreshTrigger(prev => prev + 1)
}
// Загрузка данных произойдет в useEffect при изменении activeTab
}
}
// Обработчик навигации для компонентов
const handleNavigate = (tab, params = {}) => {
handleTabChange(tab, params)
}
// Загружаем данные при открытии таба (когда таб становится активным)
const prevActiveTabRef = useRef(null)
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
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 (isFirstLoad) {
// Первая загрузка таба
lastLoadedTabRef.current = tabKey
loadTabData(activeTab, false)
} else if (isReturningToTab) {
// Возврат на таб - фоновая загрузка
lastLoadedTabRef.current = tabKey
loadTabData(activeTab, true)
}
prevActiveTabRef.current = activeTab
}, [activeTab, loadedTabs, loadTabData])
// Определяем общее состояние загрузки и ошибок для кнопки 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])
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config'
return (
<div className="flex flex-col min-h-screen min-h-dvh">
<div className={`flex-1 ${isFullscreenTab ? 'pb-0' : 'pb-20'}`}>
<div className={`max-w-7xl mx-auto ${isFullscreenTab ? 'p-0' : 'p-4 md:p-6'}`}>
{loadedTabs.current && (
<div className={activeTab === 'current' ? 'block' : 'hidden'}>
<CurrentWeek
onProjectClick={handleProjectClick}
data={currentWeekData}
loading={currentWeekLoading}
error={currentWeekError}
onRetry={fetchCurrentWeekData}
allProjectsData={fullStatisticsData}
onNavigate={handleNavigate}
/>
</div>
)}
{loadedTabs.priorities && (
<div className={activeTab === 'priorities' ? 'block' : 'hidden'}>
<ProjectPriorityManager
allProjectsData={fullStatisticsData}
currentWeekData={currentWeekData}
shouldLoad={activeTab === 'priorities' && loadedTabs.priorities}
onLoadingChange={setPrioritiesLoading}
onErrorChange={setPrioritiesError}
refreshTrigger={prioritiesRefreshTrigger}
onNavigate={handleNavigate}
/>
</div>
)}
{loadedTabs.full && (
<div className={activeTab === 'full' ? 'block' : 'hidden'}>
<FullStatistics
selectedProject={selectedProject}
onClearSelection={() => setSelectedProject(null)}
data={fullStatisticsData}
loading={fullStatisticsLoading}
error={fullStatisticsError}
onRetry={fetchFullStatisticsData}
currentWeekData={currentWeekData}
onNavigate={handleNavigate}
/>
</div>
)}
{loadedTabs.words && (
<div className={activeTab === 'words' ? 'block' : 'hidden'}>
<WordList
onNavigate={handleNavigate}
dictionaryId={tabParams.dictionaryId}
isNewDictionary={tabParams.isNewDictionary}
refreshTrigger={wordsRefreshTrigger}
/>
</div>
)}
{loadedTabs['add-words'] && (
<div className={activeTab === 'add-words' ? 'block' : 'hidden'}>
<AddWords
onNavigate={handleNavigate}
dictionaryId={tabParams.dictionaryId}
dictionaryName={tabParams.dictionaryName}
/>
</div>
)}
{loadedTabs['test-config'] && (
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}>
<TestConfigSelection
onNavigate={handleNavigate}
refreshTrigger={testConfigRefreshTrigger}
/>
</div>
)}
{loadedTabs['add-config'] && (
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
<AddConfig
key={tabParams.config?.id || 'new'}
onNavigate={handleNavigate}
editingConfig={tabParams.config}
/>
</div>
)}
{loadedTabs.test && (
<div className={activeTab === 'test' ? 'block' : 'hidden'}>
<TestWords
onNavigate={handleNavigate}
wordCount={tabParams.wordCount}
configId={tabParams.configId}
maxCards={tabParams.maxCards}
/>
</div>
)}
</div>
</div>
{!isFullscreenTab && (
<div className="sticky bottom-0 flex bg-white/90 backdrop-blur-md border-t border-white/20 flex-shrink-0 overflow-x-auto shadow-lg z-10 justify-center items-center relative w-full" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div className="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('test-config')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'test-config' || activeTab === 'test'
? '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="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>
</span>
{(activeTab === 'test-config' || activeTab === 'test') && (
<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>
)}
</div>
)
}
export default App