This commit is contained in:
2025-12-14 02:38:35 +07:00
commit 5343a8f2c3
84 changed files with 7406 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
const createSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100),
description: z.string().optional(),
start_date: z.string().min(1, 'Дата начала обязательна'),
duration_days: z.number().min(1).max(365).default(30),
})
type CreateForm = z.infer<typeof createSchema>
export function CreateMarathonPage() {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateForm>({
resolver: zodResolver(createSchema),
defaultValues: {
duration_days: 30,
},
})
const onSubmit = async (data: CreateForm) => {
setIsLoading(true)
setError(null)
try {
const marathon = await marathonsApi.create({
...data,
start_date: new Date(data.start_date).toISOString(),
})
navigate(`/marathons/${marathon.id}/lobby`)
} catch (err: unknown) {
const apiError = err as { response?: { data?: { detail?: string } } }
setError(apiError.response?.data?.detail || 'Не удалось создать марафон')
} finally {
setIsLoading(false)
}
}
return (
<div className="max-w-lg mx-auto">
<Card>
<CardHeader>
<CardTitle>Создать марафон</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
{error}
</div>
)}
<Input
label="Название"
placeholder="Введите название марафона"
error={errors.title?.message}
{...register('title')}
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Описание (необязательно)
</label>
<textarea
className="input min-h-[100px] resize-none"
placeholder="Введите описание"
{...register('description')}
/>
</div>
<Input
label="Дата начала"
type="datetime-local"
error={errors.start_date?.message}
{...register('start_date')}
/>
<Input
label="Длительность (дней)"
type="number"
error={errors.duration_days?.message}
{...register('duration_days', { valueAsNumber: true })}
/>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate('/marathons')}
>
Отмена
</Button>
<Button type="submit" className="flex-1" isLoading={isLoading}>
Создать
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,113 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Button } from '@/components/ui'
import { Gamepad2, Users, Trophy, Sparkles } from 'lucide-react'
export function HomePage() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
return (
<div className="max-w-4xl mx-auto text-center">
{/* Hero */}
<div className="py-12">
<div className="flex justify-center mb-6">
<Gamepad2 className="w-20 h-20 text-primary-500" />
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
Игровой Марафон
</h1>
<p className="text-xl text-gray-400 mb-8 max-w-2xl mx-auto">
Соревнуйтесь с друзьями в игровых челленджах. Крутите колесо, выполняйте задания, зарабатывайте очки и станьте чемпионом!
</p>
<div className="flex gap-4 justify-center">
{isAuthenticated ? (
<Link to="/marathons">
<Button size="lg">К марафонам</Button>
</Link>
) : (
<>
<Link to="/register">
<Button size="lg">Начать</Button>
</Link>
<Link to="/login">
<Button size="lg" variant="secondary">Войти</Button>
</Link>
</>
)}
</div>
</div>
{/* Features */}
<div className="grid md:grid-cols-3 gap-8 py-12">
<div className="card text-center">
<div className="flex justify-center mb-4">
<Sparkles className="w-12 h-12 text-yellow-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Случайные челленджи</h3>
<p className="text-gray-400">
Крутите колесо, чтобы получить случайную игру и задание. Проверьте свои навыки неожиданным способом!
</p>
</div>
<div className="card text-center">
<div className="flex justify-center mb-4">
<Users className="w-12 h-12 text-green-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Играйте с друзьями</h3>
<p className="text-gray-400">
Создавайте приватные марафоны и приглашайте друзей. Каждый добавляет свои любимые игры.
</p>
</div>
<div className="card text-center">
<div className="flex justify-center mb-4">
<Trophy className="w-12 h-12 text-primary-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Соревнуйтесь за очки</h3>
<p className="text-gray-400">
Выполняйте задания, чтобы зарабатывать очки. Собирайте серии для бонусных множителей!
</p>
</div>
</div>
{/* How it works */}
<div className="py-12">
<h2 className="text-2xl font-bold text-white mb-8">Как это работает</h2>
<div className="grid md:grid-cols-4 gap-6 text-left">
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">1</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Создайте марафон</h4>
<p className="text-gray-400 text-sm">Начните новый марафон и пригласите друзей по уникальному коду</p>
</div>
</div>
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">2</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Добавьте игры</h4>
<p className="text-gray-400 text-sm">Все добавляют игры, в которые хотят играть. ИИ генерирует задания</p>
</div>
</div>
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">3</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Крутите и играйте</h4>
<p className="text-gray-400 text-sm">Крутите колесо, получите задание, выполните его и отправьте доказательство</p>
</div>
</div>
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">4</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Победите!</h4>
<p className="text-gray-400 text-sm">Зарабатывайте очки, поднимайтесь в таблице лидеров, станьте чемпионом!</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { LeaderboardEntry } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Trophy, Flame, ArrowLeft, Loader2 } from 'lucide-react'
export function LeaderboardPage() {
const { id } = useParams<{ id: string }>()
const user = useAuthStore((state) => state.user)
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
loadLeaderboard()
}, [id])
const loadLeaderboard = async () => {
if (!id) return
try {
const data = await marathonsApi.getLeaderboard(parseInt(id))
setLeaderboard(data)
} catch (error) {
console.error('Failed to load leaderboard:', error)
} finally {
setIsLoading(false)
}
}
const getRankIcon = (rank: number) => {
switch (rank) {
case 1:
return <Trophy className="w-6 h-6 text-yellow-500" />
case 2:
return <Trophy className="w-6 h-6 text-gray-400" />
case 3:
return <Trophy className="w-6 h-6 text-amber-700" />
default:
return <span className="text-gray-500 font-mono w-6 text-center">{rank}</span>
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<Link to={`/marathons/${id}`} className="text-gray-400 hover:text-white">
<ArrowLeft className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" />
Рейтинг
</CardTitle>
</CardHeader>
<CardContent>
{leaderboard.length === 0 ? (
<p className="text-center text-gray-400 py-8">Пока нет участников</p>
) : (
<div className="space-y-2">
{leaderboard.map((entry) => (
<div
key={entry.user.id}
className={`flex items-center gap-4 p-4 rounded-lg ${
entry.user.id === user?.id
? 'bg-primary-500/20 border border-primary-500/50'
: 'bg-gray-900'
}`}
>
<div className="flex items-center justify-center w-8">
{getRankIcon(entry.rank)}
</div>
<div className="flex-1">
<div className="font-medium text-white">
{entry.user.nickname}
{entry.user.id === user?.id && (
<span className="ml-2 text-xs text-primary-400">(Вы)</span>
)}
</div>
<div className="text-sm text-gray-400">
{entry.completed_count} выполнено, {entry.dropped_count} пропущено
</div>
</div>
{entry.current_streak > 0 && (
<div className="flex items-center gap-1 text-yellow-500">
<Flame className="w-4 h-4" />
<span className="text-sm">{entry.current_streak}</span>
</div>
)}
<div className="text-right">
<div className="text-xl font-bold text-primary-400">
{entry.total_points}
</div>
<div className="text-xs text-gray-500">очков</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,265 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { marathonsApi, gamesApi } from '@/api'
import type { Marathon, Game } from '@/types'
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Plus, Trash2, Sparkles, Play, Loader2, Gamepad2 } from 'lucide-react'
export function LobbyPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [games, setGames] = useState<Game[]>([])
const [isLoading, setIsLoading] = useState(true)
// Add game form
const [showAddGame, setShowAddGame] = useState(false)
const [gameTitle, setGameTitle] = useState('')
const [gameUrl, setGameUrl] = useState('')
const [gameGenre, setGameGenre] = useState('')
const [isAddingGame, setIsAddingGame] = useState(false)
// Generate challenges
const [isGenerating, setIsGenerating] = useState(false)
const [generateMessage, setGenerateMessage] = useState<string | null>(null)
// Start marathon
const [isStarting, setIsStarting] = useState(false)
useEffect(() => {
loadData()
}, [id])
const loadData = async () => {
if (!id) return
try {
const [marathonData, gamesData] = await Promise.all([
marathonsApi.get(parseInt(id)),
gamesApi.list(parseInt(id)),
])
setMarathon(marathonData)
setGames(gamesData)
} catch (error) {
console.error('Failed to load data:', error)
navigate('/marathons')
} finally {
setIsLoading(false)
}
}
const handleAddGame = async () => {
if (!id || !gameTitle.trim() || !gameUrl.trim()) return
setIsAddingGame(true)
try {
await gamesApi.create(parseInt(id), {
title: gameTitle.trim(),
download_url: gameUrl.trim(),
genre: gameGenre.trim() || undefined,
})
setGameTitle('')
setGameUrl('')
setGameGenre('')
setShowAddGame(false)
await loadData()
} catch (error) {
console.error('Failed to add game:', error)
} finally {
setIsAddingGame(false)
}
}
const handleDeleteGame = async (gameId: number) => {
if (!confirm('Удалить эту игру?')) return
try {
await gamesApi.delete(gameId)
await loadData()
} catch (error) {
console.error('Failed to delete game:', error)
}
}
const handleGenerateChallenges = async () => {
if (!id) return
setIsGenerating(true)
setGenerateMessage(null)
try {
const result = await gamesApi.generateChallenges(parseInt(id))
setGenerateMessage(result.message)
await loadData()
} catch (error) {
console.error('Failed to generate challenges:', error)
setGenerateMessage('Не удалось сгенерировать задания')
} finally {
setIsGenerating(false)
}
}
const handleStartMarathon = async () => {
if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return
setIsStarting(true)
try {
await marathonsApi.start(parseInt(id))
navigate(`/marathons/${id}/play`)
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось запустить марафон')
} finally {
setIsStarting(false)
}
}
if (isLoading || !marathon) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
const isOrganizer = user?.id === marathon.organizer.id
const totalChallenges = games.reduce((sum, g) => sum + g.challenges_count, 0)
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-white">{marathon.title}</h1>
<p className="text-gray-400">Настройка - Добавьте игры и сгенерируйте задания</p>
</div>
{isOrganizer && (
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={games.length === 0}>
<Play className="w-4 h-4 mr-2" />
Запустить марафон
</Button>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{games.length}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Gamepad2 className="w-4 h-4" />
Игр
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{totalChallenges}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Sparkles className="w-4 h-4" />
Заданий
</div>
</CardContent>
</Card>
</div>
{/* Generate challenges button */}
{games.length > 0 && (
<Card className="mb-8">
<CardContent>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">Генерация заданий</h3>
<p className="text-sm text-gray-400">
Используйте ИИ для генерации заданий для всех игр без заданий
</p>
</div>
<Button onClick={handleGenerateChallenges} isLoading={isGenerating} variant="secondary">
<Sparkles className="w-4 h-4 mr-2" />
Сгенерировать
</Button>
</div>
{generateMessage && (
<p className="mt-3 text-sm text-primary-400">{generateMessage}</p>
)}
</CardContent>
</Card>
)}
{/* Games list */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Игры</CardTitle>
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
<Plus className="w-4 h-4 mr-1" />
Добавить игру
</Button>
</CardHeader>
<CardContent>
{/* Add game form */}
{showAddGame && (
<div className="mb-6 p-4 bg-gray-900 rounded-lg space-y-3">
<Input
placeholder="Название игры"
value={gameTitle}
onChange={(e) => setGameTitle(e.target.value)}
/>
<Input
placeholder="Ссылка для скачивания"
value={gameUrl}
onChange={(e) => setGameUrl(e.target.value)}
/>
<Input
placeholder="Жанр (необязательно)"
value={gameGenre}
onChange={(e) => setGameGenre(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}>
Добавить
</Button>
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
Отмена
</Button>
</div>
</div>
)}
{/* Games */}
{games.length === 0 ? (
<p className="text-center text-gray-400 py-8">
Пока нет игр. Добавьте игры, чтобы начать!
</p>
) : (
<div className="space-y-3">
{games.map((game) => (
<div
key={game.id}
className="flex items-center justify-between p-4 bg-gray-900 rounded-lg"
>
<div>
<h4 className="font-medium text-white">{game.title}</h4>
<div className="text-sm text-gray-400">
{game.genre && <span className="mr-3">{game.genre}</span>}
<span>{game.challenges_count} заданий</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGame(game.id)}
className="text-red-400 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
password: z.string().min(6, 'Пароль должен быть не менее 6 символов'),
})
type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() {
const navigate = useNavigate()
const { login, isLoading, error, clearError } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
})
const onSubmit = async (data: LoginForm) => {
setSubmitError(null)
clearError()
try {
await login(data)
navigate('/marathons')
} catch {
setSubmitError(error || 'Ошибка входа')
}
}
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader>
<CardTitle className="text-center">Вход</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
{submitError || error}
</div>
)}
<Input
label="Логин"
placeholder="Введите логин"
error={errors.login?.message}
{...register('login')}
/>
<Input
label="Пароль"
type="password"
placeholder="Введите пароль"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
Войти
</Button>
<p className="text-center text-gray-400 text-sm">
Нет аккаунта?{' '}
<Link to="/register" className="link">
Зарегистрироваться
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { Marathon } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2 } from 'lucide-react'
import { format } from 'date-fns'
export function MarathonPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [copied, setCopied] = useState(false)
useEffect(() => {
loadMarathon()
}, [id])
const loadMarathon = async () => {
if (!id) return
try {
const data = await marathonsApi.get(parseInt(id))
setMarathon(data)
} catch (error) {
console.error('Failed to load marathon:', error)
navigate('/marathons')
} finally {
setIsLoading(false)
}
}
const copyInviteCode = () => {
if (marathon) {
navigator.clipboard.writeText(marathon.invite_code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (isLoading || !marathon) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
const isOrganizer = user?.id === marathon.organizer.id
const isParticipant = !!marathon.my_participation
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex justify-between items-start mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">{marathon.title}</h1>
{marathon.description && (
<p className="text-gray-400">{marathon.description}</p>
)}
</div>
<div className="flex gap-2">
{marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary">
<Settings className="w-4 h-4 mr-2" />
Настройка
</Button>
</Link>
)}
{marathon.status === 'active' && isParticipant && (
<Link to={`/marathons/${id}/play`}>
<Button>
<Play className="w-4 h-4 mr-2" />
Играть
</Button>
</Link>
)}
<Link to={`/marathons/${id}/leaderboard`}>
<Button variant="secondary">
<Trophy className="w-4 h-4 mr-2" />
Рейтинг
</Button>
</Link>
</div>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Users className="w-4 h-4" />
Участников
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
<div className="text-sm text-gray-400">Игр</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Calendar className="w-4 h-4" />
Дата начала
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className={`text-2xl font-bold ${
marathon.status === 'active' ? 'text-green-500' :
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
}`}>
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
</div>
<div className="text-sm text-gray-400">Статус</div>
</CardContent>
</Card>
</div>
{/* Invite code */}
{marathon.status !== 'finished' && (
<Card className="mb-8">
<CardContent>
<h3 className="font-medium text-white mb-3">Код приглашения</h3>
<div className="flex items-center gap-3">
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono">
{marathon.invite_code}
</code>
<Button variant="secondary" onClick={copyInviteCode}>
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Скопировано!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Копировать
</>
)}
</Button>
</div>
<p className="text-sm text-gray-500 mt-2">
Поделитесь этим кодом с друзьями, чтобы они могли присоединиться к марафону
</p>
</CardContent>
</Card>
)}
{/* My stats */}
{marathon.my_participation && (
<Card>
<CardContent>
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-primary-500">
{marathon.my_participation.total_points}
</div>
<div className="text-sm text-gray-400">Очков</div>
</div>
<div>
<div className="text-2xl font-bold text-yellow-500">
{marathon.my_participation.current_streak}
</div>
<div className="text-sm text-gray-400">Серия</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-400">
{marathon.my_participation.drop_count}
</div>
<div className="text-sm text-gray-400">Пропусков</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,158 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { Plus, Users, Calendar, Loader2 } from 'lucide-react'
import { format } from 'date-fns'
export function MarathonsPage() {
const [marathons, setMarathons] = useState<MarathonListItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [joinCode, setJoinCode] = useState('')
const [joinError, setJoinError] = useState<string | null>(null)
const [isJoining, setIsJoining] = useState(false)
useEffect(() => {
loadMarathons()
}, [])
const loadMarathons = async () => {
try {
const data = await marathonsApi.list()
setMarathons(data)
} catch (error) {
console.error('Failed to load marathons:', error)
} finally {
setIsLoading(false)
}
}
const handleJoin = async () => {
if (!joinCode.trim()) return
setJoinError(null)
setIsJoining(true)
try {
await marathonsApi.join(joinCode.trim())
setJoinCode('')
await loadMarathons()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
setJoinError(error.response?.data?.detail || 'Не удалось присоединиться')
} finally {
setIsJoining(false)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'preparing':
return 'bg-yellow-500/20 text-yellow-500'
case 'active':
return 'bg-green-500/20 text-green-500'
case 'finished':
return 'bg-gray-500/20 text-gray-400'
default:
return 'bg-gray-500/20 text-gray-400'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'preparing':
return 'Подготовка'
case 'active':
return 'Активен'
case 'finished':
return 'Завершён'
default:
return status
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-white">Мои марафоны</h1>
<Link to="/marathons/create">
<Button>
<Plus className="w-4 h-4 mr-2" />
Создать марафон
</Button>
</Link>
</div>
{/* Join marathon */}
<Card className="mb-8">
<CardContent>
<h3 className="font-medium text-white mb-3">Присоединиться к марафону</h3>
<div className="flex gap-3">
<input
type="text"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
placeholder="Введите код приглашения"
className="input flex-1"
/>
<Button onClick={handleJoin} isLoading={isJoining}>
Присоединиться
</Button>
</div>
{joinError && <p className="mt-2 text-sm text-red-500">{joinError}</p>}
</CardContent>
</Card>
{/* Marathon list */}
{marathons.length === 0 ? (
<Card>
<CardContent className="text-center py-8">
<p className="text-gray-400 mb-4">У вас пока нет марафонов</p>
<Link to="/marathons/create">
<Button>Создать первый марафон</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{marathons.map((marathon) => (
<Link key={marathon.id} to={`/marathons/${marathon.id}`}>
<Card className="hover:bg-gray-700/50 transition-colors cursor-pointer">
<CardContent className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-white mb-1">
{marathon.title}
</h3>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{marathon.participants_count} участников
</span>
{marathon.start_date && (
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{format(new Date(marathon.start_date), 'MMM d, yyyy')}
</span>
)}
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm ${getStatusColor(marathon.status)}`}>
{getStatusText(marathon.status)}
</span>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,315 @@
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { marathonsApi, wheelApi } from '@/api'
import type { Marathon, Assignment, SpinResult } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { Loader2, Upload, X } from 'lucide-react'
export function PlayPage() {
const { id } = useParams<{ id: string }>()
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Spin state
const [isSpinning, setIsSpinning] = useState(false)
// Complete state
const [proofFile, setProofFile] = useState<File | null>(null)
const [proofUrl, setProofUrl] = useState('')
const [comment, setComment] = useState('')
const [isCompleting, setIsCompleting] = useState(false)
// Drop state
const [isDropping, setIsDropping] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadData()
}, [id])
const loadData = async () => {
if (!id) return
try {
const [marathonData, assignment] = await Promise.all([
marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setIsLoading(false)
}
}
const handleSpin = async () => {
if (!id) return
setIsSpinning(true)
setSpinResult(null)
try {
const result = await wheelApi.spin(parseInt(id))
setSpinResult(result)
// Reload to get assignment
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось крутить')
} finally {
setIsSpinning(false)
}
}
const handleComplete = async () => {
if (!currentAssignment) return
if (!proofFile && !proofUrl) {
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
setIsCompleting(true)
try {
const result = await wheelApi.complete(currentAssignment.id, {
proof_file: proofFile || undefined,
proof_url: proofUrl || undefined,
comment: comment || undefined,
})
alert(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
// Reset form
setProofFile(null)
setProofUrl('')
setComment('')
setSpinResult(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выполнить')
} finally {
setIsCompleting(false)
}
}
const handleDrop = async () => {
if (!currentAssignment) return
const penalty = spinResult?.drop_penalty || 0
if (!confirm(`Пропустить это задание? Вы потеряете ${penalty} очков.`)) return
setIsDropping(true)
try {
const result = await wheelApi.drop(currentAssignment.id)
alert(`Пропущено. Штраф: -${result.penalty} очков`)
setSpinResult(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось пропустить')
} finally {
setIsDropping(false)
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
if (!marathon) {
return <div>Марафон не найден</div>
}
const participation = marathon.my_participation
return (
<div className="max-w-2xl mx-auto">
{/* Header stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
<Card>
<CardContent className="text-center py-3">
<div className="text-xl font-bold text-primary-500">
{participation?.total_points || 0}
</div>
<div className="text-xs text-gray-400">Очков</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-3">
<div className="text-xl font-bold text-yellow-500">
{participation?.current_streak || 0}
</div>
<div className="text-xs text-gray-400">Серия</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-3">
<div className="text-xl font-bold text-gray-400">
{participation?.drop_count || 0}
</div>
<div className="text-xs text-gray-400">Пропусков</div>
</CardContent>
</Card>
</div>
{/* No active assignment - show spin */}
{!currentAssignment && (
<Card className="text-center">
<CardContent className="py-12">
<h2 className="text-2xl font-bold text-white mb-4">Крутите колесо!</h2>
<p className="text-gray-400 mb-8">
Получите случайную игру и задание для выполнения
</p>
<Button size="lg" onClick={handleSpin} isLoading={isSpinning}>
{isSpinning ? 'Крутим...' : 'КРУТИТЬ'}
</Button>
</CardContent>
</Card>
)}
{/* Active assignment */}
{currentAssignment && (
<Card>
<CardContent>
<div className="text-center mb-6">
<span className="px-3 py-1 bg-primary-500/20 text-primary-400 rounded-full text-sm">
Активное задание
</span>
</div>
{/* Game */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Игра</h3>
<p className="text-xl font-bold text-white">
{currentAssignment.challenge.game.title}
</p>
</div>
{/* Challenge */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Задание</h3>
<p className="text-xl font-bold text-white mb-2">
{currentAssignment.challenge.title}
</p>
<p className="text-gray-300">
{currentAssignment.challenge.description}
</p>
</div>
{/* Points */}
<div className="flex items-center gap-4 mb-6 text-sm">
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full">
+{currentAssignment.challenge.points} очков
</span>
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full">
{currentAssignment.challenge.difficulty}
</span>
{currentAssignment.challenge.estimated_time && (
<span className="text-gray-400">
~{currentAssignment.challenge.estimated_time} мин
</span>
)}
</div>
{/* Proof hint */}
{currentAssignment.challenge.proof_hint && (
<div className="mb-6 p-3 bg-gray-900 rounded-lg">
<p className="text-sm text-gray-400">
<strong>Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
</p>
</div>
)}
{/* Proof upload */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({currentAssignment.challenge.proof_type})
</label>
{/* File upload */}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => setProofFile(e.target.files?.[0] || null)}
/>
{proofFile ? (
<div className="flex items-center gap-2 p-3 bg-gray-900 rounded-lg">
<span className="text-white flex-1 truncate">{proofFile.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setProofFile(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Button
variant="secondary"
className="w-full"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
Выбрать файл
</Button>
)}
</div>
<div className="text-center text-gray-500">или</div>
{/* URL input */}
<input
type="text"
className="input"
placeholder="Вставьте ссылку (YouTube, профиль Steam и т.д.)"
value={proofUrl}
onChange={(e) => setProofUrl(e.target.value)}
/>
{/* Comment */}
<textarea
className="input min-h-[80px] resize-none"
placeholder="Комментарий (необязательно)"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
className="flex-1"
onClick={handleComplete}
isLoading={isCompleting}
disabled={!proofFile && !proofUrl}
>
Выполнено
</Button>
<Button
variant="danger"
onClick={handleDrop}
isLoading={isDropping}
>
Пропустить (-{spinResult?.drop_penalty || 0})
</Button>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
const registerSchema = z.object({
login: z
.string()
.min(3, 'Логин должен быть не менее 3 символов')
.max(50, 'Логин должен быть не более 50 символов')
.regex(/^[a-zA-Z0-9_]+$/, 'Логин может содержать только буквы, цифры и подчёркивания'),
nickname: z
.string()
.min(2, 'Никнейм должен быть не менее 2 символов')
.max(50, 'Никнейм должен быть не более 50 символов'),
password: z.string().min(6, 'Пароль должен быть не менее 6 символов'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Пароли не совпадают',
path: ['confirmPassword'],
})
type RegisterForm = z.infer<typeof registerSchema>
export function RegisterPage() {
const navigate = useNavigate()
const { register: registerUser, isLoading, error, clearError } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
})
const onSubmit = async (data: RegisterForm) => {
setSubmitError(null)
clearError()
try {
await registerUser({
login: data.login,
password: data.password,
nickname: data.nickname,
})
navigate('/marathons')
} catch {
setSubmitError(error || 'Ошибка регистрации')
}
}
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader>
<CardTitle className="text-center">Регистрация</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
{submitError || error}
</div>
)}
<Input
label="Логин"
placeholder="Придумайте логин"
error={errors.login?.message}
{...register('login')}
/>
<Input
label="Никнейм"
placeholder="Придумайте никнейм"
error={errors.nickname?.message}
{...register('nickname')}
/>
<Input
label="Пароль"
type="password"
placeholder="Придумайте пароль"
error={errors.password?.message}
{...register('password')}
/>
<Input
label="Подтвердите пароль"
type="password"
placeholder="Повторите пароль"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
Зарегистрироваться
</Button>
<p className="text-center text-gray-400 text-sm">
Уже есть аккаунт?{' '}
<Link to="/login" className="link">
Войти
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,9 @@
export { HomePage } from './HomePage'
export { LoginPage } from './LoginPage'
export { RegisterPage } from './RegisterPage'
export { MarathonsPage } from './MarathonsPage'
export { CreateMarathonPage } from './CreateMarathonPage'
export { MarathonPage } from './MarathonPage'
export { LobbyPage } from './LobbyPage'
export { PlayPage } from './PlayPage'
export { LeaderboardPage } from './LeaderboardPage'