Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6fae66881 | |||
| b9af3c4ee5 | |||
| f7df4c755d | |||
| 0092e55b65 | |||
| 08b654bd4d | |||
| cbf411cd54 | |||
| f4e85fe980 | |||
| 3c1a235832 | |||
| 1a52901b90 | |||
| c9bbe83bbb |
202
UPDATE.md
Normal file
202
UPDATE.md
Normal 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 автоматически
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "challenge",
|
"name": "challenge",
|
||||||
"version": "1.1.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "challenge",
|
"name": "challenge",
|
||||||
"version": "1.1.0",
|
"version": "1.3.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@brojs/cli": "^1.9.4",
|
"@brojs/cli": "^1.9.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "challenge",
|
"name": "challenge",
|
||||||
"version": "1.1.0",
|
"version": "1.3.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "./src/index.tsx",
|
"main": "./src/index.tsx",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface ChallengeTask {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
learningMaterial?: string
|
||||||
hiddenInstructions?: string
|
hiddenInstructions?: string
|
||||||
creator?: Record<string, unknown>
|
creator?: Record<string, unknown>
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ const getNavPath = (key: string, fallback: string) => {
|
|||||||
|
|
||||||
export const URLs = {
|
export const URLs = {
|
||||||
baseUrl,
|
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: {
|
auth: {
|
||||||
url: makeUrl(navs[`link.${pkg.name}.auth`]),
|
url: makeUrl(navs[`link.${pkg.name}.auth`]),
|
||||||
isOn: Boolean(navs[`link.${pkg.name}.auth`]),
|
isOn: Boolean(navs[`link.${pkg.name}.auth`]),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react'
|
import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react'
|
||||||
|
|
||||||
import { useChallenge } from '../context/ChallengeContext'
|
import { useChallenge } from '../context/ChallengeContext'
|
||||||
|
import { URLs } from '../__data__/urls'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
chainName?: string
|
chainName?: string
|
||||||
@@ -9,8 +11,14 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { nickname, workplaceNumber, logout } = useChallenge()
|
const { nickname, workplaceNumber, logout } = useChallenge()
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate(URLs.workplace)
|
||||||
|
}
|
||||||
|
|
||||||
if (!nickname) return null
|
if (!nickname) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +50,7 @@ export const Header = ({ chainName, taskProgress }: HeaderProps) => {
|
|||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
<Button onClick={logout} variant="ghost" size="sm">
|
<Button onClick={handleLogout} variant="ghost" size="sm">
|
||||||
Выйти
|
Выйти
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
271
src/components/personal/LearningMaterialViewer.tsx
Normal file
271
src/components/personal/LearningMaterialViewer.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
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]}>
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,45 +13,50 @@ import remarkGfm from 'remark-gfm'
|
|||||||
import type { ChallengeTask } from '../../__data__/types'
|
import type { ChallengeTask } from '../../__data__/types'
|
||||||
import { useChallenge } from '../../context/ChallengeContext'
|
import { useChallenge } from '../../context/ChallengeContext'
|
||||||
import { useSubmission } from '../../hooks/useSubmission'
|
import { useSubmission } from '../../hooks/useSubmission'
|
||||||
|
import { LearningMaterialViewer } from './LearningMaterialViewer'
|
||||||
|
|
||||||
interface TaskWorkspaceProps {
|
interface TaskWorkspaceProps {
|
||||||
task: ChallengeTask
|
task: ChallengeTask
|
||||||
onTaskComplete?: () => void
|
onTaskComplete?: () => void
|
||||||
|
onTaskSkip?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
export const TaskWorkspace = ({ task, onTaskComplete, onTaskSkip }: TaskWorkspaceProps) => {
|
||||||
const { refreshStats } = useChallenge()
|
const { refreshStats } = useChallenge()
|
||||||
const { result, setResult, submit, reset, queueStatus, finalSubmission, isSubmitting } = useSubmission({
|
const { result, setResult, submit, queueStatus, finalSubmission, isSubmitting } = useSubmission({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Сохраняем последний результат, чтобы блок не исчезал
|
// Сохраняем последний результат, чтобы блок не исчезал
|
||||||
const [lastResult, setLastResult] = useState<typeof finalSubmission>(null)
|
const [lastResult, setLastResult] = useState<typeof finalSubmission>(null)
|
||||||
|
// Состояние для показа дополнительного материала
|
||||||
|
const [showLearningMaterial, setShowLearningMaterial] = useState(false)
|
||||||
|
|
||||||
const isChecking = !!queueStatus || isSubmitting
|
const isChecking = !!queueStatus || isSubmitting
|
||||||
const isAccepted = finalSubmission?.status === 'accepted'
|
const isAccepted = finalSubmission?.status === 'accepted'
|
||||||
const needsRevision = finalSubmission?.status === 'needs_revision'
|
const needsRevision = finalSubmission?.status === 'needs_revision'
|
||||||
|
|
||||||
// Вычисляем прогресс проверки (0-100%)
|
// Вычисляем прогресс проверки (0-100%)
|
||||||
const checkingProgress = (() => {
|
const checkingProgress = (() => {
|
||||||
if (!queueStatus) return 0
|
if (!queueStatus) return 0
|
||||||
|
|
||||||
const initial = queueStatus.initialPosition || 3
|
const initial = queueStatus.initialPosition || 3
|
||||||
const current = queueStatus.position || 0
|
const current = queueStatus.position || 0
|
||||||
|
|
||||||
if (queueStatus.status === 'in_progress') return 90 // Почти готово
|
if (queueStatus.status === 'in_progress') return 90 // Почти готово
|
||||||
if (current === 0) return 90
|
if (current === 0) return 90
|
||||||
|
|
||||||
// От 0% до 80% по мере движения в очереди
|
// От 0% до 80% по мере движения в очереди
|
||||||
const progress = ((initial - current) / initial) * 80
|
const progress = ((initial - current) / initial) * 80
|
||||||
return Math.max(10, progress) // Минимум 10% чтобы было видно
|
return Math.max(10, progress) // Минимум 10% чтобы было видно
|
||||||
})()
|
})()
|
||||||
|
|
||||||
// Сбрасываем состояние при смене задания
|
// Сбрасываем состояние при смене задания
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLastResult(null)
|
setLastResult(null)
|
||||||
|
setShowLearningMaterial(false)
|
||||||
}, [task.id])
|
}, [task.id])
|
||||||
|
|
||||||
// Обновляем сохраненный результат только когда получаем новый
|
// Обновляем сохраненный результат только когда получаем новый
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (finalSubmission) {
|
if (finalSubmission) {
|
||||||
@@ -64,7 +69,7 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
|||||||
refreshStats()
|
refreshStats()
|
||||||
}
|
}
|
||||||
}, [finalSubmission, refreshStats])
|
}, [finalSubmission, refreshStats])
|
||||||
|
|
||||||
// Используем либо текущий результат, либо последний сохраненный
|
// Используем либо текущий результат, либо последний сохраненный
|
||||||
const displayedSubmission = finalSubmission || lastResult
|
const displayedSubmission = finalSubmission || lastResult
|
||||||
const showAccepted = displayedSubmission?.status === 'accepted'
|
const showAccepted = displayedSubmission?.status === 'accepted'
|
||||||
@@ -72,61 +77,84 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack align="stretch" gap={3}>
|
<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">
|
<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">
|
<Text fontSize="lg" fontWeight="bold" mb={3} color="gray.800">
|
||||||
{task.title}
|
{task.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
color="gray.700"
|
color="gray.700"
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
lineHeight="1.7"
|
lineHeight="1.7"
|
||||||
css={{
|
css={{
|
||||||
// Заголовки
|
// Заголовки
|
||||||
'& h1': {
|
'& h1': {
|
||||||
fontSize: '1.75em',
|
fontSize: '1.75em',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
marginTop: '1.2em',
|
marginTop: '1.2em',
|
||||||
marginBottom: '0.6em',
|
marginBottom: '0.6em',
|
||||||
color: '#2D3748',
|
color: '#2D3748',
|
||||||
borderBottom: '2px solid #E2E8F0',
|
borderBottom: '2px solid #E2E8F0',
|
||||||
paddingBottom: '0.3em'
|
paddingBottom: '0.3em'
|
||||||
},
|
},
|
||||||
'& h2': {
|
'& h2': {
|
||||||
fontSize: '1.5em',
|
fontSize: '1.5em',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginTop: '1em',
|
marginTop: '1em',
|
||||||
marginBottom: '0.5em',
|
marginBottom: '0.5em',
|
||||||
color: '#2D3748'
|
color: '#2D3748'
|
||||||
},
|
},
|
||||||
'& h3': {
|
'& h3': {
|
||||||
fontSize: '1.25em',
|
fontSize: '1.25em',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginTop: '0.8em',
|
marginTop: '0.8em',
|
||||||
marginBottom: '0.4em',
|
marginBottom: '0.4em',
|
||||||
color: '#2D3748'
|
color: '#2D3748'
|
||||||
},
|
},
|
||||||
'& h4': {
|
'& h4': {
|
||||||
fontSize: '1.1em',
|
fontSize: '1.1em',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginTop: '0.6em',
|
marginTop: '0.6em',
|
||||||
marginBottom: '0.3em',
|
marginBottom: '0.3em',
|
||||||
color: '#4A5568'
|
color: '#4A5568'
|
||||||
},
|
},
|
||||||
// Параграфы
|
// Параграфы
|
||||||
'& p': {
|
'& p': {
|
||||||
marginTop: '0.75em',
|
marginTop: '0.75em',
|
||||||
marginBottom: '0.75em',
|
marginBottom: '0.75em',
|
||||||
lineHeight: '1.8'
|
lineHeight: '1.8'
|
||||||
},
|
},
|
||||||
// Списки
|
// Списки
|
||||||
'& ul, & ol': {
|
'& ul, & ol': {
|
||||||
marginLeft: '1.5em',
|
marginLeft: '1.5em',
|
||||||
marginTop: '0.75em',
|
marginTop: '0.75em',
|
||||||
marginBottom: '0.75em',
|
marginBottom: '0.75em',
|
||||||
paddingLeft: '0.5em'
|
paddingLeft: '0.5em'
|
||||||
},
|
},
|
||||||
'& li': {
|
'& li': {
|
||||||
marginTop: '0.4em',
|
marginTop: '0.4em',
|
||||||
marginBottom: '0.4em',
|
marginBottom: '0.4em',
|
||||||
lineHeight: '1.7'
|
lineHeight: '1.7'
|
||||||
},
|
},
|
||||||
@@ -135,41 +163,41 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
|||||||
marginBottom: '0.25em'
|
marginBottom: '0.25em'
|
||||||
},
|
},
|
||||||
// Инлайн-код
|
// Инлайн-код
|
||||||
'& code': {
|
'& code': {
|
||||||
backgroundColor: '#EDF2F7',
|
backgroundColor: '#EDF2F7',
|
||||||
color: '#C53030',
|
color: '#C53030',
|
||||||
padding: '0.15em 0.4em',
|
padding: '0.15em 0.4em',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '0.9em',
|
fontSize: '0.9em',
|
||||||
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
|
fontFamily: 'Monaco, Consolas, "Courier New", monospace',
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
},
|
},
|
||||||
// Блоки кода
|
// Блоки кода
|
||||||
'& pre': {
|
'& pre': {
|
||||||
backgroundColor: '#1A202C',
|
backgroundColor: '#1A202C',
|
||||||
color: '#E2E8F0',
|
color: '#E2E8F0',
|
||||||
padding: '1em 1.2em',
|
padding: '1em 1.2em',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
marginTop: '1em',
|
marginTop: '1em',
|
||||||
marginBottom: '1em',
|
marginBottom: '1em',
|
||||||
border: '1px solid #2D3748',
|
border: '1px solid #2D3748',
|
||||||
fontSize: '0.9em',
|
fontSize: '0.9em',
|
||||||
lineHeight: '1.6'
|
lineHeight: '1.6'
|
||||||
},
|
},
|
||||||
'& pre code': {
|
'& pre code': {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
color: '#E2E8F0',
|
color: '#E2E8F0',
|
||||||
padding: '0',
|
padding: '0',
|
||||||
fontFamily: 'Monaco, Consolas, "Courier New", monospace'
|
fontFamily: 'Monaco, Consolas, "Courier New", monospace'
|
||||||
},
|
},
|
||||||
// Цитаты
|
// Цитаты
|
||||||
'& blockquote': {
|
'& blockquote': {
|
||||||
borderLeft: '4px solid #4299E1',
|
borderLeft: '4px solid #4299E1',
|
||||||
paddingLeft: '1em',
|
paddingLeft: '1em',
|
||||||
paddingTop: '0.5em',
|
paddingTop: '0.5em',
|
||||||
paddingBottom: '0.5em',
|
paddingBottom: '0.5em',
|
||||||
marginLeft: '0',
|
marginLeft: '0',
|
||||||
marginTop: '1em',
|
marginTop: '1em',
|
||||||
marginBottom: '1em',
|
marginBottom: '1em',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
@@ -182,8 +210,8 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
|||||||
marginBottom: '0.25em'
|
marginBottom: '0.25em'
|
||||||
},
|
},
|
||||||
// Ссылки
|
// Ссылки
|
||||||
'& a': {
|
'& a': {
|
||||||
color: '#3182CE',
|
color: '#3182CE',
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
transition: 'color 0.2s',
|
transition: 'color 0.2s',
|
||||||
@@ -246,14 +274,16 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Статус проверки и результат - фиксированное место */}
|
{/* Статус проверки и результат - фиксированное место */}
|
||||||
<Box minH="80px">
|
<Box minH="80px">
|
||||||
{queueStatus && !finalSubmission ? (
|
{queueStatus && !finalSubmission ? (
|
||||||
<Box
|
<Box
|
||||||
borderWidth="2px"
|
borderWidth="2px"
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
borderColor="blue.300"
|
borderColor="blue.300"
|
||||||
bg="blue.50"
|
bg="blue.50"
|
||||||
p={4}
|
p={4}
|
||||||
>
|
>
|
||||||
<VStack gap={3} align="stretch">
|
<VStack gap={3} align="stretch">
|
||||||
@@ -262,13 +292,13 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
|||||||
Проверяем решение...
|
Проверяем решение...
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
{/* Кастомный прогресс-бар */}
|
{/* Кастомный прогресс-бар */}
|
||||||
<Box
|
<Box
|
||||||
bg="blue.100"
|
bg="blue.100"
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
h="24px"
|
h="24px"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
position="relative"
|
position="relative"
|
||||||
>
|
>
|
||||||
@@ -364,12 +394,18 @@ export const TaskWorkspace = ({ task, onTaskComplete }: TaskWorkspaceProps) => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<HStack justify="flex-end" gap={2}>
|
<HStack justify="space-between" gap={2}>
|
||||||
{!isAccepted && (
|
{!isAccepted && (
|
||||||
<>
|
<>
|
||||||
{/* @ts-expect-error Chakra UI v2 uses isDisabled */}
|
<Button
|
||||||
<Button onClick={reset} variant="ghost" size="sm" isDisabled={isChecking}>
|
onClick={onTaskSkip}
|
||||||
Сбросить
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
colorScheme="gray"
|
||||||
|
// @ts-expect-error Chakra UI v2 uses isDisabled
|
||||||
|
isDisabled={isChecking}
|
||||||
|
>
|
||||||
|
Пропустить
|
||||||
</Button>
|
</Button>
|
||||||
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
|
{/* @ts-expect-error Chakra UI v2 uses isLoading/isDisabled */}
|
||||||
<Button onClick={submit} colorScheme="teal" size="sm" isLoading={isChecking} isDisabled={!result.trim() || isChecking}>
|
<Button onClick={submit} colorScheme="teal" size="sm" isLoading={isChecking} isDisabled={!result.trim() || isChecking}>
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import { BehaviorTracker, MetricsCollector, buildPersonalDashboard } from '../ut
|
|||||||
import { ChallengeEventEmitter } from '../utils/events'
|
import { ChallengeEventEmitter } from '../utils/events'
|
||||||
import { clearDraft, loadDraft, saveDraft } from '../utils/drafts'
|
import { clearDraft, loadDraft, saveDraft } from '../utils/drafts'
|
||||||
import { PollingManager } from '../utils/polling'
|
import { PollingManager } from '../utils/polling'
|
||||||
|
import { storage } from '../utils/storage'
|
||||||
const isBrowser = () => typeof window !== 'undefined'
|
|
||||||
|
|
||||||
class ChallengeCache {
|
class ChallengeCache {
|
||||||
private cache = new Map<string, { data: unknown; expires: number }>()
|
private cache = new Map<string, { data: unknown; expires: number }>()
|
||||||
@@ -77,26 +76,16 @@ interface ChallengeContextValue {
|
|||||||
|
|
||||||
const ChallengeContext = createContext<ChallengeContextValue | undefined>(undefined)
|
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) => {
|
export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
||||||
const cacheRef = useRef(new ChallengeCache())
|
const cacheRef = useRef(new ChallengeCache())
|
||||||
const metricsCollector = useMemo(() => new MetricsCollector(), [])
|
const metricsCollector = useMemo(() => new MetricsCollector(), [])
|
||||||
const behaviorTracker = useMemo(() => new BehaviorTracker(), [])
|
const behaviorTracker = useMemo(() => new BehaviorTracker(), [])
|
||||||
const eventEmitter = useMemo(() => new ChallengeEventEmitter(), [])
|
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>(() =>
|
const [userId, setUserId] = useState<string | null>(() => storage.getUserId())
|
||||||
isBrowser() ? window.localStorage.getItem(USER_ID_KEY) : null,
|
const [nickname, setNickname] = useState<string | null>(() => storage.getNickname())
|
||||||
)
|
const [workplaceNumber, setWorkplaceNumber] = useState<string | null>(() => storage.getWorkplaceNumber())
|
||||||
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 [stats, setStats] = useState<UserStats | null>(null)
|
const [stats, setStats] = useState<UserStats | null>(null)
|
||||||
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
|
const [personalDashboard, setPersonalDashboard] = useState<PersonalDashboard | null>(null)
|
||||||
const [chains, setChains] = useState<ChallengeChain[]>(() => {
|
const [chains, setChains] = useState<ChallengeChain[]>(() => {
|
||||||
@@ -162,11 +151,9 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setNickname(nicknameValue)
|
setNickname(nicknameValue)
|
||||||
setWorkplaceNumber(workplaceNumberValue)
|
setWorkplaceNumber(workplaceNumberValue)
|
||||||
|
|
||||||
if (isBrowser()) {
|
storage.setUserId(response.userId)
|
||||||
window.localStorage.setItem(USER_ID_KEY, response.userId)
|
storage.setNickname(nicknameValue)
|
||||||
window.localStorage.setItem(USER_NICKNAME_KEY, nicknameValue)
|
storage.setWorkplaceNumber(workplaceNumberValue)
|
||||||
window.localStorage.setItem(WORKPLACE_NUMBER_KEY, workplaceNumberValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheRef.current.clear('chains')
|
cacheRef.current.clear('chains')
|
||||||
await refreshStatsById(response.userId)
|
await refreshStatsById(response.userId)
|
||||||
@@ -182,13 +169,8 @@ export const ChallengeProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setPersonalDashboard(null)
|
setPersonalDashboard(null)
|
||||||
cacheRef.current.clear()
|
cacheRef.current.clear()
|
||||||
|
|
||||||
if (isBrowser()) {
|
// Очищаем всё из localStorage
|
||||||
window.localStorage.removeItem(USER_ID_KEY)
|
storage.clearAll()
|
||||||
window.localStorage.removeItem(USER_NICKNAME_KEY)
|
|
||||||
window.localStorage.removeItem(WORKPLACE_NUMBER_KEY)
|
|
||||||
window.localStorage.removeItem('challengeSelectedChainId')
|
|
||||||
window.localStorage.removeItem('challengeSelectedTaskId')
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isStatsLoading = statsResult.isLoading || statsResult.isFetching || isChainsLoading
|
const isStatsLoading = statsResult.isLoading || statsResult.isFetching || isChainsLoading
|
||||||
|
|||||||
@@ -1,24 +1,107 @@
|
|||||||
import React, { Suspense } from 'react'
|
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 { 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) => (
|
const PageWrapper = ({ children }: React.PropsWithChildren) => (
|
||||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
<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 = () => {
|
export const Dashboard = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Главная - редирект */}
|
||||||
<Route
|
<Route
|
||||||
path={URLs.baseUrl}
|
path={URLs.baseUrl}
|
||||||
|
element={<IndexRedirect />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ввод номера рабочего места */}
|
||||||
|
<Route
|
||||||
|
path={URLs.workplace}
|
||||||
element={
|
element={
|
||||||
<PageWrapper>
|
<PageWrapper>
|
||||||
<MainPage />
|
<WorkplacePage />
|
||||||
</PageWrapper>
|
</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>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '../__data__/api/api'
|
} from '../__data__/api/api'
|
||||||
import type { ChallengeSubmission, QueueStatus } from '../__data__/types'
|
import type { ChallengeSubmission, QueueStatus } from '../__data__/types'
|
||||||
import { useChallenge } from '../context/ChallengeContext'
|
import { useChallenge } from '../context/ChallengeContext'
|
||||||
|
import { loadFinalAnswer, saveFinalAnswer } from '../utils/drafts'
|
||||||
|
|
||||||
interface UseSubmissionArgs {
|
interface UseSubmissionArgs {
|
||||||
taskId: string
|
taskId: string
|
||||||
@@ -43,13 +44,24 @@ export const useSubmission = ({ taskId }: UseSubmissionArgs): SubmissionResult =
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
behaviorTracker.reset()
|
behaviorTracker.reset()
|
||||||
const draft = loadDraft(taskId)
|
|
||||||
if (draft) {
|
// Сначала проверяем финальный ответ (если задание уже решалось)
|
||||||
setResultState(draft)
|
const finalAnswer = loadFinalAnswer(taskId)
|
||||||
|
if (finalAnswer) {
|
||||||
|
setResultState(finalAnswer)
|
||||||
behaviorTracker.markDraftUsed()
|
behaviorTracker.markDraftUsed()
|
||||||
} else {
|
} else {
|
||||||
setResultState('')
|
// Если финального ответа нет, проверяем черновик
|
||||||
|
const draft = loadDraft(taskId)
|
||||||
|
if (draft) {
|
||||||
|
setResultState(draft)
|
||||||
|
behaviorTracker.markDraftUsed()
|
||||||
|
} else {
|
||||||
|
// Если ничего нет - пустое поле
|
||||||
|
setResultState('')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pollingManager.stop()
|
pollingManager.stop()
|
||||||
setQueueId(null)
|
setQueueId(null)
|
||||||
setQueueStatus(null)
|
setQueueStatus(null)
|
||||||
@@ -114,6 +126,9 @@ export const useSubmission = ({ taskId }: UseSubmissionArgs): SubmissionResult =
|
|||||||
})
|
})
|
||||||
|
|
||||||
setFinalSubmission(status.submission)
|
setFinalSubmission(status.submission)
|
||||||
|
// Сохраняем финальный ответ для восстановления при возврате
|
||||||
|
saveFinalAnswer(taskId, result)
|
||||||
|
// Очищаем черновик, так как теперь есть финальный ответ
|
||||||
clearDraft(taskId)
|
clearDraft(taskId)
|
||||||
pollingManager.stop()
|
pollingManager.stop()
|
||||||
return false
|
return false
|
||||||
|
|||||||
57
src/pages/chains/ChainsPage.tsx
Normal file
57
src/pages/chains/ChainsPage.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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) {
|
||||||
|
// Получаем самый дальний достигнутый индекс
|
||||||
|
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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/chains/index.ts
Normal file
4
src/pages/chains/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { ChainsPage } from './ChainsPage'
|
||||||
|
|
||||||
|
export default ChainsPage
|
||||||
|
|
||||||
99
src/pages/completed/CompletedPage.tsx
Normal file
99
src/pages/completed/CompletedPage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/completed/index.ts
Normal file
4
src/pages/completed/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { CompletedPage } from './CompletedPage'
|
||||||
|
|
||||||
|
export default CompletedPage
|
||||||
|
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
import { lazy } from 'react'
|
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'))
|
||||||
141
src/pages/login/LoginPage.tsx
Normal file
141
src/pages/login/LoginPage.tsx
Normal 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
4
src/pages/login/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { LoginPage } from './LoginPage'
|
||||||
|
|
||||||
|
export default LoginPage
|
||||||
|
|
||||||
@@ -1,190 +1,36 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React from 'react'
|
||||||
import {
|
import { Navigate } from 'react-router-dom'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Heading,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { Alert } from '@chakra-ui/react/alert'
|
|
||||||
|
|
||||||
import type { ChallengeChain, ChallengeTask } from '../../__data__/types'
|
import { URLs } from '../../__data__/urls'
|
||||||
import { useChallenge } from '../../context/ChallengeContext'
|
import { storage } from '../../utils/storage'
|
||||||
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'
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MainPage теперь просто редиректит на нужную страницу
|
||||||
|
* Вся логика навигации находится в отдельных страницах:
|
||||||
|
* - /workplace - ввод номера рабочего места
|
||||||
|
* - /login - ввод ФИО
|
||||||
|
* - /chains - выбор цепочки
|
||||||
|
* - /chain/:chainId/task/:taskId - задание
|
||||||
|
* - /completed/:chainId - завершение цепочки
|
||||||
|
*/
|
||||||
export const MainPage = () => {
|
export const MainPage = () => {
|
||||||
const { nickname, chains } = useChallenge()
|
const workplaceNumber = storage.getWorkplaceNumber()
|
||||||
const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
|
const nickname = storage.getNickname()
|
||||||
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(null)
|
const chainId = storage.getSelectedChainId()
|
||||||
const [completedChainName, setCompletedChainName] = useState<string | null>(null)
|
const taskId = storage.getSelectedTaskId()
|
||||||
const [isOffline, setIsOffline] = useState(() =>
|
|
||||||
typeof navigator !== 'undefined' ? !navigator.onLine : false,
|
|
||||||
)
|
|
||||||
const hasRestoredState = useRef(false)
|
|
||||||
|
|
||||||
// Восстановление состояния при загрузке
|
// Если есть сохранённое задание - туда
|
||||||
useEffect(() => {
|
if (nickname && chainId && taskId) {
|
||||||
if (hasRestoredState.current || !chains.length || !nickname) return
|
return <Navigate to={URLs.task(chainId, taskId)} replace />
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
// Если авторизован - к цепочкам
|
||||||
const handleTaskComplete = () => {
|
if (nickname) {
|
||||||
if (!selectedChain) return
|
return <Navigate to={URLs.chains} replace />
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Если есть номер места - к логину
|
||||||
const handleContinueAfterCompletion = () => {
|
if (workplaceNumber) {
|
||||||
setCompletedChainName(null)
|
return <Navigate to={URLs.login} replace />
|
||||||
}
|
}
|
||||||
|
// Иначе - к вводу места
|
||||||
|
return <Navigate to={URLs.workplace} 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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
268
src/pages/task/TaskPage.tsx
Normal file
268
src/pages/task/TaskPage.tsx
Normal 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
4
src/pages/task/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { TaskPage } from './TaskPage'
|
||||||
|
|
||||||
|
export default TaskPage
|
||||||
|
|
||||||
145
src/pages/workplace/WorkplacePage.tsx
Normal file
145
src/pages/workplace/WorkplacePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
4
src/pages/workplace/index.ts
Normal file
4
src/pages/workplace/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { WorkplacePage } from './WorkplacePage'
|
||||||
|
|
||||||
|
export default WorkplacePage
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const STORAGE_PREFIX = 'challenge_draft_'
|
const STORAGE_PREFIX = 'challenge_draft_'
|
||||||
|
const FINAL_ANSWER_PREFIX = 'challenge_final_answer_'
|
||||||
|
|
||||||
const isBrowser = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
const isBrowser = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||||
|
|
||||||
@@ -17,6 +18,24 @@ export function clearDraft(taskId: string) {
|
|||||||
window.localStorage.removeItem(`${STORAGE_PREFIX}${taskId}`)
|
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() {
|
export function listDrafts() {
|
||||||
if (!isBrowser()) return [] as string[]
|
if (!isBrowser()) return [] as string[]
|
||||||
|
|
||||||
@@ -30,3 +49,16 @@ export function listDrafts() {
|
|||||||
return keys
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class PollingManager {
|
|||||||
constructor(options: PollingOptions = {}) {
|
constructor(options: PollingOptions = {}) {
|
||||||
this.currentDelay = options.initialDelay ?? 2000
|
this.currentDelay = options.initialDelay ?? 2000
|
||||||
this.maxDelay = options.maxDelay ?? 10000
|
this.maxDelay = options.maxDelay ?? 10000
|
||||||
this.multiplier = options.multiplier ?? 1.5
|
this.multiplier = options.multiplier ?? 1.01
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(callback: PollCallback) {
|
async start(callback: PollCallback) {
|
||||||
|
|||||||
204
src/utils/storage.ts
Normal file
204
src/utils/storage.ts
Normal 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
Reference in New Issue
Block a user