19 Commits
v1.1.0 ... main

Author SHA1 Message Date
05c291120d 1.4.3
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-15 21:30:57 +03:00
4fd2e5660c Enhance LearningMaterialViewer and TaskWorkspace components to open links in a new tab with appropriate security attributes. This improves user experience by ensuring external links are handled safely.
Some checks are pending
platform/bro-js/challenge-pl/pipeline/head Build queued...
2025-12-15 21:30:50 +03:00
842bb959ab 1.4.2
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-15 21:22:34 +03:00
eb5bc8a10a Update authUser mutation to accept optional workplaceNumber parameter and adjust login function accordingly. Enhance API logging for authentication requests to include workplaceNumber for better debugging.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-15 18:03:10 +03:00
a0ac758049 Add refreshChains function to ChallengeContext and integrate it into ChainsPage for improved data management. This allows for manual refreshing of chain data when the component mounts, enhancing user experience.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-15 17:43:21 +03:00
23ec02e29a 1.4.1
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 16:05:18 +03:00
1291519cda Refactor TaskWorkspace to use getFeatureValue for feature flag retrieval, improving clarity and consistency in skip button functionality.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 16:05:05 +03:00
d2783d01c9 1.4.0
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 16:00:07 +03:00
e29242accf Add feature flag for task skip functionality in TaskWorkspace. Update bro.config.js to enable skip button based on feature toggle, enhancing user experience in task management.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 15:59:07 +03:00
e6fae66881 1.3.0
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 15:42:20 +03:00
b9af3c4ee5 Enhance task management by adding skip functionality in TaskWorkspace and TaskPage. Implement storage methods for tracking skipped tasks, allowing users to navigate to the next task or the first skipped task seamlessly. Update polling manager configuration for improved performance.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 15:39:45 +03:00
f7df4c755d Refactor TaskWorkspace to conditionally display learning materials with a button for user interaction. Update TaskPage to filter task navigation buttons based on accessibility, improving user experience in task management.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 15:25:53 +03:00
0092e55b65 Add optional learningMaterial field to ChallengeTask model and update API endpoints. Introduce LearningMaterialViewer component for displaying additional educational content in Markdown format. Enhance TaskWorkspace to conditionally render learning materials, improving user experience with task-related resources.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 15:18:26 +03:00
08b654bd4d 1.2.0
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:17:40 +03:00
cbf411cd54 Implement final answer management in submission hook and storage utilities. Add functions to save, load, and clear final answers in localStorage. Update useSubmission hook to prioritize final answers over drafts, enhancing user experience during task submissions.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:17:30 +03:00
f4e85fe980 Update LoginPage layout for improved spacing and enhance storage utility with new clearAllChainProgress function. Adjust clearAll method to retain workplace number while ensuring all chain progress data is removed upon logout.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:12:32 +03:00
3c1a235832 Fix color scheme logic in TaskPage component to ensure consistent button styling based on task accessibility. Simplified the color scheme condition for better readability and maintainability.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:08:04 +03:00
1a52901b90 Enhance task navigation and progress tracking across components. Update TaskWorkspace to improve task completion handling and button interactions. Refactor ChainsPage to select the furthest accessible task based on user progress. Implement storage functions for managing furthest task indices, ensuring users can only navigate to available tasks. Update TaskPage to check task accessibility and redirect users appropriately, improving overall user experience.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 14:06:18 +03:00
c9bbe83bbb 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.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
2025-12-14 13:58:24 +03:00
31 changed files with 1802 additions and 498 deletions

202
UPDATE.md Normal file
View File

@@ -0,0 +1,202 @@
# Добавление поля learningMaterial в задачу челленджа
## Описание изменений
В модель задачи челленджа (`ChallengeTask`) добавлено новое необязательное текстовое поле `learningMaterial` для хранения дополнительной обучающей информации в формате Markdown.
## Структура данных
### Модель ChallengeTask
```typescript
{
title: string, // Заголовок задания (обязательное)
description: string, // Основное описание в Markdown (обязательное, видно студентам)
learningMaterial: string, // Дополнительный учебный материал в Markdown (необязательное, видно студентам)
hiddenInstructions: string, // Скрытые инструкции для LLM (необязательное, только для преподавателей)
createdAt: Date, // Дата создания
updatedAt: Date, // Дата последнего обновления
creator: Object // Данные создателя из Keycloak
}
```
## Изменения в API
### 1. Создание задания (POST /challenge/task)
**Добавлено поле в тело запроса:**
```json
{
"title": "Название задания",
"description": "Основное описание в Markdown",
"learningMaterial": "Дополнительный учебный материал в Markdown",
"hiddenInstructions": "Скрытые инструкции для преподавателей"
}
```
**Пример запроса:**
```bash
POST /challenge/task
Content-Type: application/json
{
"title": "Реализация алгоритма сортировки",
"description": "Напишите функцию сортировки массива методом пузырька",
"learningMaterial": "## Теория\n\nМетод пузырьковой сортировки работает путем...\n\n## Полезные ссылки\n- [Википедия](https://ru.wikipedia.org/wiki/Сортировка_пузырьком)\n- [Видео объяснение](https://example.com/video)",
"hiddenInstructions": "Оценить эффективность алгоритма и стиль кода"
}
```
### 2. Обновление задания (PUT /challenge/task/:taskId)
**Добавлено поле в тело запроса:**
```json
{
"title": "Новое название",
"description": "Обновленное описание",
"learningMaterial": "Обновленный учебный материал",
"hiddenInstructions": "Обновленные инструкции"
}
```
## Получение данных
### Получение задания (GET /challenge/task/:taskId)
**Ответ содержит новое поле:**
```json
{
"id": "task_id",
"title": "Название задания",
"description": "Основное описание в Markdown",
"learningMaterial": "Дополнительный учебный материал в Markdown",
"createdAt": "2025-01-15T10:00:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}
```
**Важно:** Поле `learningMaterial` видно всем пользователям (студентам и преподавателям), в отличие от `hiddenInstructions`, которое скрывается от студентов.
### Получение всех заданий (GET /challenge/tasks)
Возвращает массив заданий с новым полем `learningMaterial`.
### Получение цепочек (GET /challenge/chains, GET /challenge/chain/:chainId)
При получении цепочек с populate заданий, поле `learningMaterial` будет доступно в каждом задании цепочки.
## Frontend изменения
### Интерфейсы TypeScript
```typescript
interface ChallengeTask {
id: string;
title: string;
description: string; // Markdown
learningMaterial?: string; // Новое поле - дополнительный материал в Markdown
createdAt: string;
updatedAt: string;
}
```
### Формы создания/редактирования заданий
В формах создания и редактирования заданий необходимо добавить поле для ввода `learningMaterial`:
```typescript
// Пример компонента формы
const TaskForm = () => {
const [formData, setFormData] = useState({
title: '',
description: '',
learningMaterial: '', // Новое поле
hiddenInstructions: ''
});
// Визуальный редактор или textarea для learningMaterial
return (
<form>
<input name="title" value={formData.title} />
<textarea name="description" value={formData.description} />
{/* Новое поле для дополнительного материала */}
<label>Дополнительный учебный материал (Markdown)</label>
<textarea
name="learningMaterial"
value={formData.learningMaterial}
placeholder="Дополнительные объяснения, ссылки, примеры..."
/>
{/* Только для преподавателей */}
<textarea name="hiddenInstructions" value={formData.hiddenInstructions} />
</form>
);
};
```
### Отображение заданий
При отображении задания студентам показывать `learningMaterial` как дополнительную информацию:
```typescript
const TaskView = ({ task }: { task: ChallengeTask }) => {
return (
<div>
<h1>{task.title}</h1>
{/* Основное описание */}
<div dangerouslySetInnerHTML={{ __html: marked(task.description) }} />
{/* Дополнительный учебный материал */}
{task.learningMaterial && (
<div className="learning-material">
<h2>Дополнительные материалы</h2>
<div dangerouslySetInnerHTML={{ __html: marked(task.learningMaterial) }} />
</div>
)}
</div>
);
};
```
## Миграция данных
Поле `learningMaterial` добавлено как необязательное с значением по умолчанию `''`, поэтому:
- Существующие задания будут работать без изменений
- Новое поле будет пустым для старых заданий
- Можно постепенно добавлять учебный материал к существующим заданиям
## Тестирование
### Создание задания с учебным материалом
```bash
# Создать задание с дополнительным материалом
POST /challenge/task
{
"title": "Тестовое задание",
"description": "Основное задание",
"learningMaterial": "# Полезная информация\n\nЭто дополнительный материал для студентов"
}
```
### Получение задания
```bash
GET /challenge/task/{taskId}
# Проверить, что learningMaterial присутствует в ответе
```
### Обновление учебного материала
```bash
PUT /challenge/task/{taskId}
{
"learningMaterial": "# Обновленная информация\n\nНовые полезные материалы..."
}
```
## Влияние на существующий код
- Все существующие эндпоинты получения данных автоматически возвращают новое поле
- Создание заданий без указания `learningMaterial` работает как прежде
- Фильтрация и валидация не затрагиваются
- Поле индексируется MongoDB автоматически

