feat: improved navigation and unified close buttons - version 3.5.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s

This commit is contained in:
Play Life Bot
2026-01-08 00:02:06 +03:00
parent b1cfea22e6
commit 60a6f4deb4
11 changed files with 326 additions and 93 deletions

View File

@@ -1 +1 @@
3.4.2
3.5.0

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "3.3.1",
"version": "3.5.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -10,6 +10,8 @@ import TestWords from './components/TestWords'
import Profile from './components/Profile'
import TaskList from './components/TaskList'
import TaskForm from './components/TaskForm.jsx'
import TodoistIntegration from './components/TodoistIntegration'
import TelegramIntegration from './components/TelegramIntegration'
import { AuthProvider, useAuth } from './components/auth/AuthContext'
import AuthScreen from './components/auth/AuthScreen'
@@ -17,6 +19,10 @@ import AuthScreen from './components/auth/AuthScreen'
const CURRENT_WEEK_API_URL = '/playlife-feed'
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
const mainTabs = ['current', 'test-config', 'tasks', 'profile']
const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
function AppContent() {
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
@@ -47,6 +53,8 @@ function AppContent() {
tasks: false,
'task-form': false,
profile: false,
'todoist-integration': false,
'telegram-integration': false,
})
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
@@ -62,6 +70,8 @@ function AppContent() {
tasks: false,
'task-form': false,
profile: false,
'todoist-integration': false,
'telegram-integration': false,
})
// Параметры для навигации между вкладками
@@ -98,21 +108,59 @@ function AppContent() {
// Восстанавливаем последний выбранный таб после перезагрузки
const [isInitialized, setIsInitialized] = useState(false)
// Инициализация из URL (только для глубоких табов) или localStorage
useEffect(() => {
if (isInitialized) return
try {
const savedTab = window.localStorage?.getItem('activeTab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile']
if (savedTab && validTabs.includes(savedTab)) {
setActiveTab(savedTab)
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
setIsInitialized(true)
// Проверяем URL только для глубоких табов
const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile', 'todoist-integration', 'telegram-integration']
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
// Если в URL есть глубокий таб, восстанавливаем его
setActiveTab(tabFromUrl)
setLoadedTabs(prev => ({ ...prev, [tabFromUrl]: true }))
// Восстанавливаем параметры из URL
const params = {}
urlParams.forEach((value, key) => {
if (key !== 'tab') {
try {
params[key] = JSON.parse(value)
} catch {
params[key] = value
}
}
})
if (Object.keys(params).length > 0) {
setTabParams(params)
// Если это экран full с selectedProject, восстанавливаем его
if (tabFromUrl === 'full' && params.selectedProject) {
setSelectedProject(params.selectedProject)
}
}
} else {
setIsInitialized(true)
// Если в URL нет глубокого таба, проверяем localStorage для основного таба
const savedTab = window.localStorage?.getItem('activeTab')
if (savedTab && validTabs.includes(savedTab)) {
setActiveTab(savedTab)
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
}
// Очищаем URL от параметров таба, если это основной таб
if (tabFromUrl && mainTabs.includes(tabFromUrl)) {
const url = new URL(window.location)
url.searchParams.delete('tab')
url.searchParams.forEach((value, key) => {
url.searchParams.delete(key)
})
window.history.replaceState({}, '', url)
}
}
setIsInitialized(true)
} catch (err) {
console.warn('Не удалось прочитать активный таб из localStorage', err)
console.warn('Не удалось прочитать активный таб', err)
setIsInitialized(true)
}
}, [isInitialized])
@@ -121,6 +169,91 @@ function AppContent() {
setLoadedTabs(prev => (prev[tab] ? prev : { ...prev, [tab]: true }))
}, [])
// Функция для обновления URL (только для глубоких табов)
const updateUrl = useCallback((tab, params = {}, previousTab = null) => {
if (!deepTabs.includes(tab)) {
// Для основных табов не обновляем URL
return
}
const url = new URL(window.location)
url.searchParams.set('tab', tab)
// Удаляем старые параметры таба
const keysToRemove = []
url.searchParams.forEach((value, key) => {
if (key !== 'tab') {
keysToRemove.push(key)
}
})
keysToRemove.forEach(key => url.searchParams.delete(key))
// Добавляем новые параметры
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value)
}
})
// Сохраняем предыдущий таб в state для восстановления при "Назад"
window.history.pushState({ tab, params, previousTab }, '', url)
}, []) // deepTabs - константа, не нужно в зависимостях
// Функция для очистки URL (при возврате к основному табу)
const clearUrl = useCallback((tab = null, usePushState = false) => {
const url = new URL(window.location)
const hasTabParam = url.searchParams.has('tab')
if (hasTabParam) {
url.searchParams.delete('tab')
url.searchParams.forEach((value, key) => {
url.searchParams.delete(key)
})
// Сохраняем текущий таб в state для восстановления при "Назад"
if (usePushState && tab) {
window.history.pushState({ tab }, '', url)
} else {
window.history.replaceState(tab ? { tab } : {}, '', url)
}
} else if (tab) {
// Если URL уже чистый, но нужно сохранить state таба
if (usePushState) {
window.history.pushState({ tab }, '', url)
} else {
window.history.replaceState({ tab }, '', url)
}
}
}, [])
// Функция для обновления URL без создания новой записи в истории (для обновления параметров того же таба)
const replaceUrl = useCallback((tab, params = {}) => {
if (!deepTabs.includes(tab)) {
return
}
const url = new URL(window.location)
url.searchParams.set('tab', tab)
// Удаляем старые параметры таба
const keysToRemove = []
url.searchParams.forEach((value, key) => {
if (key !== 'tab') {
keysToRemove.push(key)
}
})
keysToRemove.forEach(key => url.searchParams.delete(key))
// Добавляем новые параметры
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value)
}
})
// Сохраняем текущий state, чтобы не потерять previousTab
const currentState = window.history.state || {}
window.history.replaceState({ ...currentState, tab, params }, '', url)
}, [])
const fetchCurrentWeekData = useCallback(async (isBackground = false) => {
try {
if (isBackground) {
@@ -240,6 +373,8 @@ function AppContent() {
tasks: false,
'task-form': false,
profile: false,
'todoist-integration': false,
'telegram-integration': false,
})
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
@@ -350,6 +485,82 @@ function AppContent() {
setIsRefreshing(false)
}, [fetchCurrentWeekData, fetchFullStatisticsData])
// Обработчик кнопки "назад" в браузере (только для глубоких табов)
useEffect(() => {
const handlePopState = (event) => {
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile', 'todoist-integration', 'telegram-integration']
// Проверяем state текущей записи истории (куда мы вернулись)
if (event.state && event.state.tab) {
const { tab, params = {} } = event.state
if (validTabs.includes(tab)) {
setActiveTab(tab)
setTabParams(params)
markTabAsLoaded(tab)
// Если это экран full с selectedProject, восстанавливаем его
if (tab === 'full' && params.selectedProject) {
setSelectedProject(params.selectedProject)
} else if (tab === 'full') {
setSelectedProject(null)
}
return
}
}
// Если state пустой или не содержит таб, пытаемся восстановить из URL
const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab')
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
// Если в URL есть глубокий таб, восстанавливаем его
setActiveTab(tabFromUrl)
markTabAsLoaded(tabFromUrl)
const params = {}
urlParams.forEach((value, key) => {
if (key !== 'tab') {
try {
params[key] = JSON.parse(value)
} catch {
params[key] = value
}
}
})
setTabParams(params)
// Если это экран full с selectedProject, восстанавливаем его
if (tabFromUrl === 'full' && params.selectedProject) {
setSelectedProject(params.selectedProject)
}
} else {
// Если в URL нет глубокого таба, значит мы вернулись на основной таб
// Проверяем state - если там есть tab, используем его
if (event.state && event.state.tab && validTabs.includes(event.state.tab)) {
setActiveTab(event.state.tab)
setTabParams({})
markTabAsLoaded(event.state.tab)
setSelectedProject(null)
clearUrl(event.state.tab)
} else {
// Если state пустой, используем сохраненный таб из localStorage
const savedTab = window.localStorage?.getItem('activeTab')
const validMainTab = savedTab && validTabs.includes(savedTab) ? savedTab : 'current'
setActiveTab(validMainTab)
setTabParams({})
markTabAsLoaded(validMainTab)
setSelectedProject(null)
clearUrl(validMainTab)
}
}
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [markTabAsLoaded, clearUrl]) // mainTabs и deepTabs - константы, не нужно в зависимостях
// Обновляем данные при возвращении экрана в фокус (фоново)
useEffect(() => {
const handleFocus = () => {
@@ -371,6 +582,8 @@ function AppContent() {
const handleProjectClick = (projectName) => {
setSelectedProject(projectName)
markTabAsLoaded('full')
setTabParams({ selectedProject: projectName })
updateUrl('full', { selectedProject: projectName }, activeTab)
setActiveTab('full')
}
@@ -378,20 +591,51 @@ function AppContent() {
if (tab === 'full' && activeTab === 'full') {
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
setSelectedProject(null)
setTabParams({})
updateUrl('full', {}, activeTab)
} else if (tab !== activeTab || tab === 'task-form') {
// Для task-form всегда обновляем параметры, даже если это тот же таб
markTabAsLoaded(tab)
// Определяем, является ли текущий таб глубоким
const isCurrentTabDeep = deepTabs.includes(activeTab)
const isNewTabDeep = deepTabs.includes(tab)
const isCurrentTabMain = mainTabs.includes(activeTab)
const isNewTabMain = mainTabs.includes(tab)
// Сбрасываем tabParams при переходе с add-config на другой таб
if (activeTab === 'add-config' && tab !== 'add-config') {
setTabParams({})
if (isNewTabMain) {
clearUrl()
} else if (isNewTabDeep) {
updateUrl(tab, {}, activeTab)
}
} else {
// Для task-form явно удаляем taskId, если он undefined
if (tab === 'task-form' && params.taskId === undefined) {
setTabParams({})
if (isNewTabMain) {
clearUrl()
} else if (isNewTabDeep) {
updateUrl(tab, {}, activeTab)
}
} else {
setTabParams(params)
// Обновляем URL только для глубоких табов
if (isNewTabDeep) {
// Сохраняем текущий таб как предыдущий при переходе на глубокий таб
updateUrl(tab, params, activeTab)
} else if (isNewTabMain && isCurrentTabDeep) {
// При переходе с глубокого таба на основной - очищаем URL и сохраняем таб в state
clearUrl(tab)
} else if (isNewTabMain && isCurrentTabMain) {
// При переходе между основными табами - сохраняем таб в state без изменения URL, создаем новую запись в истории
clearUrl(tab, true)
}
}
}
setActiveTab(tab)
if (tab === 'current') {
setSelectedProject(null)
@@ -457,12 +701,25 @@ function AppContent() {
}, [activeTab])
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form'
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities'
// Определяем отступы для контейнера
const getContainerPadding = () => {
if (!isFullscreenTab) {
return 'p-4 md:p-6'
}
// Для экрана статистики добавляем горизонтальные отступы
if (activeTab === 'full') {
return 'px-4 md:px-6 py-0'
}
// Для остальных fullscreen экранов без отступов
return 'p-0'
}
return (
<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'}`}>
<div className={`max-w-7xl mx-auto ${getContainerPadding()}`}>
{loadedTabs.current && (
<div className={activeTab === 'current' ? 'block' : 'hidden'}>
<CurrentWeek
@@ -495,7 +752,11 @@ function AppContent() {
<div className={activeTab === 'full' ? 'block' : 'hidden'}>
<FullStatistics
selectedProject={selectedProject}
onClearSelection={() => setSelectedProject(null)}
onClearSelection={() => {
setSelectedProject(null)
setTabParams({})
replaceUrl('full', {})
}}
data={fullStatisticsData}
loading={fullStatisticsLoading}
error={fullStatisticsError}
@@ -584,6 +845,18 @@ function AppContent() {
<Profile onNavigate={handleNavigate} />
</div>
)}
{loadedTabs['todoist-integration'] && (
<div className={activeTab === 'todoist-integration' ? 'block' : 'hidden'}>
<TodoistIntegration onNavigate={handleNavigate} />
</div>
)}
{loadedTabs['telegram-integration'] && (
<div className={activeTab === 'telegram-integration' ? 'block' : 'hidden'}>
<TelegramIntegration onNavigate={handleNavigate} />
</div>
)}
</div>
</div>

View File

@@ -13,6 +13,7 @@ import {
import { Line } from 'react-chartjs-2'
import WeekProgressChart from './WeekProgressChart'
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
import './Integrations.css'
// Экспортируем для обратной совместимости (если используется в других местах)
export { getProjectColorByIndex } from '../utils/projectUtils'
@@ -216,18 +217,13 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
return (
<div>
{onNavigate && (
<div className="flex justify-end mb-4">
<button
onClick={() => onNavigate('current')}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Закрыть"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<button
onClick={() => onNavigate('current')}
className="close-x-button"
title="Закрыть"
>
</button>
)}
<div style={{ height: '550px' }}>
<Line data={chartData} options={chartOptions} />

View File

@@ -1,15 +1,12 @@
import React, { useState } from 'react'
import React from 'react'
import { useAuth } from './auth/AuthContext'
import TodoistIntegration from './TodoistIntegration'
import TelegramIntegration from './TelegramIntegration'
function Profile({ onNavigate }) {
const { user, logout } = useAuth()
const [selectedIntegration, setSelectedIntegration] = useState(null)
const integrations = [
{ id: 'todoist', name: 'TODOist' },
{ id: 'telegram', name: 'Telegram' },
{ id: 'todoist-integration', name: 'TODOist' },
{ id: 'telegram-integration', name: 'Telegram' },
]
const handleLogout = async () => {
@@ -18,14 +15,6 @@ function Profile({ onNavigate }) {
}
}
if (selectedIntegration) {
if (selectedIntegration === 'todoist') {
return <TodoistIntegration onBack={() => setSelectedIntegration(null)} />
} else if (selectedIntegration === 'telegram') {
return <TelegramIntegration onBack={() => setSelectedIntegration(null)} />
}
}
return (
<div className="p-4 md:p-6 max-w-2xl mx-auto">
{/* Profile Header */}
@@ -52,7 +41,7 @@ function Profile({ onNavigate }) {
{integrations.map((integration) => (
<button
key={integration.id}
onClick={() => setSelectedIntegration(integration.id)}
onClick={() => onNavigate?.(integration.id)}
className="w-full p-4 bg-white rounded-xl shadow-sm hover:shadow-md transition-all text-left border border-gray-100 hover:border-indigo-200 group"
>
<div className="flex items-center justify-between">

View File

@@ -20,6 +20,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { useAuth } from './auth/AuthContext'
import './Integrations.css'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const PROJECTS_API_URL = '/projects'
@@ -866,18 +867,13 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
return (
<div className="max-w-4xl mx-auto flex flex-col max-h-[calc(100vh-11rem)]">
{onNavigate && (
<div className="flex justify-end mb-4 flex-shrink-0">
<button
onClick={() => onNavigate('current')}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Закрыть"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<button
onClick={() => onNavigate('current')}
className="close-x-button"
title="Закрыть"
>
</button>
)}
{projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-sm flex-shrink-0">

View File

@@ -6,26 +6,28 @@
}
.close-x-button {
position: absolute;
position: fixed;
top: 1rem;
right: 1rem;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 50%;
width: 2rem;
height: 2rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.25rem;
color: #6b7280;
transition: all 0.2s;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.close-x-button:hover {
background: #e5e7eb;
color: #1f2937;
background-color: #ffffff;
color: #2c3e50;
}
.task-form h2 {

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './Integrations.css'
function TelegramIntegration({ onBack }) {
function TelegramIntegration({ onNavigate }) {
const { authFetch } = useAuth()
const [integration, setIntegration] = useState(null)
const [loading, setLoading] = useState(true)
@@ -49,7 +49,7 @@ function TelegramIntegration({ onBack }) {
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={onBack} title="Закрыть">
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
</button>

View File

@@ -16,30 +16,6 @@
flex-direction: column;
}
.test-close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.test-close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.test-duration-selection {
text-align: center;

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import './TestWords.css'
import './Integrations.css'
const API_URL = '/api'
@@ -436,7 +437,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
return (
<div className="test-container test-container-fullscreen">
<button className="test-close-x-button" onClick={handleClose}>
<button className="close-x-button" onClick={handleClose}>
</button>
{showPreview ? (

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './Integrations.css'
function TodoistIntegration({ onBack }) {
function TodoistIntegration({ onNavigate }) {
const { authFetch } = useAuth()
const [connected, setConnected] = useState(false)
const [todoistEmail, setTodoistEmail] = useState('')
@@ -102,7 +102,7 @@ function TodoistIntegration({ onBack }) {
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={onBack} title="Закрыть">
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
</button>