initial
This commit is contained in:
115
frontend/src/pages/CreateMarathonPage.tsx
Normal file
115
frontend/src/pages/CreateMarathonPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
frontend/src/pages/HomePage.tsx
Normal file
113
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
frontend/src/pages/LeaderboardPage.tsx
Normal file
119
frontend/src/pages/LeaderboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
265
frontend/src/pages/LobbyPage.tsx
Normal file
265
frontend/src/pages/LobbyPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
frontend/src/pages/LoginPage.tsx
Normal file
84
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
197
frontend/src/pages/MarathonPage.tsx
Normal file
197
frontend/src/pages/MarathonPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
frontend/src/pages/MarathonsPage.tsx
Normal file
158
frontend/src/pages/MarathonsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
315
frontend/src/pages/PlayPage.tsx
Normal file
315
frontend/src/pages/PlayPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
frontend/src/pages/RegisterPage.tsx
Normal file
115
frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
frontend/src/pages/index.ts
Normal file
9
frontend/src/pages/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user