challenge-pl/memory bank/frontend/CHALLENGE_REACT_EXAMPLE.md
Primakov Alexandr Alexandrovich 3a65307fd0
All checks were successful
platform/bro-js/challenge-pl/pipeline/head This commit looks good
init brojs
2025-11-02 17:44:37 +03:00

1061 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Challenge Service - React Example
Полный пример интеграции сервиса в React приложение с TypeScript.
## Структура проекта
```
src/
├── api/
│ └── challenge.ts # API клиент
├── components/
│ ├── AuthForm.tsx
│ ├── ChainList.tsx
│ ├── TaskView.tsx
│ ├── CheckStatus.tsx
│ ├── ResultView.tsx
│ └── UserStats.tsx
├── context/
│ └── ChallengeContext.tsx # State management
├── hooks/
│ ├── useChallenge.ts
│ ├── usePolling.ts
│ └── useSubmission.ts
├── types/
│ └── challenge.ts # TypeScript типы
└── App.tsx
```
## Полный код
### 1. TypeScript Types (`src/types/challenge.ts`)
```typescript
export interface ChallengeUser {
_id: string
id: string
nickname: string
createdAt: string
}
export interface ChallengeTask {
_id: string
id: string
title: string
description: string
createdAt: string
updatedAt: string
}
export interface ChallengeChain {
_id: string
id: string
name: string
tasks: ChallengeTask[]
createdAt: string
updatedAt: string
}
export type SubmissionStatus = 'pending' | 'in_progress' | 'accepted' | 'needs_revision'
export interface ChallengeSubmission {
_id: string
id: string
user: string
task: string
result: string
status: SubmissionStatus
queueId?: string
feedback?: string
submittedAt: string
checkedAt?: string
attemptNumber: number
}
export type QueueStatusType = 'waiting' | 'in_progress' | 'completed' | 'error' | 'not_found'
export interface QueueStatus {
status: QueueStatusType
submission?: ChallengeSubmission & { task: ChallengeTask }
error?: string
position?: number
}
export interface UserStats {
totalTasksAttempted: number
completedTasks: number
inProgressTasks: number
needsRevisionTasks: number
totalSubmissions: number
averageCheckTimeMs: number
taskStats: Array<{
taskId: string
taskTitle: string
totalAttempts: number
status: string
lastAttemptAt: string | null
}>
chainStats: Array<{
chainId: string
chainName: string
totalTasks: number
completedTasks: number
progress: number
}>
}
```
### 2. API Client (`src/api/challenge.ts`)
```typescript
import type {
ChallengeChain,
ChallengeSubmission,
QueueStatus,
UserStats,
} from '../types/challenge'
const API_BASE = 'http://localhost:8082/api/challenge'
interface APIResponse<T> {
error: any
data: T
}
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
...options,
})
const json: APIResponse<T> = await response.json()
if (json.error) {
throw new Error(json.error.message || 'API Error')
}
return json.data
}
export const challengeAPI = {
// Аутентификация
async auth(nickname: string) {
return request<{ ok: boolean; userId: string }>('/auth', {
method: 'POST',
body: JSON.stringify({ nickname }),
})
},
// Цепочки
async getChains() {
return request<ChallengeChain[]>('/chains')
},
async getChain(chainId: string) {
return request<ChallengeChain>(`/chain/${chainId}`)
},
// Отправка решения
async submit(userId: string, taskId: string, result: string) {
return request<{ queueId: string; submissionId: string }>('/submit', {
method: 'POST',
body: JSON.stringify({ userId, taskId, result }),
})
},
// Проверка статуса
async checkStatus(queueId: string) {
return request<QueueStatus>(`/check-status/${queueId}`)
},
// Статистика
async getUserStats(userId: string) {
return request<UserStats>(`/user/${userId}/stats`)
},
// Попытки
async getSubmissions(userId: string, taskId?: string) {
const query = taskId ? `?taskId=${taskId}` : ''
return request<ChallengeSubmission[]>(`/user/${userId}/submissions${query}`)
},
}
```
### 3. Context (`src/context/ChallengeContext.tsx`)
```typescript
import React, { createContext, useContext, useState, useEffect } from 'react'
import { challengeAPI } from '../api/challenge'
import type { UserStats } from '../types/challenge'
interface ChallengeContextType {
userId: string | null
nickname: string | null
stats: UserStats | null
isAuthenticated: boolean
login: (nickname: string) => Promise<void>
logout: () => void
refreshStats: () => Promise<void>
}
const ChallengeContext = createContext<ChallengeContextType | undefined>(undefined)
export function ChallengeProvider({ children }: { children: React.ReactNode }) {
const [userId, setUserId] = useState<string | null>(
localStorage.getItem('challenge_user_id')
)
const [nickname, setNickname] = useState<string | null>(
localStorage.getItem('challenge_nickname')
)
const [stats, setStats] = useState<UserStats | null>(null)
const login = async (nickname: string) => {
const { userId } = await challengeAPI.auth(nickname)
setUserId(userId)
setNickname(nickname)
localStorage.setItem('challenge_user_id', userId)
localStorage.setItem('challenge_nickname', nickname)
}
const logout = () => {
setUserId(null)
setNickname(null)
setStats(null)
localStorage.removeItem('challenge_user_id')
localStorage.removeItem('challenge_nickname')
}
const refreshStats = async () => {
if (userId) {
const userStats = await challengeAPI.getUserStats(userId)
setStats(userStats)
}
}
useEffect(() => {
if (userId) {
refreshStats()
}
}, [userId])
return (
<ChallengeContext.Provider
value={{
userId,
nickname,
stats,
isAuthenticated: !!userId,
login,
logout,
refreshStats,
}}
>
{children}
</ChallengeContext.Provider>
)
}
export function useChallenge() {
const context = useContext(ChallengeContext)
if (!context) {
throw new Error('useChallenge must be used within ChallengeProvider')
}
return context
}
```
### 4. Custom Hooks
#### `src/hooks/usePolling.ts`
```typescript
import { useEffect, useRef } from 'react'
export function usePolling(
callback: () => Promise<boolean>,
interval: number = 2000,
enabled: boolean = true
) {
const timeoutRef = useRef<NodeJS.Timeout>()
useEffect(() => {
if (!enabled) return
const poll = async () => {
try {
const shouldContinue = await callback()
if (shouldContinue) {
timeoutRef.current = setTimeout(poll, interval)
}
} catch (error) {
console.error('Polling error:', error)
}
}
poll()
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [callback, interval, enabled])
}
```
#### `src/hooks/useSubmission.ts`
```typescript
import { useState } from 'react'
import { challengeAPI } from '../api/challenge'
import type { ChallengeSubmission, QueueStatus } from '../types/challenge'
export function useSubmission(userId: string, taskId: string) {
const [result, setResult] = useState('')
const [submitting, setSubmitting] = useState(false)
const [queueId, setQueueId] = useState<string | null>(null)
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null)
const [finalSubmission, setFinalSubmission] = useState<ChallengeSubmission | null>(null)
const submit = async () => {
if (!result.trim()) return
setSubmitting(true)
try {
const { queueId: newQueueId } = await challengeAPI.submit(userId, taskId, result)
setQueueId(newQueueId)
} catch (error) {
console.error('Submit error:', error)
alert('Ошибка отправки решения')
} finally {
setSubmitting(false)
}
}
const checkStatus = async () => {
if (!queueId) return false
const status = await challengeAPI.checkStatus(queueId)
setQueueStatus(status)
if (status.status === 'completed' && status.submission) {
setFinalSubmission(status.submission as any)
return false // Останавливаем polling
}
if (status.status === 'error') {
return false // Останавливаем polling
}
return true // Продолжаем polling
}
const reset = () => {
setResult('')
setQueueId(null)
setQueueStatus(null)
setFinalSubmission(null)
}
return {
result,
setResult,
submitting,
queueId,
queueStatus,
finalSubmission,
submit,
checkStatus,
reset,
}
}
```
### 5. Components
#### `src/components/AuthForm.tsx`
```typescript
import React, { useState } from 'react'
import { useChallenge } from '../context/ChallengeContext'
export function AuthForm() {
const [nickname, setNickname] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { login } = useChallenge()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
await login(nickname)
} catch (err) {
setError('Ошибка входа. Попробуйте другой nickname.')
} finally {
setLoading(false)
}
}
return (
<div className="auth-form">
<h2>Вход в систему</h2>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Введите ваш nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
minLength={3}
maxLength={50}
required
disabled={loading}
/>
<button type="submit" disabled={loading || !nickname.trim()}>
{loading ? 'Вход...' : 'Войти'}
</button>
{error && <div className="error">{error}</div>}
</form>
</div>
)
}
```
#### `src/components/ChainList.tsx`
```typescript
import React, { useState, useEffect } from 'react'
import { challengeAPI } from '../api/challenge'
import { useChallenge } from '../context/ChallengeContext'
import type { ChallengeChain } from '../types/challenge'
interface Props {
onSelectChain: (chain: ChallengeChain) => void
}
export function ChainList({ onSelectChain }: Props) {
const [chains, setChains] = useState<ChallengeChain[]>([])
const [loading, setLoading] = useState(true)
const { stats } = useChallenge()
useEffect(() => {
challengeAPI
.getChains()
.then(setChains)
.finally(() => setLoading(false))
}, [])
if (loading) return <div>Загрузка цепочек...</div>
return (
<div className="chain-list">
<h2>Доступные цепочки заданий</h2>
{chains.map((chain) => {
const chainProgress = stats?.chainStats.find((cs) => cs.chainId === chain.id)
return (
<div
key={chain.id}
className="chain-card"
onClick={() => onSelectChain(chain)}
>
<h3>{chain.name}</h3>
<p>{chain.tasks.length} заданий</p>
{chainProgress && (
<div className="progress-container">
<div
className="progress-bar"
style={{ width: `${chainProgress.progress}%` }}
/>
<span className="progress-text">
{chainProgress.completedTasks} / {chainProgress.totalTasks} выполнено
</span>
</div>
)}
</div>
)
})}
</div>
)
}
```
#### `src/components/TaskView.tsx`
```typescript
import React from 'react'
import ReactMarkdown from 'react-markdown'
import { useSubmission } from '../hooks/useSubmission'
import { usePolling } from '../hooks/usePolling'
import { useChallenge } from '../context/ChallengeContext'
import { CheckStatus } from './CheckStatus'
import { ResultView } from './ResultView'
import type { ChallengeTask } from '../types/challenge'
interface Props {
task: ChallengeTask
onComplete: () => void
}
export function TaskView({ task, onComplete }: Props) {
const { userId } = useChallenge()
const {
result,
setResult,
submitting,
queueId,
queueStatus,
finalSubmission,
submit,
checkStatus,
reset,
} = useSubmission(userId!, task.id)
// Запускаем polling когда есть queueId
usePolling(checkStatus, 2000, !!queueId && !finalSubmission)
if (finalSubmission) {
return (
<ResultView
submission={finalSubmission}
onRetry={reset}
onNext={onComplete}
/>
)
}
if (queueId) {
return <CheckStatus status={queueStatus} />
}
return (
<div className="task-view">
<h2>{task.title}</h2>
<div className="task-description">
<ReactMarkdown>{task.description}</ReactMarkdown>
</div>
<div className="solution-editor">
<h3>Ваше решение:</h3>
<textarea
value={result}
onChange={(e) => setResult(e.target.value)}
placeholder="Напишите ваше решение здесь..."
rows={15}
disabled={submitting}
/>
</div>
<button onClick={submit} disabled={!result.trim() || submitting}>
{submitting ? 'Отправка...' : 'Отправить на проверку'}
</button>
</div>
)
}
```
#### `src/components/CheckStatus.tsx`
```typescript
import React from 'react'
import type { QueueStatus } from '../types/challenge'
interface Props {
status: QueueStatus | null
}
export function CheckStatus({ status }: Props) {
if (!status) {
return <div className="check-status">Инициализация...</div>
}
return (
<div className="check-status">
{status.status === 'waiting' && (
<>
<div className="spinner" />
<h3> Ожидание в очереди</h3>
{status.position && <p>Позиция в очереди: {status.position}</p>}
</>
)}
{status.status === 'in_progress' && (
<>
<div className="spinner" />
<h3>🔍 Проверяем ваше решение...</h3>
<p>Это может занять несколько секунд</p>
</>
)}
{status.status === 'error' && (
<>
<h3> Ошибка проверки</h3>
<p>{status.error || 'Неизвестная ошибка'}</p>
</>
)}
</div>
)
}
```
#### `src/components/ResultView.tsx`
```typescript
import React from 'react'
import type { ChallengeSubmission } from '../types/challenge'
interface Props {
submission: ChallengeSubmission
onRetry?: () => void
onNext?: () => void
}
export function ResultView({ submission, onRetry, onNext }: Props) {
const isAccepted = submission.status === 'accepted'
return (
<div className={`result-view ${isAccepted ? 'accepted' : 'needs-revision'}`}>
<div className="result-icon">{isAccepted ? '✅' : '❌'}</div>
<h2>{isAccepted ? 'Задание принято!' : 'Требуется доработка'}</h2>
<div className="feedback">
<h3>Комментарий от системы:</h3>
<p>{submission.feedback}</p>
</div>
<div className="result-meta">
<p>Попытка #{submission.attemptNumber}</p>
{submission.checkedAt && (
<p>
Время проверки:{' '}
{Math.round(
(new Date(submission.checkedAt).getTime() -
new Date(submission.submittedAt).getTime()) /
1000
)}{' '}
сек
</p>
)}
</div>
<div className="actions">
{isAccepted && onNext && (
<button onClick={onNext} className="btn-primary">
Следующее задание
</button>
)}
{!isAccepted && onRetry && (
<button onClick={onRetry} className="btn-secondary">
Попробовать снова
</button>
)}
</div>
</div>
)
}
```
### 6. Main App (`src/App.tsx`)
```typescript
import React, { useState } from 'react'
import { ChallengeProvider, useChallenge } from './context/ChallengeContext'
import { AuthForm } from './components/AuthForm'
import { ChainList } from './components/ChainList'
import { TaskView } from './components/TaskView'
import type { ChallengeChain, ChallengeTask } from './types/challenge'
import './App.css'
function AppContent() {
const { isAuthenticated, nickname, logout, refreshStats } = useChallenge()
const [selectedChain, setSelectedChain] = useState<ChallengeChain | null>(null)
const [currentTaskIndex, setCurrentTaskIndex] = useState(0)
if (!isAuthenticated) {
return <AuthForm />
}
const handleTaskComplete = () => {
refreshStats()
if (selectedChain && currentTaskIndex < selectedChain.tasks.length - 1) {
setCurrentTaskIndex(currentTaskIndex + 1)
} else {
// Цепочка завершена
setSelectedChain(null)
setCurrentTaskIndex(0)
}
}
const handleBackToChains = () => {
setSelectedChain(null)
setCurrentTaskIndex(0)
}
if (selectedChain) {
const currentTask = selectedChain.tasks[currentTaskIndex]
return (
<div className="app-container">
<header>
<h1>Challenge Platform</h1>
<div className="user-info">
<span>👤 {nickname}</span>
<button onClick={logout}>Выйти</button>
</div>
</header>
<div className="chain-progress">
<button onClick={handleBackToChains}> Назад к цепочкам</button>
<h3>{selectedChain.name}</h3>
<p>
Задание {currentTaskIndex + 1} из {selectedChain.tasks.length}
</p>
</div>
<main>
<TaskView task={currentTask} onComplete={handleTaskComplete} />
</main>
</div>
)
}
return (
<div className="app-container">
<header>
<h1>Challenge Platform</h1>
<div className="user-info">
<span>👤 {nickname}</span>
<button onClick={logout}>Выйти</button>
</div>
</header>
<main>
<ChainList onSelectChain={setSelectedChain} />
</main>
</div>
)
}
function App() {
return (
<ChallengeProvider>
<AppContent />
</ChallengeProvider>
)
}
export default App
```
### 7. Styles (`src/App.css`)
```css
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 2px solid #eee;
margin-bottom: 40px;
}
.user-info {
display: flex;
gap: 15px;
align-items: center;
}
/* Auth Form */
.auth-form {
max-width: 400px;
margin: 100px auto;
padding: 40px;
border: 1px solid #ddd;
border-radius: 8px;
}
.auth-form input {
width: 100%;
padding: 12px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.auth-form button {
width: 100%;
padding: 12px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.auth-form button:disabled {
background: #ccc;
cursor: not-allowed;
}
.error {
color: #dc3545;
margin-top: 10px;
}
/* Chain List */
.chain-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.chain-card {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.chain-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.progress-container {
margin-top: 15px;
}
.progress-bar {
height: 8px;
background: #007bff;
border-radius: 4px;
transition: width 0.3s;
}
.progress-text {
display: block;
margin-top: 5px;
font-size: 14px;
color: #666;
}
/* Task View */
.task-view {
max-width: 800px;
margin: 0 auto;
}
.task-description {
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
margin: 20px 0;
}
.solution-editor textarea {
width: 100%;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 14px;
}
/* Check Status */
.check-status {
text-align: center;
padding: 60px 20px;
}
.spinner {
width: 50px;
height: 50px;
border: 5px solid #f3f3f3;
border-top: 5px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Result View */
.result-view {
max-width: 600px;
margin: 40px auto;
padding: 40px;
border-radius: 8px;
text-align: center;
}
.result-view.accepted {
background: #d4edda;
border: 2px solid #28a745;
}
.result-view.needs-revision {
background: #f8d7da;
border: 2px solid #dc3545;
}
.result-icon {
font-size: 64px;
margin-bottom: 20px;
}
.feedback {
margin: 30px 0;
padding: 20px;
background: white;
border-radius: 4px;
text-align: left;
}
.result-meta {
color: #666;
font-size: 14px;
}
.actions {
margin-top: 30px;
display: flex;
gap: 10px;
justify-content: center;
}
button {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
button:hover {
opacity: 0.9;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
```
## Запуск проекта
```bash
# Установка зависимостей
npm install react react-dom
npm install -D @types/react @types/react-dom typescript
npm install react-markdown
# Запуск dev сервера
npm run dev
```
## package.json
```json
{
"name": "challenge-react-app",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}
```
## Особенности реализации
### 1. Автоматический polling
- Использует custom hook `usePolling`
- Запускается только когда есть `queueId`
- Останавливается при получении финального результата
### 2. State management
- Context API для глобального состояния
- Local state для компонентов
- LocalStorage для персистентности
### 3. Error handling
- Try-catch блоки для всех API вызовов
- Отображение ошибок пользователю
- Graceful degradation
### 4. UX оптимизации
- Loading состояния
- Disabled кнопки во время запросов
- Прогресс бары для цепочек
- Индикаторы позиции в очереди
### 5. TypeScript
- Полная типизация
- Autocompletion в IDE
- Меньше runtime ошибок
## Расширения
Добавьте эти фичи для улучшения UX:
1. **Автосохранение черновиков**
2. **Таймер на задание**
3. **История попыток с детализацией**
4. **Markdown preview в редакторе**
5. **Dark mode**
6. **Горячие клавиши**
7. **Экспорт статистики**
Используйте этот пример как основу для вашего приложения!