Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 16s
Features: - User registration and login with JWT tokens - All data is now user-specific (multi-tenancy) - Profile page with integrations and logout - Automatic migration of existing data to first user Backend changes: - Added users and refresh_tokens tables - Added user_id to all data tables (projects, entries, nodes, dictionaries, words, progress, configs, telegram_integrations, weekly_goals) - JWT authentication middleware - claimOrphanedData() for data migration Frontend changes: - AuthContext for state management - Login/Register forms - Profile page (replaced Integrations) - All API calls use authFetch with Bearer token Migration notes: - On first deploy, backend automatically adds user_id columns - First user to login claims all existing data
586 lines
24 KiB
JavaScript
586 lines
24 KiB
JavaScript
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'
|
||
import Profile from './components/Profile'
|
||
import { AuthProvider, useAuth } from './components/auth/AuthContext'
|
||
import AuthScreen from './components/auth/AuthScreen'
|
||
|
||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||
const CURRENT_WEEK_API_URL = '/playlife-feed'
|
||
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
||
|
||
function AppContent() {
|
||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||
|
||
// Show loading while checking auth
|
||
if (authLoading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
|
||
<div className="text-white text-xl">Загрузка...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Show auth screen if not authenticated
|
||
if (!isAuthenticated) {
|
||
return <AuthScreen />
|
||
}
|
||
const [activeTab, setActiveTab] = useState('current')
|
||
const [selectedProject, setSelectedProject] = useState(null)
|
||
const [loadedTabs, setLoadedTabs] = useState({
|
||
current: false,
|
||
priorities: false,
|
||
full: false,
|
||
words: false,
|
||
'add-words': false,
|
||
'test-config': false,
|
||
'add-config': false,
|
||
test: false,
|
||
profile: false,
|
||
})
|
||
|
||
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||
const [tabsInitialized, setTabsInitialized] = useState({
|
||
current: false,
|
||
priorities: false,
|
||
full: false,
|
||
words: false,
|
||
'add-words': false,
|
||
'test-config': false,
|
||
'add-config': false,
|
||
test: false,
|
||
profile: 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', 'profile']
|
||
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 authFetch(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)
|
||
}
|
||
}
|
||
}, [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])
|
||
|
||
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
||
const tabsInitializedRef = useRef({
|
||
current: false,
|
||
priorities: false,
|
||
full: false,
|
||
words: false,
|
||
'add-words': false,
|
||
'test-config': false,
|
||
'add-config': false,
|
||
test: false,
|
||
profile: 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>
|
||
)}
|
||
|
||
{loadedTabs.profile && (
|
||
<div className={activeTab === 'profile' ? 'block' : 'hidden'}>
|
||
<Profile onNavigate={handleNavigate} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{!isFullscreenTab && (
|
||
<div className="sticky bottom-0 flex bg-white/90 backdrop-blur-md border-t border-white/20 flex-shrink-0 overflow-x-auto shadow-lg z-10 justify-center items-center relative w-full" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||
<div className="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>
|
||
<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>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function App() {
|
||
return (
|
||
<AuthProvider>
|
||
<AppContent />
|
||
</AuthProvider>
|
||
)
|
||
}
|
||
|
||
export default App
|
||
|
||
|