From c9bbe83bbb3e69ec41a4d2cf2953baa2d7e58664 Mon Sep 17 00:00:00 2001 From: Primakov Alexandr Date: Sun, 14 Dec 2025 13:58:24 +0300 Subject: [PATCH] Refactor Dashboard component to implement a structured routing system with dedicated pages for workplace input, login, chain selection, task management, and completion. Introduce centralized localStorage management for user data and navigation logic, enhancing user experience and streamlining the application flow. Remove the deprecated LoginForm component and update the MainPage to redirect users based on their authentication and task status. --- src/__data__/urls.ts | 8 + src/components/Header.tsx | 10 +- src/components/LoginForm.tsx | 205 ------------------------- src/context/ChallengeContext.tsx | 36 ++--- src/dashboard.tsx | 89 ++++++++++- src/pages/chains/ChainsPage.tsx | 47 ++++++ src/pages/chains/index.ts | 4 + src/pages/completed/CompletedPage.tsx | 99 ++++++++++++ src/pages/completed/index.ts | 4 + src/pages/index.ts | 7 +- src/pages/login/LoginPage.tsx | 141 +++++++++++++++++ src/pages/login/index.ts | 4 + src/pages/main/main.tsx | 210 ++++---------------------- src/pages/task/TaskPage.tsx | 138 +++++++++++++++++ src/pages/task/index.ts | 4 + src/pages/workplace/WorkplacePage.tsx | 145 ++++++++++++++++++ src/pages/workplace/index.ts | 4 + src/utils/storage.ts | 113 ++++++++++++++ stubs/api/data/chains.json | 32 ++++ 19 files changed, 881 insertions(+), 419 deletions(-) delete mode 100644 src/components/LoginForm.tsx create mode 100644 src/pages/chains/ChainsPage.tsx create mode 100644 src/pages/chains/index.ts create mode 100644 src/pages/completed/CompletedPage.tsx create mode 100644 src/pages/completed/index.ts create mode 100644 src/pages/login/LoginPage.tsx create mode 100644 src/pages/login/index.ts create mode 100644 src/pages/task/TaskPage.tsx create mode 100644 src/pages/task/index.ts create mode 100644 src/pages/workplace/WorkplacePage.tsx create mode 100644 src/pages/workplace/index.ts create mode 100644 src/utils/storage.ts diff --git a/src/__data__/urls.ts b/src/__data__/urls.ts index c8df917..b87b4ed 100644 --- a/src/__data__/urls.ts +++ b/src/__data__/urls.ts @@ -17,6 +17,14 @@ const getNavPath = (key: string, fallback: string) => { export const URLs = { baseUrl, + // Основные маршруты + workplace: makeUrl('/workplace'), + login: makeUrl('/login'), + chains: makeUrl('/chains'), + chain: (chainId: string) => makeUrl(`/chain/${chainId}`), + task: (chainId: string, taskId: string) => makeUrl(`/chain/${chainId}/task/${taskId}`), + completed: (chainId: string) => makeUrl(`/completed/${chainId}`), + // Старые маршруты auth: { url: makeUrl(navs[`link.${pkg.name}.auth`]), isOn: Boolean(navs[`link.${pkg.name}.auth`]), diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6757c06..f163f06 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,7 +1,9 @@ import React from 'react' +import { useNavigate } from 'react-router-dom' import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react' import { useChallenge } from '../context/ChallengeContext' +import { URLs } from '../__data__/urls' interface HeaderProps { chainName?: string @@ -9,8 +11,14 @@ interface HeaderProps { } export const Header = ({ chainName, taskProgress }: HeaderProps) => { + const navigate = useNavigate() const { nickname, workplaceNumber, logout } = useChallenge() + const handleLogout = () => { + logout() + navigate(URLs.workplace) + } + if (!nickname) return null return ( @@ -42,7 +50,7 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => { )} - diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx deleted file mode 100644 index 63a13a2..0000000 --- a/src/components/LoginForm.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { - Box, - Button, - Heading, - Input, - Text, - VStack, -} from '@chakra-ui/react' - -import { useChallenge } from '../context/ChallengeContext' - -const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber' - -export const LoginForm = () => { - const [step, setStep] = useState<'workplace' | 'fio'>('workplace') - const [workplaceNumber, setWorkplaceNumber] = useState('') - const [fullName, setFullName] = useState('') - const [error, setError] = useState('') - const { login, isAuthLoading } = useChallenge() - - // Проверяем, есть ли сохранённый номер рабочего места - useEffect(() => { - const savedWorkplace = localStorage.getItem(WORKPLACE_NUMBER_KEY) - if (savedWorkplace) { - setWorkplaceNumber(savedWorkplace) - setStep('fio') - } - }, []) - - const handleWorkplaceSubmit = (e: React.FormEvent) => { - e.preventDefault() - - const trimmedWorkplace = workplaceNumber.trim() - if (!trimmedWorkplace) { - setError('Пожалуйста, введите номер рабочего места') - return - } - - // Сохраняем номер рабочего места сразу - localStorage.setItem(WORKPLACE_NUMBER_KEY, trimmedWorkplace) - setWorkplaceNumber(trimmedWorkplace) - setError('') - setStep('fio') - } - - const handleFioSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - const trimmedName = fullName.trim() - if (!trimmedName) { - setError('Пожалуйста, введите ваше ФИО') - return - } - - if (trimmedName.length < 3) { - setError('ФИО должно содержать минимум 3 символа') - return - } - - try { - setError('') - await login(trimmedName, workplaceNumber.trim()) - } catch (err) { - setError('Произошла ошибка при входе. Попробуйте снова.') - console.error('Login error:', err) - } - } - - if (step === 'workplace') { - return ( - - - {/* @ts-expect-error Chakra UI v2 uses spacing */} - - - - Challenge Platform - - - Добро пожаловать! Введите номер рабочего места - - - -
- {/* @ts-expect-error Chakra UI v2 uses spacing */} - - - - Номер рабочего места - - setWorkplaceNumber(e.target.value)} - placeholder="Например: 1" - size="lg" - autoFocus - // @ts-expect-error Chakra UI v2 uses isInvalid - isInvalid={!!error} - /> - {error && ( - - {error} - - )} - - - - -
-
-
-
- ) - } - - return ( - - - {/* @ts-expect-error Chakra UI v2 uses spacing */} - - - - Challenge Platform - - - Рабочее место: №{workplaceNumber} - - - (номер сохранён) - - - Введите ваше ФИО для начала работы - - - -
- {/* @ts-expect-error Chakra UI v2 uses spacing */} - - - - Ваше ФИО - - setFullName(e.target.value)} - placeholder="Иванов Иван Иванович" - size="lg" - autoFocus - // @ts-expect-error Chakra UI v2 uses isInvalid - isInvalid={!!error} - /> - {error && ( - - {error} - - )} - - - {/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */} - - - - -
-
-
-
- ) -} diff --git a/src/context/ChallengeContext.tsx b/src/context/ChallengeContext.tsx index 1b257a4..ce9ee4d 100644 --- a/src/context/ChallengeContext.tsx +++ b/src/context/ChallengeContext.tsx @@ -19,8 +19,7 @@ import { BehaviorTracker, MetricsCollector, buildPersonalDashboard } from '../ut import { ChallengeEventEmitter } from '../utils/events' import { clearDraft, loadDraft, saveDraft } from '../utils/drafts' import { PollingManager } from '../utils/polling' - -const isBrowser = () => typeof window !== 'undefined' +import { storage } from '../utils/storage' class ChallengeCache { private cache = new Map() @@ -77,10 +76,6 @@ interface ChallengeContextValue { const ChallengeContext = createContext(undefined) -const USER_ID_KEY = 'challengeUserId' -const USER_NICKNAME_KEY = 'challengeNickname' -const WORKPLACE_NUMBER_KEY = 'challengeWorkplaceNumber' - export const ChallengeProvider = ({ children }: PropsWithChildren) => { const cacheRef = useRef(new ChallengeCache()) const metricsCollector = useMemo(() => new MetricsCollector(), []) @@ -88,15 +83,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { const eventEmitter = useMemo(() => new ChallengeEventEmitter(), []) const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1.15 }), []) - const [userId, setUserId] = useState(() => - isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null, - ) - const [nickname, setNickname] = useState(() => - isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null, - ) - const [workplaceNumber, setWorkplaceNumber] = useState(() => - isBrowser() ? window.localStorage.getItem(WORKPLACE_NUMBER_KEY) : null, - ) + const [userId, setUserId] = useState(() => storage.getUserId()) + const [nickname, setNickname] = useState(() => storage.getNickname()) + const [workplaceNumber, setWorkplaceNumber] = useState(() => storage.getWorkplaceNumber()) const [stats, setStats] = useState(null) const [personalDashboard, setPersonalDashboard] = useState(null) const [chains, setChains] = useState(() => { @@ -162,11 +151,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { setNickname(nicknameValue) setWorkplaceNumber(workplaceNumberValue) - if (isBrowser()) { - window.localStorage.setItem(USER_ID_KEY, response.userId) - window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue) - window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue) - } + storage.setUserId(response.userId) + storage.setNickname(nicknameValue) + storage.setWorkplaceNumber(workplaceNumberValue) cacheRef.current.clear('chains') await refreshStatsById(response.userId) @@ -182,13 +169,8 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => { setPersonalDashboard(null) cacheRef.current.clear() - if (isBrowser()) { - window.localStorage.removeItem(USER_ID_KEY) - window.localStorage.removeItem(USER_NICKNAME_KEY) - window.localStorage.removeItem(WORKPLACE_NUMBER_KEY) - window.localStorage.removeItem('challengeSelectedChainId') - window.localStorage.removeItem('challengeSelectedTaskId') - } + // Очищаем всё из localStorage + storage.clearAll() }, []) const isStatsLoading = statsResult.isLoading || statsResult.isFetching || isChainsLoading diff --git a/src/dashboard.tsx b/src/dashboard.tsx index a691263..f5b1ec0 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,24 +1,107 @@ import React, { Suspense } from 'react' -import { Route, Routes } from 'react-router-dom' +import { Navigate, Route, Routes } from 'react-router-dom' import { URLs } from './__data__/urls' -import { MainPage } from './pages' +import { + WorkplacePage, + LoginPage, + ChainsPage, + TaskPage, + CompletedPage +} from './pages' +import { storage } from './utils/storage' const PageWrapper = ({ children }: React.PropsWithChildren) => ( Loading...}>{children} ) +// Компонент для редиректа на нужную страницу +const IndexRedirect = () => { + const workplaceNumber = storage.getWorkplaceNumber() + const nickname = storage.getNickname() + const chainId = storage.getSelectedChainId() + const taskId = storage.getSelectedTaskId() + + // Если есть сохранённое задание - туда + if (nickname && chainId && taskId) { + return + } + // Если авторизован - к цепочкам + if (nickname) { + return + } + // Если есть номер места - к логину + if (workplaceNumber) { + return + } + // Иначе - к вводу места + return +} + export const Dashboard = () => { return ( + {/* Главная - редирект */} } + /> + + {/* Ввод номера рабочего места */} + - + } /> + + {/* Ввод ФИО */} + + + + } + /> + + {/* Выбор цепочки */} + + + + } + /> + + {/* Задание */} + + + + } + /> + + {/* Завершение цепочки */} + + + + } + /> + + {/* Fallback */} + } + /> ) } diff --git a/src/pages/chains/ChainsPage.tsx b/src/pages/chains/ChainsPage.tsx new file mode 100644 index 0000000..d31c62b --- /dev/null +++ b/src/pages/chains/ChainsPage.tsx @@ -0,0 +1,47 @@ +import React, { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' + +import { URLs } from '../../__data__/urls' +import { useChallenge } from '../../context/ChallengeContext' +import { Header } from '../../components/Header' +import { ChainSelector } from '../../components/ChainSelector' +import { storage } from '../../utils/storage' +import type { ChallengeChain } from '../../__data__/types' + +export const ChainsPage = () => { + const navigate = useNavigate() + const { nickname } = useChallenge() + + // Проверяем авторизацию + useEffect(() => { + const workplaceNumber = storage.getWorkplaceNumber() + if (!workplaceNumber) { + navigate(URLs.workplace, { replace: true }) + return + } + if (!nickname) { + navigate(URLs.login, { replace: true }) + } + }, [navigate, nickname]) + + const handleSelectChain = (chain: ChallengeChain) => { + storage.setSelectedChainId(chain.id) + // Переходим к первому заданию цепочки + if (chain.tasks.length > 0) { + storage.setSelectedTaskId(chain.tasks[0].id) + navigate(URLs.task(chain.id, chain.tasks[0].id)) + } + } + + if (!nickname) { + return null + } + + return ( + <> +
+ + + ) +} + diff --git a/src/pages/chains/index.ts b/src/pages/chains/index.ts new file mode 100644 index 0000000..1b71a88 --- /dev/null +++ b/src/pages/chains/index.ts @@ -0,0 +1,4 @@ +import { ChainsPage } from './ChainsPage' + +export default ChainsPage + diff --git a/src/pages/completed/CompletedPage.tsx b/src/pages/completed/CompletedPage.tsx new file mode 100644 index 0000000..ea05e71 --- /dev/null +++ b/src/pages/completed/CompletedPage.tsx @@ -0,0 +1,99 @@ +import React, { useEffect, useMemo } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Box, + Button, + Heading, + Text, + VStack, +} from '@chakra-ui/react' + +import { URLs } from '../../__data__/urls' +import { useChallenge } from '../../context/ChallengeContext' +import { Header } from '../../components/Header' +import { storage } from '../../utils/storage' + +export const CompletedPage = () => { + const navigate = useNavigate() + const { chainId } = useParams<{ chainId: string }>() + const { nickname, chains } = useChallenge() + + // Проверяем авторизацию + useEffect(() => { + const workplaceNumber = storage.getWorkplaceNumber() + if (!workplaceNumber) { + navigate(URLs.workplace, { replace: true }) + return + } + if (!nickname) { + navigate(URLs.login, { replace: true }) + } + }, [navigate, nickname]) + + const chain = useMemo(() => { + return chains.find(c => c.id === chainId) || null + }, [chains, chainId]) + + const handleContinue = () => { + navigate(URLs.chains) + } + + if (!nickname) { + return null + } + + return ( + <> +
+ + + + 🎉 + + Поздравляем! + + + Вы успешно выполнили все задания + + {chain && ( + + + {chain.name} + + + )} + + Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий. + + + + + + + ) +} + diff --git a/src/pages/completed/index.ts b/src/pages/completed/index.ts new file mode 100644 index 0000000..1f4a978 --- /dev/null +++ b/src/pages/completed/index.ts @@ -0,0 +1,4 @@ +import { CompletedPage } from './CompletedPage' + +export default CompletedPage + diff --git a/src/pages/index.ts b/src/pages/index.ts index 0bc85fb..44cda1f 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,3 +1,8 @@ import { lazy } from 'react' -export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main')) \ No newline at end of file +export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main')) +export const WorkplacePage = lazy(() => import(/* webpackChunkName: 'workplace' */'./workplace')) +export const LoginPage = lazy(() => import(/* webpackChunkName: 'login' */'./login')) +export const ChainsPage = lazy(() => import(/* webpackChunkName: 'chains' */'./chains')) +export const TaskPage = lazy(() => import(/* webpackChunkName: 'task' */'./task')) +export const CompletedPage = lazy(() => import(/* webpackChunkName: 'completed' */'./completed')) \ No newline at end of file diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx new file mode 100644 index 0000000..9625ab8 --- /dev/null +++ b/src/pages/login/LoginPage.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, + Button, + Heading, + Input, + Text, + VStack, +} from '@chakra-ui/react' + +import { URLs } from '../../__data__/urls' +import { useChallenge } from '../../context/ChallengeContext' +import { storage } from '../../utils/storage' + +export const LoginPage = () => { + const navigate = useNavigate() + const { login, isAuthLoading, nickname } = useChallenge() + const [fullName, setFullName] = useState('') + const [error, setError] = useState('') + const workplaceNumber = storage.getWorkplaceNumber() + + // Если нет номера рабочего места, возвращаемся + useEffect(() => { + if (!workplaceNumber) { + navigate(URLs.workplace, { replace: true }) + return + } + // Если уже авторизован, переходим к цепочкам + if (nickname) { + navigate(URLs.chains, { replace: true }) + } + }, [navigate, workplaceNumber, nickname]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + const trimmedName = fullName.trim() + if (!trimmedName) { + setError('Пожалуйста, введите ваше ФИО') + return + } + + if (trimmedName.length < 3) { + setError('ФИО должно содержать минимум 3 символа') + return + } + + try { + setError('') + await login(trimmedName, workplaceNumber || '') + navigate(URLs.chains) + } catch (err) { + setError('Произошла ошибка при входе. Попробуйте снова.') + console.error('Login error:', err) + } + } + + const handleChangeWorkplace = () => { + storage.removeWorkplaceNumber() + navigate(URLs.workplace) + } + + if (!workplaceNumber) { + return null + } + + return ( + + + {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Challenge Platform + + + Рабочее место: №{workplaceNumber} + + + Введите ваше ФИО для начала работы + + + +
+ {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Ваше ФИО + + setFullName(e.target.value)} + placeholder="Иванов Иван Иванович" + size="lg" + autoFocus + // @ts-expect-error Chakra UI v2 uses isInvalid + isInvalid={!!error} + /> + {error && ( + + {error} + + )} + + + {/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */} + + +
+ + + + Изменить рабочее место + + +
+
+
+ ) +} + diff --git a/src/pages/login/index.ts b/src/pages/login/index.ts new file mode 100644 index 0000000..bb665da --- /dev/null +++ b/src/pages/login/index.ts @@ -0,0 +1,4 @@ +import { LoginPage } from './LoginPage' + +export default LoginPage + diff --git a/src/pages/main/main.tsx b/src/pages/main/main.tsx index 99a6e61..8936a21 100644 --- a/src/pages/main/main.tsx +++ b/src/pages/main/main.tsx @@ -1,190 +1,36 @@ -import React, { useEffect, useRef, useState } from 'react' -import { - Box, - Button, - Heading, - Text, - VStack, -} from '@chakra-ui/react' -import { Alert } from '@chakra-ui/react/alert' +import React from 'react' +import { Navigate } from 'react-router-dom' -import type { ChallengeChain, ChallengeTask } from '../../__data__/types' -import { useChallenge } from '../../context/ChallengeContext' -import { TaskWorkspace } from '../../components/personal' -import { Header } from '../../components/Header' -import { LoginForm } from '../../components/LoginForm' -import { ChainSelector } from '../../components/ChainSelector' - -const SELECTED_CHAIN_KEY = 'challengeSelectedChainId' -const SELECTED_TASK_KEY = 'challengeSelectedTaskId' +import { URLs } from '../../__data__/urls' +import { storage } from '../../utils/storage' +/** + * MainPage теперь просто редиректит на нужную страницу + * Вся логика навигации находится в отдельных страницах: + * - /workplace - ввод номера рабочего места + * - /login - ввод ФИО + * - /chains - выбор цепочки + * - /chain/:chainId/task/:taskId - задание + * - /completed/:chainId - завершение цепочки + */ export const MainPage = () => { - const { nickname, chains } = useChallenge() - const [selectedChain, setSelectedChain] = useState(null) - const [selectedTask, setSelectedTask] = useState(null) - const [completedChainName, setCompletedChainName] = useState(null) - const [isOffline, setIsOffline] = useState(() => - typeof navigator !== 'undefined' ? !navigator.onLine : false, - ) - const hasRestoredState = useRef(false) + const workplaceNumber = storage.getWorkplaceNumber() + const nickname = storage.getNickname() + const chainId = storage.getSelectedChainId() + const taskId = storage.getSelectedTaskId() - // Восстановление состояния при загрузке - useEffect(() => { - if (hasRestoredState.current || !chains.length || !nickname) return - - const savedChainId = localStorage.getItem(SELECTED_CHAIN_KEY) - const savedTaskId = localStorage.getItem(SELECTED_TASK_KEY) - - if (savedChainId) { - const chain = chains.find(c => c.id === savedChainId) - if (chain) { - setSelectedChain(chain) - - if (savedTaskId) { - const task = chain.tasks.find(t => t.id === savedTaskId) - setSelectedTask(task || chain.tasks[0]) - } else { - setSelectedTask(chain.tasks[0]) - } - } - } - - hasRestoredState.current = true - }, [chains, nickname]) - - const handleSelectChain = (chain: ChallengeChain) => { - setSelectedChain(chain) - setSelectedTask(chain.tasks[0]) - localStorage.setItem(SELECTED_CHAIN_KEY, chain.id) - localStorage.setItem(SELECTED_TASK_KEY, chain.tasks[0].id) + // Если есть сохранённое задание - туда + if (nickname && chainId && taskId) { + return } - - const handleTaskComplete = () => { - if (!selectedChain) return - const currentIndex = selectedChain.tasks.findIndex((item) => item.id === selectedTask?.id) - const nextTask = currentIndex >= 0 ? selectedChain.tasks[currentIndex + 1] : null - - if (nextTask) { - setSelectedTask(nextTask) - localStorage.setItem(SELECTED_TASK_KEY, nextTask.id) - } else { - // Цепочка завершена - показываем экран поздравления - setCompletedChainName(selectedChain.name) - setSelectedChain(null) - setSelectedTask(null) - localStorage.removeItem(SELECTED_CHAIN_KEY) - localStorage.removeItem(SELECTED_TASK_KEY) - } + // Если авторизован - к цепочкам + if (nickname) { + return } - - const handleContinueAfterCompletion = () => { - setCompletedChainName(null) + // Если есть номер места - к логину + if (workplaceNumber) { + return } - - - useEffect(() => { - const handleOnline = () => setIsOffline(false) - const handleOffline = () => setIsOffline(true) - - window.addEventListener('online', handleOnline) - window.addEventListener('offline', handleOffline) - - return () => { - window.removeEventListener('online', handleOnline) - window.removeEventListener('offline', handleOffline) - } - }, []) - - // Если пользователь не авторизован, показываем форму входа - if (!nickname) { - return - } - - // Если цепочка завершена, показываем экран поздравления - if (completedChainName) { - return ( - <> -
- - - - 🎉 - - Поздравляем! - - - Вы успешно выполнили все задания - - - - {completedChainName} - - - - Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий. - - - - - - - ) - } - - // Если цепочка не выбрана, показываем селектор цепочек - if (!selectedChain) { - return ( - <> -
- - - ) - } - - const taskProgress = `Задание ${selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1}` // из ${selectedChain.tasks.length}` - - // Показываем выбранную цепочку и задания - return ( - <> -
- - - {isOffline && ( - - - Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи. - - )} - - {selectedTask && ( - - )} - - - - ) + // Иначе - к вводу места + return } diff --git a/src/pages/task/TaskPage.tsx b/src/pages/task/TaskPage.tsx new file mode 100644 index 0000000..f782656 --- /dev/null +++ b/src/pages/task/TaskPage.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useMemo } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Box, + Button, + Flex, + HStack, + Text, +} from '@chakra-ui/react' +import { Alert } from '@chakra-ui/react/alert' + +import { URLs } from '../../__data__/urls' +import { useChallenge } from '../../context/ChallengeContext' +import { TaskWorkspace } from '../../components/personal' +import { Header } from '../../components/Header' +import { storage } from '../../utils/storage' + +export const TaskPage = () => { + const navigate = useNavigate() + const { chainId, taskId } = useParams<{ chainId: string; taskId: string }>() + const { nickname, chains } = useChallenge() + + // Проверяем авторизацию + useEffect(() => { + const workplaceNumber = storage.getWorkplaceNumber() + if (!workplaceNumber) { + navigate(URLs.workplace, { replace: true }) + return + } + if (!nickname) { + navigate(URLs.login, { replace: true }) + } + }, [navigate, nickname]) + + // Находим цепочку и задание + const chain = useMemo(() => { + return chains.find(c => c.id === chainId) || null + }, [chains, chainId]) + + const task = useMemo(() => { + return chain?.tasks.find(t => t.id === taskId) || null + }, [chain, taskId]) + + const currentTaskIndex = useMemo(() => { + if (!chain || !taskId) return -1 + return chain.tasks.findIndex(t => t.id === taskId) + }, [chain, taskId]) + + // Сохраняем текущее состояние в storage + useEffect(() => { + if (chainId && taskId) { + storage.setSelectedChainId(chainId) + storage.setSelectedTaskId(taskId) + } + }, [chainId, taskId]) + + const handleTaskComplete = () => { + if (!chain || currentTaskIndex === -1) return + + const nextTask = chain.tasks[currentTaskIndex + 1] + if (nextTask) { + navigate(URLs.task(chain.id, nextTask.id)) + } else { + // Цепочка завершена + storage.clearSessionData() + navigate(URLs.completed(chain.id)) + } + } + + const handleNavigateToTask = (newTaskId: string) => { + if (chain) { + navigate(URLs.task(chain.id, newTaskId)) + } + } + + const handleBackToChains = () => { + storage.clearSessionData() + navigate(URLs.chains) + } + + if (!nickname || !chain || !task) { + return null + } + + const taskProgress = `Задание ${currentTaskIndex + 1}` + + return ( + <> +
+ + + {/* Навигация по заданиям */} + + + + + Задания: + + {chain.tasks.map((t, index) => ( + + ))} + + + + + + + + + + + ) +} + diff --git a/src/pages/task/index.ts b/src/pages/task/index.ts new file mode 100644 index 0000000..859438f --- /dev/null +++ b/src/pages/task/index.ts @@ -0,0 +1,4 @@ +import { TaskPage } from './TaskPage' + +export default TaskPage + diff --git a/src/pages/workplace/WorkplacePage.tsx b/src/pages/workplace/WorkplacePage.tsx new file mode 100644 index 0000000..00299cd --- /dev/null +++ b/src/pages/workplace/WorkplacePage.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, + Button, + Heading, + Input, + Link, + Text, + VStack, +} from '@chakra-ui/react' + +import { URLs } from '../../__data__/urls' +import { storage } from '../../utils/storage' + +// Список полезных ссылок +const USEFUL_LINKS = [ + { url: 'https://ya.ru', label: 'Яндекс' }, + { url: 'https://giga.chat', label: 'GigaChat' }, +] + +export const WorkplacePage = () => { + const navigate = useNavigate() + const [workplaceNumber, setWorkplaceNumber] = useState('') + const [error, setError] = useState('') + + // Если номер уже есть, переходим дальше + useEffect(() => { + const saved = storage.getWorkplaceNumber() + if (saved) { + // Проверяем, авторизован ли пользователь + const nickname = storage.getNickname() + if (nickname) { + navigate(URLs.chains, { replace: true }) + } else { + navigate(URLs.login, { replace: true }) + } + } + }, [navigate]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + const trimmed = workplaceNumber.trim() + if (!trimmed) { + setError('Пожалуйста, введите номер рабочего места') + return + } + + storage.setWorkplaceNumber(trimmed) + navigate(URLs.login) + } + + return ( + + + {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Challenge Platform + + + Добро пожаловать! Введите номер рабочего места + + + +
+ {/* @ts-expect-error Chakra UI v2 uses spacing */} + + + + Номер рабочего места + + setWorkplaceNumber(e.target.value)} + placeholder="Например: 1" + size="lg" + autoFocus + // @ts-expect-error Chakra UI v2 uses isInvalid + isInvalid={!!error} + /> + {error && ( + + {error} + + )} + + + {/* Полезные ссылки */} + + + Полезные ссылки: + + + {/* @ts-expect-error Chakra UI v2 uses spacing */} + + {USEFUL_LINKS.map(link => ( + + 🔗 {link.label} + + ))} + + + + + +
+
+
+
+ ) +} + diff --git a/src/pages/workplace/index.ts b/src/pages/workplace/index.ts new file mode 100644 index 0000000..22ac727 --- /dev/null +++ b/src/pages/workplace/index.ts @@ -0,0 +1,4 @@ +import { WorkplacePage } from './WorkplacePage' + +export default WorkplacePage + diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..592f130 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,113 @@ +/** + * Централизованная работа с localStorage + * Все ключи и операции в одном месте + */ + +const isBrowser = () => typeof window !== 'undefined' + +// Ключи localStorage +export const STORAGE_KEYS = { + USER_ID: 'challengeUserId', + NICKNAME: 'challengeNickname', + WORKPLACE_NUMBER: 'challengeWorkplaceNumber', + SELECTED_CHAIN_ID: 'challengeSelectedChainId', + SELECTED_TASK_ID: 'challengeSelectedTaskId', +} as const + +// Получение значений +export const storage = { + getUserId: (): string | null => { + if (!isBrowser()) return null + return localStorage.getItem(STORAGE_KEYS.USER_ID) + }, + + getNickname: (): string | null => { + if (!isBrowser()) return null + return localStorage.getItem(STORAGE_KEYS.NICKNAME) + }, + + getWorkplaceNumber: (): string | null => { + if (!isBrowser()) return null + return localStorage.getItem(STORAGE_KEYS.WORKPLACE_NUMBER) + }, + + getSelectedChainId: (): string | null => { + if (!isBrowser()) return null + return localStorage.getItem(STORAGE_KEYS.SELECTED_CHAIN_ID) + }, + + getSelectedTaskId: (): string | null => { + if (!isBrowser()) return null + return localStorage.getItem(STORAGE_KEYS.SELECTED_TASK_ID) + }, + + // Установка значений + setUserId: (value: string): void => { + if (!isBrowser()) return + localStorage.setItem(STORAGE_KEYS.USER_ID, value) + }, + + setNickname: (value: string): void => { + if (!isBrowser()) return + localStorage.setItem(STORAGE_KEYS.NICKNAME, value) + }, + + setWorkplaceNumber: (value: string): void => { + if (!isBrowser()) return + localStorage.setItem(STORAGE_KEYS.WORKPLACE_NUMBER, value) + }, + + setSelectedChainId: (value: string): void => { + if (!isBrowser()) return + localStorage.setItem(STORAGE_KEYS.SELECTED_CHAIN_ID, value) + }, + + setSelectedTaskId: (value: string): void => { + if (!isBrowser()) return + localStorage.setItem(STORAGE_KEYS.SELECTED_TASK_ID, value) + }, + + // Удаление значений + removeUserId: (): void => { + if (!isBrowser()) return + localStorage.removeItem(STORAGE_KEYS.USER_ID) + }, + + removeNickname: (): void => { + if (!isBrowser()) return + localStorage.removeItem(STORAGE_KEYS.NICKNAME) + }, + + removeWorkplaceNumber: (): void => { + if (!isBrowser()) return + localStorage.removeItem(STORAGE_KEYS.WORKPLACE_NUMBER) + }, + + removeSelectedChainId: (): void => { + if (!isBrowser()) return + localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID) + }, + + removeSelectedTaskId: (): void => { + if (!isBrowser()) return + localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID) + }, + + // Полная очистка при выходе + clearAll: (): void => { + if (!isBrowser()) return + localStorage.removeItem(STORAGE_KEYS.USER_ID) + localStorage.removeItem(STORAGE_KEYS.NICKNAME) + localStorage.removeItem(STORAGE_KEYS.WORKPLACE_NUMBER) + localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID) + localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID) + }, + + // Очистка данных сессии (цепочка, задание) без выхода + clearSessionData: (): void => { + if (!isBrowser()) return + localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID) + localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID) + }, +} + diff --git a/stubs/api/data/chains.json b/stubs/api/data/chains.json index 1059c30..0d831fa 100644 --- a/stubs/api/data/chains.json +++ b/stubs/api/data/chains.json @@ -23,6 +23,38 @@ "description": "# React компонент\n\nСоздайте компонент `StatCard` с пропсами `title` и `value`.", "createdAt": "2024-09-05T11:30:00.000Z", "updatedAt": "2024-10-01T09:45:00.000Z" + }, + { + "id": "task-css-layout", + "_id": "task-css-layout", + "title": "CSS-верстка", + "description": "# CSS-верстка\n\nСверстайте карточку с заголовком, описанием и кнопкой. Используйте Flexbox и тени.", + "createdAt": "2024-09-10T14:00:00.000Z", + "updatedAt": "2024-10-05T09:00:00.000Z" + }, + { + "id": "task-js-dom", + "_id": "task-js-dom", + "title": "DOM-манипуляции", + "description": "# DOM-манипуляции\n\nНапишите скрипт, который добавляет элементы списка по клику на кнопку и очищает их по второй кнопке.", + "createdAt": "2024-09-15T10:20:00.000Z", + "updatedAt": "2024-10-07T12:30:00.000Z" + }, + { + "id": "task-react-state", + "_id": "task-react-state", + "title": "Состояние React", + "description": "# Состояние React\n\nСоздайте компонент, который показывает счётчик кликов и два кнопки — увеличить/сбросить. Используйте хуки.", + "createdAt": "2024-09-20T09:15:00.000Z", + "updatedAt": "2024-10-08T08:45:00.000Z" + }, + { + "id": "task-api-fetch", + "_id": "task-api-fetch", + "title": "Работа с API", + "description": "# Работа с API\n\nСделайте запрос к фиктивному API и отобразите список пользователей с именами и email.", + "createdAt": "2024-09-25T13:40:00.000Z", + "updatedAt": "2024-10-09T10:10:00.000Z" } ] }