View File

@@ -23,6 +23,7 @@ module.exports = {
features: {
'challenge': {
// add your features here in the format [featureName]: { value: string }
'task.skip.enabled': { value: 'true' }, // Включить кнопку пропуска задания
},
},
config: {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "challenge",
"version": "1.1.0",
"version": "1.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "challenge",
"version": "1.1.0",
"version": "1.4.3",
"license": "ISC",
"dependencies": {
"@brojs/cli": "^1.9.4",

View File

@@ -1,6 +1,6 @@
{
"name": "challenge",
"version": "1.1.0",
"version": "1.4.3",
"description": "",
"main": "./src/index.tsx",
"scripts": {

View File

@@ -33,7 +33,7 @@ export const api = createApi({
}),
tagTypes: ['Chains', 'Chain', 'UserStats', 'SystemStats', 'Submissions', 'Queue'],
endpoints: (builder) => ({
authUser: builder.mutation<ChallengeAuthResponse, { nickname: string }>({
authUser: builder.mutation<ChallengeAuthResponse, { nickname: string; workplaceNumber?: string }>({
query: (body) => ({
url: '/auth',
method: 'POST',

View File

@@ -12,6 +12,7 @@ export interface ChallengeTask {
id: string
title: string
description: string
learningMaterial?: string
hiddenInstructions?: string
creator?: Record<string, unknown>
createdAt: string

View File

@@ -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`]),

View File

@@ -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) => {
)}
</Flex>
</Box>
<Button onClick={logout} variant="ghost" size="sm">
<Button onClick={handleLogout} variant="ghost" size="sm">
Выйти
</Button>
</Flex>

View File

@@ -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 (
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
bg="white"
borderWidth="1px"
borderRadius="lg"
borderColor="gray.200"
p={8}
maxW="480px"
w="full"
>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={6} align="stretch">
<Box textAlign="center">
<Heading size="lg" color="teal.600" mb={2}>
Challenge Platform
</Heading>
<Text color="gray.600">
Добро пожаловать! Введите номер рабочего места
</Text>
</Box>
<form onSubmit={handleWorkplaceSubmit}>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={4} align="stretch">
<Box>
<Text fontWeight="medium" mb={2}>
Номер рабочего места
</Text>
<Input
value={workplaceNumber}
onChange={(e) => setWorkplaceNumber(e.target.value)}
placeholder="Например: 1"
size="lg"
autoFocus
// @ts-expect-error Chakra UI v2 uses isInvalid
isInvalid={!!error}
/>
{error && (
<Text color="red.500" fontSize="sm" mt={2}>
{error}
</Text>
)}
</Box>
<Button
type="submit"
colorScheme="teal"
size="lg"
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={!workplaceNumber.trim()}
>
Продолжить
</Button>
</VStack>
</form>
</VStack>
</Box>
</Box>
)
}
return (
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
bg="white"
borderWidth="1px"
borderRadius="lg"
borderColor="gray.200"
p={8}
maxW="480px"
w="full"
>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={6} align="stretch">
<Box textAlign="center">
<Heading size="lg" color="teal.600" mb={2}>
Challenge Platform
</Heading>
<Text color="gray.600" fontWeight="medium">
Рабочее место: {workplaceNumber}
</Text>
<Text color="gray.500" fontSize="sm" mt={1}>
(номер сохранён)
</Text>
<Text color="gray.600" mt={3}>
Введите ваше ФИО для начала работы
</Text>
</Box>
<form onSubmit={handleFioSubmit}>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={4} align="stretch">
<Box>
<Text fontWeight="medium" mb={2}>
Ваше ФИО
</Text>
<Input
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Иванов Иван Иванович"
size="lg"
autoFocus
// @ts-expect-error Chakra UI v2 uses isInvalid
isInvalid={!!error}
/>
{error && (
<Text color="red.500" fontSize="sm" mt={2}>
{error}
</Text>
)}
</Box>
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
<Button type="submit" colorScheme="teal" size="lg" isLoading={isAuthLoading} isDisabled={!fullName.trim()}>
Войти
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setStep('workplace')
setError('')
}}
>
Изменить рабочее место
</Button>
</VStack>
</form>
</VStack>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,278 @@
import React, { useState, useMemo } from 'react'
import {
Box,
Button,
HStack,
Text,
VStack,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface LearningMaterialViewerProps {
content: string
linesPerPage?: number
}
export const LearningMaterialViewer = ({
content,
linesPerPage = 30
}: LearningMaterialViewerProps) => {
const [currentPage, setCurrentPage] = useState(0)
// Разделяем контент на страницы по linesPerPage строк
const pages = useMemo(() => {
const lines = content.split('\n')
const pagesArray: string[] = []
for (let i = 0; i < lines.length; i += linesPerPage) {
const pageLines = lines.slice(i, i + linesPerPage)
pagesArray.push(pageLines.join('\n'))
}
return pagesArray
}, [content, linesPerPage])
const totalPages = pages.length
if (totalPages === 0) {
return null
}
const goToPrevious = () => {
setCurrentPage(prev => Math.max(0, prev - 1))
}
const goToNext = () => {
setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))
}
return (
<Box
borderWidth="1px"
borderRadius="md"
borderColor="blue.200"
p={4}
bg="blue.50"
shadow="sm"
>
<VStack align="stretch" gap={3}>
<HStack justify="space-between" align="center">
<Text fontSize="lg" fontWeight="bold" color="blue.800">
Дополнительные материалы
</Text>
{totalPages > 1 && (
<Text fontSize="sm" color="blue.600">
Страница {currentPage + 1} из {totalPages}
</Text>
)}
</HStack>
<Box
color="gray.700"
fontSize="sm"
lineHeight="1.7"
css={{
// Заголовки
'& h1': {
fontSize: '1.75em',
fontWeight: '700',
marginTop: '1.2em',
marginBottom: '0.6em',
color: '#2D3748',
borderBottom: '2px solid #E2E8F0',
paddingBottom: '0.3em'
},
'& h2': {
fontSize: '1.5em',
fontWeight: '600',
marginTop: '1em',
marginBottom: '0.5em',
color: '#2D3748'
},
'& h3': {
fontSize: '1.25em',
fontWeight: '600',
marginTop: '0.8em',
marginBottom: '0.4em',
color: '#2D3748'
},
'& h4': {
fontSize: '1.1em',
fontWeight: '600',
marginTop: '0.6em',
marginBottom: '0.3em',
color: '#4A5568'
},
// Параграфы
'& p': {
marginTop: '0.75em',
marginBottom: '0.75em',
lineHeight: '1.8'
},
// Списки
'& ul, & ol': {
marginLeft: '1.5em',
marginTop: '0.75em',
marginBottom: '0.75em',
paddingLeft: '0.5em'
},
'& li': {
marginTop: '0.4em',
marginBottom: '0.4em',
lineHeight: '1.7'
},
'& li > p': {
marginTop: '0.25em',
marginBottom: '0.25em'
},
// Инлайн-код
'& code': {
backgroundColor: '#EDF2F7',
color: '#C53030',
padding: '0.15em 0.4em',
borderRadius: '4px',
fontSize: '0.9em',
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
fontWeight: '500'
},
// Блоки кода
'& pre': {
backgroundColor: '#1A202C',
color: '#E2E8F0',
padding: '1em 1.2em',
borderRadius: '8px',
overflowX: 'auto',
marginTop: '1em',
marginBottom: '1em',
border: '1px solid #2D3748',
fontSize: '0.9em',
lineHeight: '1.6'
},
'& pre code': {
backgroundColor: 'transparent',
color: '#E2E8F0',
padding: '0',
fontFamily: 'Monaco, Consolas, "Courier New", monospace'
},
// Цитаты
'& blockquote': {
borderLeft: '4px solid #4299E1',
paddingLeft: '1em',
paddingTop: '0.5em',
paddingBottom: '0.5em',
marginLeft: '0',
marginTop: '1em',
marginBottom: '1em',
fontStyle: 'italic',
color: '#4A5568',
backgroundColor: '#EBF8FF',
borderRadius: '0 4px 4px 0'
},
'& blockquote p': {
marginTop: '0.25em',
marginBottom: '0.25em'
},
// Ссылки
'& a': {
color: '#3182CE',
textDecoration: 'underline',
fontWeight: '500',
transition: 'color 0.2s',
'&:hover': {
color: '#2C5282'
}
},
// Горизонтальная линия
'& hr': {
border: 'none',
borderTop: '2px solid #E2E8F0',
marginTop: '1.5em',
marginBottom: '1.5em'
},
// Таблицы
'& table': {
borderCollapse: 'collapse',
width: '100%',
marginTop: '1em',
marginBottom: '1em',
fontSize: '0.95em'
},
'& table thead': {
backgroundColor: '#F7FAFC'
},
'& table th': {
border: '1px solid #E2E8F0',
padding: '0.75em 1em',
textAlign: 'left',
fontWeight: '600',
color: '#2D3748'
},
'& table td': {
border: '1px solid #E2E8F0',
padding: '0.75em 1em',
textAlign: 'left'
},
'& table tr:nth-of-type(even)': {
backgroundColor: '#F7FAFC'
},
// Выделение (strong, em)
'& strong': {
fontWeight: '600',
color: '#2D3748'
},
'& em': {
fontStyle: 'italic'
},
// Изображения
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: '8px',
marginTop: '1em',
marginBottom: '1em'
}
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node: _node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
)
}}
>
{pages[currentPage]}
</ReactMarkdown>
</Box>
{totalPages > 1 && (
<HStack justify="center" gap={2}>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={goToPrevious}
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={currentPage === 0}
leftIcon={<Text></Text>}
>
Предыдущая
</Button>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={goToNext}
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={currentPage === totalPages - 1}
rightIcon={<Text></Text>}
>
Следующая
</Button>
</HStack>
)}
</VStack>
</Box>
)
}

View File

@@ -9,49 +9,58 @@ import {
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { getFeatureValue } from '@brojs/cli'
import type { ChallengeTask } from '../../__data__/types'
import { useChallenge } from '../../context/ChallengeContext'
import { useSubmission } from '../../hooks/useSubmission'
import { LearningMaterialViewer } from './LearningMaterialViewer'
interface TaskWorkspaceProps {
task: ChallengeTask
onTaskComplete?: () => void
onTaskSkip?: () => void
}
export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
export const TaskWorkspace = ({ task, onTaskComplete, onTaskSkip }: TaskWorkspaceProps) => {
const { refreshStats } = useChallenge()
const { result, setResult, submit, reset, queueStatus, finalSubmission, isSubmitting } = useSubmission({
const { result, setResult, submit, queueStatus, finalSubmission, isSubmitting } = useSubmission({
taskId: task.id,
})
// Проверяем, включена ли кнопка пропуска через feature flag
const skipEnabled = getFeatureValue('challenge', 'task.skip.enabled', 'false').value === 'true'
// Сохраняем последний результат, чтобы блок не исчезал
const [lastResult, setLastResult] = useState<typeof finalSubmission>(null)
// Состояние для показа дополнительного материала
const [showLearningMaterial, setShowLearningMaterial] = useState(false)
const isChecking = !!queueStatus || isSubmitting
const isAccepted = finalSubmission?.status === 'accepted'
const needsRevision = finalSubmission?.status === 'needs_revision'
// Вычисляем прогресс проверки (0-100%)
const checkingProgress = (() => {
if (!queueStatus) return 0
const initial = queueStatus.initialPosition || 3
const current = queueStatus.position || 0
if (queueStatus.status === 'in_progress') return 90 // Почти готово
if (current === 0) return 90
// От 0% до 80% по мере движения в очереди
const progress = ((initial - current) / initial) * 80
return Math.max(10, progress) // Минимум 10% чтобы было видно
})()
// Сбрасываем состояние при смене задания
useEffect(() => {
setLastResult(null)
setShowLearningMaterial(false)
}, [task.id])
// Обновляем сохраненный результат только когда получаем новый
useEffect(() => {
if (finalSubmission) {
@@ -64,7 +73,7 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
refreshStats()
}
}, [finalSubmission, refreshStats])
// Используем либо текущий результат, либо последний сохраненный
const displayedSubmission = finalSubmission || lastResult
const showAccepted = displayedSubmission?.status === 'accepted'
@@ -72,61 +81,84 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
return (
<VStack align="stretch" gap={3}>
{/* Дополнительные материалы - показываются сверху */}
{task.learningMaterial && showLearningMaterial && (
<LearningMaterialViewer
content={task.learningMaterial}
linesPerPage={30}
/>
)}
<Box borderWidth="1px" borderRadius="md" borderColor="gray.200" p={4} bg="white" shadow="sm">
{/* Кнопка для показа дополнительного материала */}
{task.learningMaterial && !showLearningMaterial && (
<HStack justify="center" mb={4}>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={() => setShowLearningMaterial(true)}
>
📚 Прочитать доп. материал
</Button>
</HStack>
)}
<Text fontSize="lg" fontWeight="bold" mb={3} color="gray.800">
{task.title}
</Text>
<Box
color="gray.700"
fontSize="sm"
lineHeight="1.7"
css={{
// Заголовки
'& h1': {
fontSize: '1.75em',
fontWeight: '700',
marginTop: '1.2em',
'& h1': {
fontSize: '1.75em',
fontWeight: '700',
marginTop: '1.2em',
marginBottom: '0.6em',
color: '#2D3748',
borderBottom: '2px solid #E2E8F0',
paddingBottom: '0.3em'
},
'& h2': {
fontSize: '1.5em',
fontWeight: '600',
marginTop: '1em',
'& h2': {
fontSize: '1.5em',
fontWeight: '600',
marginTop: '1em',
marginBottom: '0.5em',
color: '#2D3748'
},
'& h3': {
fontSize: '1.25em',
fontWeight: '600',
marginTop: '0.8em',
'& h3': {
fontSize: '1.25em',
fontWeight: '600',
marginTop: '0.8em',
marginBottom: '0.4em',
color: '#2D3748'
},
'& h4': {
fontSize: '1.1em',
fontWeight: '600',
marginTop: '0.6em',
'& h4': {
fontSize: '1.1em',
fontWeight: '600',
marginTop: '0.6em',
marginBottom: '0.3em',
color: '#4A5568'
},
// Параграфы
'& p': {
marginTop: '0.75em',
'& p': {
marginTop: '0.75em',
marginBottom: '0.75em',
lineHeight: '1.8'
},
// Списки
'& ul, & ol': {
marginLeft: '1.5em',
marginTop: '0.75em',
'& ul, & ol': {
marginLeft: '1.5em',
marginTop: '0.75em',
marginBottom: '0.75em',
paddingLeft: '0.5em'
},
'& li': {
marginTop: '0.4em',
'& li': {
marginTop: '0.4em',
marginBottom: '0.4em',
lineHeight: '1.7'
},
@@ -135,41 +167,41 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
marginBottom: '0.25em'
},
// Инлайн-код
'& code': {
backgroundColor: '#EDF2F7',
'& code': {
backgroundColor: '#EDF2F7',
color: '#C53030',
padding: '0.15em 0.4em',
borderRadius: '4px',
padding: '0.15em 0.4em',
borderRadius: '4px',
fontSize: '0.9em',
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
fontWeight: '500'
},
// Блоки кода
'& pre': {
backgroundColor: '#1A202C',
'& pre': {
backgroundColor: '#1A202C',
color: '#E2E8F0',
padding: '1em 1.2em',
borderRadius: '8px',
overflowX: 'auto',
marginTop: '1em',
padding: '1em 1.2em',
borderRadius: '8px',
overflowX: 'auto',
marginTop: '1em',
marginBottom: '1em',
border: '1px solid #2D3748',
fontSize: '0.9em',
lineHeight: '1.6'
},
'& pre code': {
backgroundColor: 'transparent',
'& pre code': {
backgroundColor: 'transparent',
color: '#E2E8F0',
padding: '0',
fontFamily: 'Monaco, Consolas, "Courier New", monospace'
},
// Цитаты
'& blockquote': {
borderLeft: '4px solid #4299E1',
paddingLeft: '1em',
'& blockquote': {
borderLeft: '4px solid #4299E1',
paddingLeft: '1em',
paddingTop: '0.5em',
paddingBottom: '0.5em',
marginLeft: '0',
marginLeft: '0',
marginTop: '1em',
marginBottom: '1em',
fontStyle: 'italic',
@@ -182,8 +214,8 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
marginBottom: '0.25em'
},
// Ссылки
'& a': {
color: '#3182CE',
'& a': {
color: '#3182CE',
textDecoration: 'underline',
fontWeight: '500',
transition: 'color 0.2s',
@@ -242,18 +274,29 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
}
}}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{task.description}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node: _node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
)
}}
>
{task.description}
</ReactMarkdown>
</Box>
</Box>
{/* Статус проверки и результат - фиксированное место */}
<Box minH="80px">
{queueStatus && !finalSubmission ? (
<Box
borderWidth="2px"
borderRadius="lg"
borderColor="blue.300"
bg="blue.50"
<Box
borderWidth="2px"
borderRadius="lg"
borderColor="blue.300"
bg="blue.50"
p={4}
>
<VStack gap={3} align="stretch">
@@ -262,13 +305,13 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
Проверяем решение...
</Text>
</HStack>
<Box>
{/* Кастомный прогресс-бар */}
<Box
bg="blue.100"
borderRadius="md"
h="24px"
<Box
bg="blue.100"
borderRadius="md"
h="24px"
overflow="hidden"
position="relative"
>
@@ -364,13 +407,21 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
/>
</Box>
<HStack justify="flex-end" gap={2}>
<HStack justify="space-between" gap={2}>
{!isAccepted && (
<>
{/* @ts-expect-error Chakra UI v2 uses isDisabled */}
<Button onClick={reset} variant="ghost" size="sm" isDisabled={isChecking}>
Сбросить
</Button>
{skipEnabled && (
<Button
onClick={onTaskSkip}
variant="outline"
size="sm"
colorScheme="gray"
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={isChecking}
>
Пропустить
</Button>
)}
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
<Button onClick={submit} colorScheme="teal" size="sm" isLoading={isChecking} isDisabled={!result.trim() || isChecking}>
{needsRevision ? 'Отправить снова' : 'Отправить на проверку'}

View File

@@ -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<string, { data: unknown; expires: number }>()
@@ -66,6 +65,7 @@ interface ChallengeContextValue {
login: (nickname: string, workplaceNumber: string) => Promise<void>
logout: () => void
refreshStats: () => Promise<void>
refreshChains: () => Promise<void>
eventEmitter: ChallengeEventEmitter
pollingManager: PollingManager
metricsCollector: MetricsCollector
@@ -77,26 +77,16 @@ interface ChallengeContextValue {
const ChallengeContext = createContext<ChallengeContextValue | undefined>(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(), [])
const behaviorTracker = useMemo(() => new BehaviorTracker(), [])
const eventEmitter = useMemo(() => new ChallengeEventEmitter(), [])
const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1.15 }), [])
const pollingManager = useMemo(() => new PollingManager({ initialDelay: 800, maxDelay: 5000, multiplier: 1 }), [])
const [userId, setUserId] = useState<string | null>(() =>
isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null,
)
const [nickname, setNickname] = useState<string | null>(() =>
isBrowser() ? window.localStorage.getItem(USER_NICKNAME_KEY) : null,
)
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() =>
isBrowser() ? window.localStorage.getItem(WORKPLACE_NUMBER_KEY) : null,
)
const [userId, setUserId] = useState<string | null>(() => storage.getUserId())
const [nickname, setNickname] = useState<string | null>(() => storage.getNickname())
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() => storage.getWorkplaceNumber())
const [stats, setStats] = useState<UserStats | null>(null)
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
const [chains, setChains] = useState<ChallengeChain[]>(() => {
@@ -107,10 +97,23 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
const [authUser, { isLoading: isAuthLoading }] = useAuthUserMutation()
const [triggerStats, statsResult] = useLazyGetUserStatsQuery()
const { data: chainsData, isLoading: isChainsLoading } = useGetChainsQuery(undefined, {
const {
data: chainsData,
isLoading: isChainsLoading,
refetch: refetchChains,
} = useGetChainsQuery(undefined, {
skip: !userId,
})
const refreshChains = useCallback(async () => {
if (!userId) {
return
}
cacheRef.current.clear('chains')
await refetchChains()
}, [refetchChains, userId])
useEffect(() => {
if (chainsData) {
setChains(chainsData)
@@ -157,16 +160,17 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
const login = useCallback(
async (nicknameValue: string, workplaceNumberValue: string) => {
const response = await authUser({ nickname: nicknameValue }).unwrap()
const response = await authUser({
nickname: nicknameValue,
workplaceNumber: workplaceNumberValue || undefined
}).unwrap()
setUserId(response.userId)
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 +186,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
@@ -207,6 +206,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
login,
logout,
refreshStats,
refreshChains,
eventEmitter,
pollingManager,
metricsCollector,
@@ -231,6 +231,7 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
personalDashboard,
pollingManager,
refreshStats,
refreshChains,
saveDraft,
stats,
userId,

View File

@@ -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) => (
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
)
// Компонент для редиректа на нужную страницу
const IndexRedirect = () => {
const workplaceNumber = storage.getWorkplaceNumber()
const nickname = storage.getNickname()
const chainId = storage.getSelectedChainId()
const taskId = storage.getSelectedTaskId()
// Если есть сохранённое задание - туда
if (nickname && chainId && taskId) {
return <Navigate to={URLs.task(chainId, taskId)} replace />
}
// Если авторизован - к цепочкам
if (nickname) {
return <Navigate to={URLs.chains} replace />
}
// Если есть номер места - к логину
if (workplaceNumber) {
return <Navigate to={URLs.login} replace />
}
// Иначе - к вводу места
return <Navigate to={URLs.workplace} replace />
}
export const Dashboard = () => {
return (
<Routes>
{/* Главная - редирект */}
<Route
path={URLs.baseUrl}
element={<IndexRedirect />}
/>
{/* Ввод номера рабочего места */}
<Route
path={URLs.workplace}
element={
<PageWrapper>
<MainPage />
<WorkplacePage />
</PageWrapper>
}
/>
{/* Ввод ФИО */}
<Route
path={URLs.login}
element={
<PageWrapper>
<LoginPage />
</PageWrapper>
}
/>
{/* Выбор цепочки */}
<Route
path={URLs.chains}
element={
<PageWrapper>
<ChainsPage />
</PageWrapper>
}
/>
{/* Задание */}
<Route
path={`${URLs.baseUrl}/chain/:chainId/task/:taskId`}
element={
<PageWrapper>
<TaskPage />
</PageWrapper>
}
/>
{/* Завершение цепочки */}
<Route
path={`${URLs.baseUrl}/completed/:chainId`}
element={
<PageWrapper>
<CompletedPage />
</PageWrapper>
}
/>
{/* Fallback */}
<Route
path="*"
element={<IndexRedirect />}
/>
</Routes>
)
}

View File

@@ -6,6 +6,7 @@ import {
} from '../__data__/api/api'
import type { ChallengeSubmission, QueueStatus } from '../__data__/types'
import { useChallenge } from '../context/ChallengeContext'
import { loadFinalAnswer, saveFinalAnswer } from '../utils/drafts'
interface UseSubmissionArgs {
taskId: string
@@ -43,13 +44,24 @@ export const useSubmission = ({ taskId }: UseSubmissionArgs): SubmissionResult =
useEffect(() => {
behaviorTracker.reset()
const draft = loadDraft(taskId)
if (draft) {
setResultState(draft)
// Сначала проверяем финальный ответ (если задание уже решалось)
const finalAnswer = loadFinalAnswer(taskId)
if (finalAnswer) {
setResultState(finalAnswer)
behaviorTracker.markDraftUsed()
} else {
setResultState('')
// Если финального ответа нет, проверяем черновик
const draft = loadDraft(taskId)
if (draft) {
setResultState(draft)
behaviorTracker.markDraftUsed()
} else {
// Если ничего нет - пустое поле
setResultState('')
}
}
pollingManager.stop()
setQueueId(null)
setQueueStatus(null)
@@ -114,6 +126,9 @@ export const useSubmission = ({ taskId }: UseSubmissionArgs): SubmissionResult =
})
setFinalSubmission(status.submission)
// Сохраняем финальный ответ для восстановления при возврате
saveFinalAnswer(taskId, result)
// Очищаем черновик, так как теперь есть финальный ответ
clearDraft(taskId)
pollingManager.stop()
return false

View File

@@ -0,0 +1,61 @@
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, refreshChains } = useChallenge()
// Проверяем авторизацию
useEffect(() => {
const workplaceNumber = storage.getWorkplaceNumber()
if (!workplaceNumber) {
navigate(URLs.workplace, { replace: true })
return
}
if (!nickname) {
navigate(URLs.login, { replace: true })
}
}, [navigate, nickname])
useEffect(() => {
refreshChains()
}, [refreshChains])
const handleSelectChain = (chain: ChallengeChain) => {
storage.setSelectedChainId(chain.id)
if (chain.tasks.length > 0) {
// Получаем самый дальний достигнутый индекс
const furthestIndex = storage.getFurthestTaskIndex(chain.id)
// Если нет прогресса, инициализируем с первого задания
const targetIndex = furthestIndex >= 0 ? furthestIndex : 0
const targetTask = chain.tasks[targetIndex] || chain.tasks[0]
storage.setSelectedTaskId(targetTask.id)
// Убеждаемся, что прогресс установлен
storage.setFurthestTaskIndex(chain.id, targetIndex)
navigate(URLs.task(chain.id, targetTask.id))
}
}
if (!nickname) {
return null
}
return (
<>
<Header />
<ChainSelector onSelectChain={handleSelectChain} />
</>
)
}

View File

@@ -0,0 +1,4 @@
import { ChainsPage } from './ChainsPage'
export default ChainsPage

View File

@@ -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 (
<>
<Header />
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
bg="white"
borderWidth="2px"
borderRadius="xl"
borderColor="green.300"
p={10}
maxW="600px"
w="full"
textAlign="center"
shadow="lg"
>
<VStack gap={6}>
<Text fontSize="6xl">🎉</Text>
<Heading size="xl" color="green.600">
Поздравляем!
</Heading>
<Text fontSize="lg" color="gray.700">
Вы успешно выполнили все задания
</Text>
{chain && (
<Box
bg="green.50"
borderRadius="lg"
px={6}
py={3}
borderWidth="1px"
borderColor="green.200"
>
<Text fontSize="xl" fontWeight="bold" color="green.700">
{chain.name}
</Text>
</Box>
)}
<Text fontSize="md" color="gray.600">
Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий.
</Text>
<Button
colorScheme="green"
size="lg"
onClick={handleContinue}
mt={4}
>
Продолжить
</Button>
</VStack>
</Box>
</Box>
</>
)
}

View File

@@ -0,0 +1,4 @@
import { CompletedPage } from './CompletedPage'
export default CompletedPage

View File

@@ -1,3 +1,8 @@
import { lazy } from 'react'
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
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'))

View File

@@ -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 (
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
bg="white"
borderWidth="1px"
borderRadius="lg"
borderColor="gray.200"
p={8}
maxW="480px"
w="full"
>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={6} align="stretch">
<Box textAlign="center">
<Heading size="lg" color="teal.600" mb={2}>
Challenge Platform
</Heading>
<Text color="gray.600" fontWeight="medium">
Рабочее место: {workplaceNumber}
</Text>
<Text color="gray.600" mt={3}>
Введите ваше ФИО для начала работы
</Text>
</Box>
<form onSubmit={handleSubmit}>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={4} align="stretch">
<Box>
<Text fontWeight="medium" mb={2}>
Ваше ФИО
</Text>
<Input
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Иванов Иван Иванович"
size="lg"
autoFocus
// @ts-expect-error Chakra UI v2 uses isInvalid
isInvalid={!!error}
/>
{error && (
<Text color="red.500" fontSize="sm" mt={2}>
{error}
</Text>
)}
</Box>
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
<Button type="submit" colorScheme="teal" size="lg" isLoading={isAuthLoading} isDisabled={!fullName.trim()}>
Войти
</Button>
</VStack>
</form>
<Box textAlign="center" mt={120}>
<Text
as="button"
type="button"
color="gray.500"
fontSize="sm"
cursor="pointer"
_hover={{ color: 'teal.600', textDecoration: 'underline' }}
onClick={handleChangeWorkplace}
>
Изменить рабочее место
</Text>
</Box>
</VStack>
</Box>
</Box>
)
}

4
src/pages/login/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { LoginPage } from './LoginPage'
export default LoginPage

View File

@@ -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<ChallengeChain | null>(null)
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null)
const [completedChainName, setCompletedChainName] = useState<string | null>(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 <Navigate to={URLs.task(chainId, taskId)} replace />
}
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 <Navigate to={URLs.chains} replace />
}
const handleContinueAfterCompletion = () => {
setCompletedChainName(null)
// Если есть номер места - к логину
if (workplaceNumber) {
return <Navigate to={URLs.login} replace />
}
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 <LoginForm />
}
// Если цепочка завершена, показываем экран поздравления
if (completedChainName) {
return (
<>
<Header />
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
bg="white"
borderWidth="2px"
borderRadius="xl"
borderColor="green.300"
p={10}
maxW="600px"
w="full"
textAlign="center"
shadow="lg"
>
<VStack gap={6}>
<Text fontSize="6xl">🎉</Text>
<Heading size="xl" color="green.600">
Поздравляем!
</Heading>
<Text fontSize="lg" color="gray.700">
Вы успешно выполнили все задания
</Text>
<Box
bg="green.50"
borderRadius="lg"
px={6}
py={3}
borderWidth="1px"
borderColor="green.200"
>
<Text fontSize="xl" fontWeight="bold" color="green.700">
{completedChainName}
</Text>
</Box>
<Text fontSize="md" color="gray.600">
Отличная работа! Вы можете продолжить обучение, выбрав другую цепочку заданий.
</Text>
<Button
colorScheme="green"
size="lg"
onClick={handleContinueAfterCompletion}
mt={4}
>
Продолжить
</Button>
</VStack>
</Box>
</Box>
</>
)
}
// Если цепочка не выбрана, показываем селектор цепочек
if (!selectedChain) {
return (
<>
<Header />
<ChainSelector onSelectChain={handleSelectChain} />
</>
)
}
const taskProgress = `Задание ${selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1}` // из ${selectedChain.tasks.length}`
// Показываем выбранную цепочку и задания
return (
<>
<Header chainName={selectedChain.name} taskProgress={taskProgress} />
<Box bg="gray.50" minH="100vh" py={4} px={{ base: 4, md: 8 }}>
<Box maxW="1200px" mx="auto">
{isOffline && (
<Alert.Root status="warning" borderRadius="md" mb={4}>
<Alert.Indicator />
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
</Alert.Root>
)}
{selectedTask && (
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
)}
</Box>
</Box>
</>
)
// Иначе - к вводу места
return <Navigate to={URLs.workplace} replace />
}

268
src/pages/task/TaskPage.tsx Normal file
View File

@@ -0,0 +1,268 @@
import React, { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Box,
Button,
Flex,
HStack,
Text,
} from '@chakra-ui/react'
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])
// Получаем самый дальний достигнутый индекс задания (используем state для реактивности)
const [furthestTaskIndex, setFurthestTaskIndex] = useState(() => {
if (!chainId) return 0
return storage.getFurthestTaskIndex(chainId)
})
// Отслеживаем пропущенные задания
const [skippedTasks, setSkippedTasks] = useState<string[]>(() => {
if (!chainId) return []
return storage.getSkippedTasks(chainId)
})
// Обновляем furthestTaskIndex при изменении chainId или currentTaskIndex
useEffect(() => {
if (!chainId) return
const currentFurthest = storage.getFurthestTaskIndex(chainId)
setFurthestTaskIndex(currentFurthest)
// Также обновляем список пропущенных заданий
const currentSkipped = storage.getSkippedTasks(chainId)
setSkippedTasks(currentSkipped)
}, [chainId, currentTaskIndex])
// Сохраняем текущее состояние в storage и обновляем прогресс
useEffect(() => {
if (chainId && taskId && currentTaskIndex >= 0) {
storage.setSelectedChainId(chainId)
storage.setSelectedTaskId(taskId)
// Обновляем прогресс, если текущее задание дальше предыдущего
const newFurthest = Math.max(furthestTaskIndex, currentTaskIndex)
if (newFurthest > furthestTaskIndex) {
storage.setFurthestTaskIndex(chainId, newFurthest)
setFurthestTaskIndex(newFurthest)
}
}
}, [chainId, taskId, currentTaskIndex, furthestTaskIndex])
// Проверка доступности задания
const isTaskAccessible = (taskIndex: number): boolean => {
return taskIndex <= furthestTaskIndex
}
const handleTaskSkip = () => {
if (!chain || currentTaskIndex === -1 || !taskId) return
// Добавляем задание в список пропущенных
storage.addSkippedTask(chain.id, taskId)
setSkippedTasks(storage.getSkippedTasks(chain.id))
const nextTaskIndex = currentTaskIndex + 1
const nextTask = chain.tasks[nextTaskIndex]
if (nextTask) {
// Обновляем прогресс перед переходом
storage.setFurthestTaskIndex(chain.id, nextTaskIndex)
setFurthestTaskIndex(nextTaskIndex) // Обновляем state сразу
navigate(URLs.task(chain.id, nextTask.id))
} else {
// Достигнут конец списка заданий - проверяем пропущенные
const currentSkipped = storage.getSkippedTasks(chain.id)
if (currentSkipped.length > 0) {
// Есть пропущенные задания - переходим к первому пропущенному
const firstSkippedId = currentSkipped[0]
navigate(URLs.task(chain.id, firstSkippedId))
} else {
// Нет пропущенных заданий - переходим на страницу завершения
storage.clearSessionData()
navigate(URLs.completed(chain.id))
}
}
}
const handleTaskComplete = () => {
if (!chain || currentTaskIndex === -1) return
// При успешном выполнении удаляем задание из пропущенных (если оно там было)
if (taskId) {
storage.removeSkippedTask(chain.id, taskId)
setSkippedTasks(storage.getSkippedTasks(chain.id))
}
const nextTaskIndex = currentTaskIndex + 1
const nextTask = chain.tasks[nextTaskIndex]
if (nextTask) {
// Обновляем прогресс перед переходом
storage.setFurthestTaskIndex(chain.id, nextTaskIndex)
setFurthestTaskIndex(nextTaskIndex) // Обновляем state сразу
navigate(URLs.task(chain.id, nextTask.id))
} else {
// Достигнут конец списка заданий - проверяем пропущенные
const currentSkipped = storage.getSkippedTasks(chain.id)
if (currentSkipped.length > 0) {
// Есть пропущенные задания - переходим к первому пропущенному
const firstSkippedId = currentSkipped[0]
navigate(URLs.task(chain.id, firstSkippedId))
} else {
// Нет пропущенных заданий - переходим на страницу завершения
storage.clearSessionData()
navigate(URLs.completed(chain.id))
}
}
}
const handleNavigateToTask = (newTaskId: string) => {
if (!chain) return
const newTaskIndex = chain.tasks.findIndex(t => t.id === newTaskId)
if (newTaskIndex === -1) return
// Проверяем доступность
if (!isTaskAccessible(newTaskIndex)) {
return // Не переходим к заблокированному заданию
}
// Обновляем прогресс при переходе
const newFurthest = Math.max(furthestTaskIndex, newTaskIndex)
if (newFurthest > furthestTaskIndex) {
storage.setFurthestTaskIndex(chain.id, newFurthest)
setFurthestTaskIndex(newFurthest)
}
navigate(URLs.task(chain.id, newTaskId))
}
// Проверяем доступность текущего задания при загрузке
useEffect(() => {
if (chain && currentTaskIndex >= 0 && !isTaskAccessible(currentTaskIndex)) {
// Если пытаемся открыть недоступное задание, перенаправляем на последнее доступное
const lastAccessibleIndex = furthestTaskIndex
if (lastAccessibleIndex >= 0 && chain.tasks[lastAccessibleIndex]) {
navigate(URLs.task(chain.id, chain.tasks[lastAccessibleIndex].id), { replace: true })
} else if (chain.tasks[0]) {
// Если нет прогресса, идём к первому заданию
navigate(URLs.task(chain.id, chain.tasks[0].id), { replace: true })
}
}
}, [chain, currentTaskIndex, furthestTaskIndex, navigate])
const handleBackToChains = () => {
storage.clearSessionData()
navigate(URLs.chains)
}
if (!nickname || !chain || !task) {
return null
}
const taskProgress = `Задание`
return (
<>
<Header chainName={chain.name} taskProgress={taskProgress} />
<Box bg="gray.50" minH="100vh" py={4} px={{ base: 4, md: 8 }}>
<Box maxW="1200px" mx="auto">
{/* Навигация по заданиям */}
<Box
bg="white"
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
p={3}
mb={4}
shadow="sm"
>
<Flex justify="space-between" align="center" flexWrap="wrap" gap={3}>
<HStack gap={2} flexWrap="wrap">
<Text fontSize="sm" fontWeight="medium" color="gray.600" mr={2}>
Задания:
</Text>
{chain.tasks
.filter((_, index) => {
const isAccessible = isTaskAccessible(index)
// Показываем все доступные + только следующую недоступную
return isAccessible || index === furthestTaskIndex + 1
})
.map((t) => {
const taskIndex = chain.tasks.indexOf(t)
const isAccessible = isTaskAccessible(taskIndex)
const isCurrent = t.id === taskId
const isSkipped = skippedTasks.includes(t.id)
return (
<Button
key={t.id}
size="sm"
variant={isCurrent ? 'solid' : isAccessible ? 'outline' : 'ghost'}
colorScheme={isCurrent ? 'teal' : isSkipped ? 'gray' : 'gray'}
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={!isAccessible}
onClick={() => isAccessible && handleNavigateToTask(t.id)}
minW="40px"
opacity={isAccessible ? (isSkipped ? 0.6 : 1) : 0.5}
cursor={isAccessible ? 'pointer' : 'not-allowed'}
bg={isSkipped && !isCurrent ? 'gray.200' : undefined}
color={isSkipped && !isCurrent ? 'gray.500' : undefined}
_hover={isAccessible ? (isSkipped ? { bg: 'gray.300' } : undefined) : undefined}
>
{isAccessible ? taskIndex + 1 : `🔒${taskIndex + 1}`}
</Button>
)
})}
</HStack>
<Button
size="sm"
variant="ghost"
colorScheme="gray"
onClick={handleBackToChains}
>
К выбору цепочки
</Button>
</Flex>
</Box>
<TaskWorkspace task={task} onTaskComplete={handleTaskComplete} onTaskSkip={handleTaskSkip} />
</Box>
</Box>
</>
)
}

4
src/pages/task/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { TaskPage } from './TaskPage'
export default TaskPage

View File

@@ -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 (
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
<Box
bg="white"
borderWidth="1px"
borderRadius="lg"
borderColor="gray.200"
p={8}
maxW="480px"
w="full"
>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={6} align="stretch">
<Box textAlign="center">
<Heading size="lg" color="teal.600" mb={2}>
Challenge Platform
</Heading>
<Text color="gray.600">
Добро пожаловать! Введите номер рабочего места
</Text>
</Box>
<form onSubmit={handleSubmit}>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={4} align="stretch">
<Box>
<Text fontWeight="medium" mb={2}>
Номер рабочего места
</Text>
<Input
value={workplaceNumber}
onChange={(e) => setWorkplaceNumber(e.target.value)}
placeholder="Например: 1"
size="lg"
autoFocus
// @ts-expect-error Chakra UI v2 uses isInvalid
isInvalid={!!error}
/>
{error && (
<Text color="red.500" fontSize="sm" mt={2}>
{error}
</Text>
)}
</Box>
{/* Полезные ссылки */}
<Box
borderWidth="1px"
borderRadius="md"
borderColor="gray.200"
p={4}
bg="gray.50"
>
<Text fontWeight="medium" mb={3} fontSize="sm">
Полезные ссылки:
</Text>
{/* @ts-expect-error Chakra UI v2 uses spacing */}
<VStack spacing={2} align="stretch">
{USEFUL_LINKS.map(link => (
<Link
key={link.url}
href={link.url}
target="_blank"
color="teal.600"
fontSize="sm"
fontWeight="medium"
_hover={{ color: 'teal.800', textDecoration: 'underline' }}
>
🔗 {link.label}
</Link>
))}
</VStack>
</Box>
<Button
type="submit"
colorScheme="teal"
size="lg"
// @ts-expect-error Chakra UI v2 uses isDisabled
isDisabled={!workplaceNumber.trim()}
>
Продолжить
</Button>
</VStack>
</form>
</VStack>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,4 @@
import { WorkplacePage } from './WorkplacePage'
export default WorkplacePage

View File

@@ -1,4 +1,5 @@
const STORAGE_PREFIX = 'challenge_draft_'
const FINAL_ANSWER_PREFIX = 'challenge_final_answer_'
const isBrowser = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
@@ -17,6 +18,24 @@ export function clearDraft(taskId: string) {
window.localStorage.removeItem(`${STORAGE_PREFIX}${taskId}`)
}
// Сохранение финального ответа (после успешной отправки)
export function saveFinalAnswer(taskId: string, result: string) {
if (!isBrowser()) return
window.localStorage.setItem(`${FINAL_ANSWER_PREFIX}${taskId}`, result)
}
// Загрузка финального ответа
export function loadFinalAnswer(taskId: string): string | null {
if (!isBrowser()) return null
return window.localStorage.getItem(`${FINAL_ANSWER_PREFIX}${taskId}`)
}
// Очистка финального ответа
export function clearFinalAnswer(taskId: string) {
if (!isBrowser()) return
window.localStorage.removeItem(`${FINAL_ANSWER_PREFIX}${taskId}`)
}
export function listDrafts() {
if (!isBrowser()) return [] as string[]
@@ -30,3 +49,16 @@ export function listDrafts() {
return keys
}
// Очистка всех финальных ответов (при выходе)
export function clearAllFinalAnswers() {
if (!isBrowser()) return
const keysToRemove: string[] = []
for (let i = 0; i < window.localStorage.length; i += 1) {
const key = window.localStorage.key(i)
if (key?.startsWith(FINAL_ANSWER_PREFIX)) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => window.localStorage.removeItem(key))
}

View File

@@ -16,7 +16,7 @@ export class PollingManager {
constructor(options: PollingOptions = {}) {
this.currentDelay = options.initialDelay ?? 2000
this.maxDelay = options.maxDelay ?? 10000
this.multiplier = options.multiplier ?? 1.5
this.multiplier = options.multiplier ?? 1.01
}
async start(callback: PollCallback) {

204
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,204 @@
/**
* Централизованная работа с localStorage
* Все ключи и операции в одном месте
*/
import { clearAllFinalAnswers, listDrafts, clearDraft } from './drafts'
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
// Вспомогательные функции для ключей
const getFurthestTaskKey = (chainId: string) => `challengeFurthestTask_${chainId}`
const getSkippedTasksKey = (chainId: string) => `challengeSkippedTasks_${chainId}`
// Получение значений
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.SELECTED_CHAIN_ID)
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
// Очищаем все прогрессы по цепочкам
storage.clearAllChainProgress()
// Очищаем все финальные ответы
clearAllFinalAnswers()
// Очищаем все черновики
const drafts = listDrafts()
drafts.forEach(taskId => clearDraft(taskId))
},
// Очистка всех прогрессов по цепочкам
clearAllChainProgress: (): void => {
if (!isBrowser()) return
// Перебираем все ключи localStorage и удаляем те, что начинаются с challengeFurthestTask_ или challengeSkippedTasks_
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && (key.startsWith('challengeFurthestTask_') || key.startsWith('challengeSkippedTasks_'))) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => localStorage.removeItem(key))
},
// Очистка данных сессии (цепочка, задание) без выхода
clearSessionData: (): void => {
if (!isBrowser()) return
localStorage.removeItem(STORAGE_KEYS.SELECTED_CHAIN_ID)
localStorage.removeItem(STORAGE_KEYS.SELECTED_TASK_ID)
},
// Получение самого дальнего достигнутого индекса задания в цепочке
getFurthestTaskIndex: (chainId: string): number => {
if (!isBrowser()) return 0
const value = localStorage.getItem(getFurthestTaskKey(chainId))
return value ? parseInt(value, 10) : 0
},
// Установка самого дальнего достигнутого индекса задания
setFurthestTaskIndex: (chainId: string, index: number): void => {
if (!isBrowser()) return
const current = storage.getFurthestTaskIndex(chainId)
// Обновляем только если новый индекс больше текущего
if (index > current) {
localStorage.setItem(getFurthestTaskKey(chainId), index.toString())
}
},
// Очистка прогресса цепочки
clearChainProgress: (chainId: string): void => {
if (!isBrowser()) return
localStorage.removeItem(getFurthestTaskKey(chainId))
},
// Получение пропущенных заданий для цепочки
getSkippedTasks: (chainId: string): string[] => {
if (!isBrowser()) return []
const value = localStorage.getItem(getSkippedTasksKey(chainId))
return value ? JSON.parse(value) : []
},
// Добавление задания в список пропущенных
addSkippedTask: (chainId: string, taskId: string): void => {
if (!isBrowser()) return
const skipped = storage.getSkippedTasks(chainId)
if (!skipped.includes(taskId)) {
skipped.push(taskId)
localStorage.setItem(getSkippedTasksKey(chainId), JSON.stringify(skipped))
}
},
// Удаление задания из списка пропущенных (когда оно выполнено)
removeSkippedTask: (chainId: string, taskId: string): void => {
if (!isBrowser()) return
const skipped = storage.getSkippedTasks(chainId)
const filtered = skipped.filter(id => id !== taskId)
localStorage.setItem(getSkippedTasksKey(chainId), JSON.stringify(filtered))
},
// Проверка, пропущено ли задание
isTaskSkipped: (chainId: string, taskId: string): boolean => {
if (!isBrowser()) return false
const skipped = storage.getSkippedTasks(chainId)
return skipped.includes(taskId)
},
// Очистка всех пропущенных заданий цепочки
clearSkippedTasks: (chainId: string): void => {
if (!isBrowser()) return
localStorage.removeItem(getSkippedTasksKey(chainId))
},
}

File diff suppressed because one or more lines are too long

View File

@@ -24,7 +24,13 @@ router.use((req, res, next) => {
// Challenge API endpoints
router.post('/challenge/auth', (req, res) => {
res.json(readJson('auth.json'))
const { nickname, workplaceNumber } = req.body
const response = readJson('auth.json')
// Логируем для отладки
console.log('Auth request:', { nickname, workplaceNumber })
res.json(response)
})
router.get('/challenge/chains', (req, res) => {