All checks were successful
platform/bro-js/challenge-pl/pipeline/head This commit looks good
1061 lines
24 KiB
Markdown
1061 lines
24 KiB
Markdown
# 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. **Экспорт статистики**
|
||
|
||
Используйте этот пример как основу для вашего приложения!
|
||
|